diff --git a/.github/check_license_headers.rb b/.github/check_license_headers.rb new file mode 100644 index 000000000..4c3c21fea --- /dev/null +++ b/.github/check_license_headers.rb @@ -0,0 +1,33 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +LICENSE = File.read('./.github/license-header.txt') +files = `git ls-files | grep -E '\.rb|Rakefile|\.rake|\.erb|Gemfile|gemspec'`.split("\n") +errors = [] + +files.each do |file| + unless File.read(file).include?(LICENSE) + errors << file + puts "#{file} doesn't contain the correct license header" + end +end + +if errors.empty? + puts 'All checked files have the correct license header' +else + exit 1 +end diff --git a/.github/license-header.txt b/.github/license-header.txt new file mode 100644 index 000000000..5b0f712aa --- /dev/null +++ b/.github/license-header.txt @@ -0,0 +1,16 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 000000000..9b77b1806 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,20 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 120 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 30 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - work-in-progress + - backport + - bug +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/jruby.yml b/.github/workflows/jruby.yml new file mode 100644 index 000000000..91dc5ff48 --- /dev/null +++ b/.github/workflows/jruby.yml @@ -0,0 +1,48 @@ +name: JRuby tests +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + branches: + - '*' +jobs: + tests: + env: + TEST_ES_SERVER: http://localhost:9200 + RAILS_VERSIONS: ${{ matrix.rails }} + strategy: + fail-fast: false + matrix: + rails: [ '6.1', '7.0', '7.1' ] + ruby: ['jruby-9.4'] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Increase system limits + run: | + sudo swapoff -a + sudo sysctl -w vm.swappiness=1 + sudo sysctl -w fs.file-max=262144 + sudo sysctl -w vm.max_map_count=262144 + - uses: elastic/elastic-github-actions/elasticsearch@master + with: + stack-version: 8.15.0-SNAPSHOT + security-enabled: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - name: Bundle + run: | + sudo apt-get install libsqlite3-dev libcurl4-openssl-dev + gem install bundler + bundle install + bundle exec rake bundle:clean + bundle exec rake bundle:install + - name: Test elasticsearch-rails + run: cd elasticsearch-rails && bundle exec rake test:all + - name: Test elasticsearch-persistence + run: cd elasticsearch-persistence && bundle exec rake test:all diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml new file mode 100644 index 000000000..27201b4b7 --- /dev/null +++ b/.github/workflows/license.yml @@ -0,0 +1,13 @@ +name: License headers +on: [pull_request, workflow_dispatch] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3 + - name: Check license headers + run: | + ruby ./.github/check_license_headers.rb diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..e2ae0ca8b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,47 @@ +name: Ruby tests +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + tests: + env: + ELASTICSEARCH_URL: http://localhost:9200 + RAILS_VERSIONS: ${{ matrix.rails }} + strategy: + fail-fast: false + matrix: + rails: [ '6.1', '7.0', '7.1' ] + ruby: ['3.1', '3.2', '3.3'] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Increase system limits + run: | + sudo swapoff -a + sudo sysctl -w vm.swappiness=1 + sudo sysctl -w fs.file-max=262144 + sudo sysctl -w vm.max_map_count=262144 + - uses: elastic/elastic-github-actions/elasticsearch@master + with: + stack-version: 8.15.0-SNAPSHOT + security-enabled: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - name: Bundle + run: | + sudo apt-get install libsqlite3-dev + gem install bundler + bundle install + bundle exec rake bundle:clean + bundle exec rake bundle:install + - name: Test elasticsearch-rails + run: cd elasticsearch-rails && bundle exec rake test:all + - name: Test elasticsearch-persistence + run: cd elasticsearch-persistence && bundle exec rake test:all + - name: Test elasticsearch-model + run: cd elasticsearch-model && bundle exec rake test:all diff --git a/.gitignore b/.gitignore index 6076dc96c..e3359ace9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store *.log tmp/ +.idea/* .yardoc/ _yardoc/ @@ -8,3 +9,4 @@ coverage/ rdoc/ doc/ Gemfile.lock +.byebug_history \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ee115b57a..000000000 --- a/.travis.yml +++ /dev/null @@ -1,35 +0,0 @@ -# ----------------------------------------------------------------------------- -# Configuration file for http://travis-ci.org/elasticsearch/elasticsearch-rails -# ----------------------------------------------------------------------------- - -language: ruby - -branches: - only: - - master - - travis - -rvm: - - 1.9.3 - - 2.0.0 - -jdk: - - openjdk7 - -env: - - TEST_BUNDLE_GEMFILE=$PWD/elasticsearch/elasticsearch-rails/elasticsearch-model/gemfiles/3.0.gemfile - - TEST_BUNDLE_GEMFILE=$PWD/elasticsearch/elasticsearch-rails/elasticsearch-model/gemfiles/4.0.gemfile - -services: - - mongodb - -before_script: - - ls -la /usr/share/elasticsearch/bin/elasticsearch - - rake bundle:clean - - rake bundle:install - -script: - - SERVER=launch TEST_CLUSTER_COMMAND=/usr/share/elasticsearch/bin/elasticsearch TEST_BUNDLE_GEMFILE=$TEST_BUNDLE_GEMFILE bundle exec rake test:all - -notifications: - disable: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..ce1eed141 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,256 @@ +## 8.0.0 + +First general availability release for `8.0.0`. Major versions of `8.x` will support Elasticsearch version `8.x` changing the `elasticsearch` dependency's major version: `gem 'elasticsearch', '~> 8'`. +All references to `type` should have been removed. Document types were deprecated and do not exist in `8.x`. + +The dependency from `elasticsearch` on `elasticsearch-transport` was updated to `elastic-transport`. All `8.x` Elasticsearch APIs supported by `elasticsearch` should now be supported on the Rails library. See [Release notes for the Elasticsearch client 8.0](https://www.elastic.co/guide/en/elasticsearch/client/ruby-api/current/release_notes_80.html) and the [8.x release notes](https://www.elastic.co/guide/en/elasticsearch/client/ruby-api/current/release_notes.html#_8_x) for more information. + +### Compatibility + +The gem is currently testing with Ruby 3.1, 3.2 and 3.3 and JRuby 9.4. Testing for Ruby `2.x` versions has been dropped as they're no longer updated or supported. Currently testing with Rails 6.1, 7.0 and 7.1. + +### Changes since 8.0.0.pre + +- Address Rails 7.1 deprecation warning in elasticsearch-rails [PR](https://github.com/elastic/elasticsearch-rails/pull/1067) +- Ensure subclasses are added to the registry [PR](https://github.com/elastic/elasticsearch-rails/pull/1073) + +### Development changes + +- Using `debug` for debugging in `development` and `testing` Gemfile groups. +- Minor general code cleanups and styling changes. +- Updated code for `elasticsearch` 8.x. + +## 7.2.1 + +* The default git branch `master` has been renamed to `main` +* Adds compatibility with Ruby 3 [Pull Request](https://github.com/elastic/elasticsearch-rails/pull/992) + +## 7.2.0 + +* Updates specs and dependency to use with `elasticsearch` v7.14.0. +* Update README, remove Virtus (unmaintained) +* Updates `Bundler.with_clean_dev` (deprecated) to `with_unbundled_env` [commit](https://github.com/elastic/elasticsearch-rails/commit/e4545e4fe2a1ce80009206c831d5740360bad6c2) +* Deal with `nil` document types in Multimodel [commit](https://github.com/elastic/elasticsearch-rails/commit/cd9c309b78de443d2e37760998418616ba34276d) +* Update dependency to explicitly support version 7 [commit](https://github.com/elastic/elasticsearch-rails/commit/65942e3da9cabad2f6965e69c8ef6a0994da9408) +* Stop emitting FATAL log when checking existence of indices [commit](https://github.com/elastic/elasticsearch-rails/commit/5db9207ca398c5d77f671109360ca7f63e3f2112) +* Remove unnecessary exception test on index checking [commit](https://github.com/elastic/elasticsearch-rails/commit/ce57cc17e304b0a4af123c1599f37fb892a5d93a) +* Removes dependency on extensions [commit](https://github.com/elastic/elasticsearch-rails/commit/ed070b8329ca48b4cb12b513ac81ed78c88acc61) +* Fixes basic template elasticsearch dependency [commit](https://github.com/elastic/elasticsearch-rails/commit/a4ec07b2d097545ca41c13686c9cbfc9eab9e639) + +### ActiveModel + +* Fixes indexing to use right logger in client +* Updates ES client spec for client 7.14.0 +* Updates transport references + +## 7.1.1 + +* Fix: Ruby 2.7 deprecation warning on `find_in_batches` +* Updates README for generating app with template. Related: #938 + +### ActiveModel + +* Do not override existing methods (#936) + +## 7.1.0 + +* Tested with elasticsearch Ruby client version 7.6.0 +* Updates rake version +* Adds pipeline to bulk params [commit](https://github.com/elastic/elasticsearch-rails/commit/63c24c9fe48a74d00c65145cc55c32f4c6907448) + +## 7.0.0 + +* Update test tasks and travis (#840) +* `respond_to_missing?` to silence Ruby 2.4 warnings (#838) +* Update README.md to link to migration blog post (#857) +* Add license headers, LICENSE and NOTICE files (#861) +* Only execute update if document attributes is not empty (#862) +* Remove bundler version requirement in gemspec files +* 7.0 support (#875) + +### ActiveModel + +* Fix import when preprocess returns empty collection (#720) +* Add test for not importing when ActiveRecord query is empty +* with 0 +* Port basic response tests to rspec (#833) +* Add newlines at the end of files that are missing it +* Port adapter tests to rspec (#834) +* Ensure that specified ActiveRecord order is not overwritten by Elasticsearch search results order (#835) +* Port remainder of Elasticsearch::Model unit tests to rspec (#836) +* Port all integration tests to rspec (#837) +* Avoid executing search twice; Reuse response in Response#raw_response (#850) +* Update example to account for deprecation of _suggest endpoint in favor of _search +* Handle total hits as an object in search response +* Use logger to log index not found message (#868) +* Test against Rails 6.0.rc1 + +### Persistence + +* Ensure that arguments are passed to super (#853) +* Index name option is handled by super, no need to pass options expicitly +* Handle total hits as an object in search response + +### Ruby on Rails + +* Convert tests to rspec (#842) +* Fix seeds file to stop using outdated YAML method (#843) +* Fixed 03-expert.rb set tracer only in dev env (#621) + +## 6.0.0 + +* Update to test against Elasticsearch 6.4 +* Fix sort order on ActiveRecord >= 5. re issue #546 (#831) + +### ActiveModel + +* Inherit from HashWrapper for disabling warnings +* Fix import method to pass index name on refresh (#692) +* Use default scope on ActiveRecord model when importing (#827) +* Support scope, query and preprocess importing options in Mongoid Adapter in 6.x (#829) +* Address performance of HashWrapper in Response objects (#825) + +### Persistence + +* Address performance of HashWrapper in Response objects (#825) +* Minor refactor in Repository::Search +* Remove example music app that demonstrates the AR pattern +* Update Sinatra app +* Update README +* Change document type references to _doc + +## 6.0.0.pre + +* Added the "Compatibility" chapter to the READMEs +* Updated the Bundler instructions and Github URLs in the READMEs +* Updated the version on the `master` branch to `6.0.0.alpha1` +* Update versions to 6.0.0.beta +* minor: Fix spacing +* Update various gemspecs to conditionally depend on gems incompatible with JRuby (#810) +* Update versions +* Use local as source for gem dependencies when possible +* Only require 'oj' gem if not using JRuby +* Update versions to .pre + +### ActiveModel + +* Added an example with a custom "pattern" analyzer +* Added a "trigram" custom analyzer to the example +* Fix README typo (s/situation/situations) +* Fix reference to @ids in example and README +* Add Callbacks to the example datamapper adapter +* Fix `Asynchronous Callbacks` example +* Fixed a typo in the README +* Improved the custom analyzer example +* Removed left-overs from previous implementation in the "completion suggester" example +* Updated the `changes` method name in `Indexing` to `changes_to_save` for compatibility with Rails 5.1 +* Fixed the handling of changed attributes in `Indexing` to work with older Rails versions +* Update child-parent integration test to use single index type for ES 6.3 (#805) +* Use default doc type: _doc (#814) +* Avoid making an update when no attributes are changed (#762) + +### Persistence + +* Updated the failing integration tests for Elasticsearch 5.x +* Updated the dependency for "elasticsearch" and "elasticsearch-model" to `5.x` +* Documentation for Model should include Model and not Repository +* Depend on version >= 6 of elasticsearch gems +* Undo last commit; depend on version 5 of elasticsearch gems +* Reduce repeated string instantiation (#813) +* Make default doc type '_doc' in preparation for deprecation of mapping types (#816) +* Remove Elasticsearch::Persistence::Model (ActiveRecord persistence pattern) (#812) +* Deprecate _all field in ES 6.x (#820) +* Remove development dependency on virtus, include explicitly in Gemfile for integration test +* Refactor Repository as mixin (#824) +* Add missing Repository::Response::Results spec +* Update README for Repository mixin refactor +* Minor typo in README +* Add #inspect method for Repository +* Update references to Elasticsearch::Client + +### Ruby on Rails + +* Fixed typo in README +* Fix typo in rake import task +* Updated the templates for example Rails applications +* Add 'oj' back as a development dependency in gemspec + +## 6.0.0.alpha1 + +* Updated the Rake dependency to 11.1 +* Reduced verbosity of `rake test:unit` and `rake test:integration` +* Removed the "CI Reporter" integration from test Rake tasks +* Added the "Compatibility" chapter to the READMEs +* Updated the Bundler instructions and Github URLs in the READMEs + +### ActiveModel + +* Fixed a problem where `Hashie::Mash#min` and `#max` returned unexpected values +* Added information about `elasticsearch-dsl` to the README +* Added support for inherited index names and doc types +* Added a `Elasticsearch::Model.settings` method +* Changed the naming inheritance logic to use `Elasticsearch::Model.settings` +* Added information about the `settings` method and the `inheritance_enabled` setting into the README +* Disable "verbose" and "warnings" in integration tests +* Added code for establishing ActiveRecord connections to test classes +* Reorganized the class definitions in the integration tests +* Moved `require` within unit test to the top of the file +* Added ActiveRecord 5 support to integration test configuration +* Fixed records sorting with ActiveRecord 5.x +* Added, that `add_index` for ActiveRecord models is only called when it doesn't exist already +* Use `records.__send__ :load` instead of `records.load` in the ActiveRecord adapter +* Call `Kaminari::Hooks.init` only when available +* Fixed the deprecation messages for `raise_in_transactional_callbacks` +* Fixed the deprecation messages for `timestamps` in migrations in integration tests +* Fixed the naming for the indexing integration tests +* Fixed the failing integration tests for ActiveRecord associations +* Fixed integration tests for ActiveRecord pagination +* Added the `rake bundle:install` Rake task to install dependencies for all gemfiles +* Run unit tests against all Gemfiles +* Updated dependencies in gemspec +* Relaxed the dependency on the "elasticsearch" gem +* Fixed the completion example for ActiveRecord for Elasticsearch 5 +* Added an example with Edge NGram mapping for auto-completion +* Expanded the example for indexing and searching ActiveRecord associations +* Added an example for source filtering to the ActiveRecord associations example +* Fixed a typo in the README +* Changed the default mapping type to `text` +* Added a `HashWrapper` class to wrap Hash structures instead of raw `Hashie::Mash` +* Call `Hashie.disable_warnings` method in Response wrappers +* Added, that `HashWrapper`, a sub-class of `Hashie::Mash` is used +* Updated the configuration for required routing in the integration test +* Fixed incorrect name for the parent/child integration test +* Fixed incorrect mapping configuration in the integration tests +* Allow passing the index settings and mappings as arguments to `create_index!` +* Added instructions about creating the index into the README +* Updated the "completion suggester" example + +### Persistence + +* Updated dependencies in gemspec +* Updated dependencies in gemspec +* Relaxed the dependency on the "elasticsearch" gem +* Use `text` instead of `string` for the data types +* Changed the default mapping type to `text` +* Removed the `search_type=scan` in the `find_in_batches` method +* Updated the `count` method in the "repository" module +* Updated the "update by script" integration test for Elasticsearch 5 +* Added, that `HashWrapper`, a sub-class of `Hashie::Mash` is used +* Updated the "Notes" example application for Elasticsearch 5.x +* Updated the "Music" example application for Elasticsearch 5.x +* Updated the URLs in the "Music" application template +* Updated the Git URLs in the "Notes" example application + +### Ruby on Rails + +* Updated the application templates to support Rails 5 & Elasticsearch 5 +* Updated the `03-expert` application template to work with Rails 5 +* Updated the application templates to work with README.md instead of README.rdoc +* Updated the installation process in the "01-basic" application template +* Fixed typo in README +* Fix typo in rake import task + +## 0.1.9 + +The last version for the old versioning scheme -- please see the Git commit log +at https://github.com/elastic/elasticsearch-rails/commits/v0.1.9 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1bf5d2a13..161dff67d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ The process for contributing to any of the [Elasticsearch](https://github.com/el 2. Make sure your changes don't break any existing tests, and that you add tests for both bugfixes and new functionality. 3. **Sign the contributor license agreement.** -Please make sure you have signed the [Contributor License Agreement](http://www.elasticsearch.org/contributor-agreement/). We are not asking you to assign copyright to us, but to give us the right to distribute your code without restriction. We ask this of all contributors in order to assure our users of the origin and continuing existence of the code. You only need to sign the CLA once. +Please make sure you have signed the [Contributor License Agreement](https://www.elastic.co/contributor-agreement/). We are not asking you to assign copyright to us, but to give us the right to distribute your code without restriction. We ask this of all contributors in order to assure our users of the origin and continuing existence of the code. You only need to sign the CLA once. 4. Submit a pull request. Push your local changes to your forked copy of the repository and submit a pull request. In the pull request, describe what your changes do and mention the number of the issue where discussion has taken place, eg “Closes #123″. diff --git a/Gemfile b/Gemfile index cbb51dd33..ddb9b7bbd 100644 --- a/Gemfile +++ b/Gemfile @@ -1,24 +1,30 @@ -source 'https://rubygems.org' - -gem "bundler", "> 1" -gem "rake" +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. -gem 'elasticsearch' -gem 'elasticsearch-extensions' +source 'https://rubygems.org' -gem 'elasticsearch-model', :path => File.expand_path("../elasticsearch-model", __FILE__), :require => false -gem 'elasticsearch-rails', :path => File.expand_path("../elasticsearch-rails", __FILE__), :require => false +gem 'ansi' +gem 'cane' +gem 'elasticsearch', '~> 8' +gem 'pry' +gem 'rake', '~> 12' -gem "pry" -gem "ansi" -gem "shoulda-context" -gem "mocha" -gem "turn" -gem "yard" -gem "ci_reporter" -gem "ruby-prof" -gem "simplecov" -gem "simplecov-rcov" -gem "cane" -gem "require-prof" -gem "coveralls" +group :development do + gem 'debug' unless defined?(JRUBY_VERSION) + gem 'rspec' + gem 'yard' +end diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index d0c6df584..0e27afb21 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,62 @@ -# Elasticsearch +# Elasticsearch Rails -This repository contains ActiveModel, ActiveRecord and Ruby on Rails integrations for -[Elasticsearch](http://elasticsearch.org): +[![Ruby tests](https://github.com/elastic/elasticsearch-rails/actions/workflows/tests.yml/badge.svg)](https://github.com/elastic/elasticsearch-rails/actions/workflows/tests.yml) +[![JRuby tests](https://github.com/elastic/elasticsearch-rails/actions/workflows/jruby.yml/badge.svg)](https://github.com/elastic/elasticsearch-rails/actions/workflows/jruby.yml) + +This repository contains various Ruby and Rails integrations for [Elasticsearch](http://elasticsearch.org): * ActiveModel integration with adapters for ActiveRecord and Mongoid +* _Repository pattern_ based persistence layer for Ruby objects * Enumerable-based wrapper for search results * ActiveRecord::Relation-based wrapper for returning search results as records * Convenience model methods such as `search`, `mapping`, `import`, etc * Rake tasks for importing the data -* Kaminari-based pagination support -* Integration with Rails's instrumentation framework +* Support for Kaminari and WillPaginate pagination +* Integration with Rails' instrumentation framework * Templates for generating example Rails application Elasticsearch client and Ruby API is provided by the -**[elasticsearch-ruby](https://github.com/elasticsearch/elasticsearch-ruby)** project. +**[elasticsearch-ruby](https://github.com/elastic/elasticsearch-ruby)** project. ## Installation -The libraries are compatible with Ruby 1.9.3 and higher. +Install each library from [Rubygems](https://rubygems.org/gems/elasticsearch): + + gem install elasticsearch-model + gem install elasticsearch-rails -Install the `elasticsearch-model` and/or `elasticsearch-rails` package from -[Rubygems](https://rubygems.org/gems/elasticsearch): +## Compatibility - gem install elasticsearch-model elasticsearch-rails +The libraries are compatible with Ruby 3.0 and higher. -To use an unreleased version, either add it to your `Gemfile` for [Bundler](http://gembundler.com): +We follow Ruby’s own maintenance policy and officially support all currently maintained versions per [Ruby Maintenance Branches](https://www.ruby-lang.org/en/downloads/branches/). - gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git' - gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git' +The version numbers follow the Elasticsearch major versions. Currently the `main` branch is compatible with version `8.x` of the Elasticsearch stack. -or install it from a source code checkout: +| Rubygem | | Elasticsearch | +|:-------------:|:-:| :-----------: | +| 0.1 | → | 1.x | +| 2.x | → | 2.x | +| 5.x | → | 5.x | +| 6.x | → | 6.x | +| 7.x | → | 7.x | +| 8.x | → | 8.x | +| main | → | 8.x | - git clone https://github.com/elasticsearch/elasticsearch-rails.git - cd elasticsearch-model - bundle install - rake install - cd elasticsearch-rails - bundle install - rake install +Check out [Elastic product end of life dates](https://www.elastic.co/support/eol) to learn which releases are still actively supported and tested. ## Usage -This project is split into two separate gems: +This project is split into three separate gems: -* [**`elasticsearch-model`**](https://github.com/elasticsearch/elasticsearch-rails/tree/master/elasticsearch-model), - which contains model-related features such as setting up indices, `search` method, pagination, etc +* [**`elasticsearch-model`**](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-model), + which contains search integration for Ruby/Rails models such as ActiveRecord::Base and Mongoid, -* [**`elasticsearch-rails`**](https://github.com/elasticsearch/elasticsearch-rails/tree/master/elasticsearch-rails), - which contains features for Ruby on Rails applications +* [**`elasticsearch-persistence`**](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-persistence), + which provides a standalone persistence layer for Ruby/Rails objects and models + +* [**`elasticsearch-rails`**](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-rails), + which contains various features for Ruby on Rails applications Example of a basic integration into an ActiveRecord-based model: @@ -59,45 +68,108 @@ class Article < ActiveRecord::Base include Elasticsearch::Model::Callbacks end +# Index creation right at import time is not encouraged. +# Typically, you would call create_index! asynchronously (e.g. in a cron job) +# However, we are adding it here so that this usage example can run correctly. +Article.__elasticsearch__.create_index! Article.import @articles = Article.search('foobar').records ``` -You can generate a fully working Ruby on Rails application with a single command: +You can generate a simple Ruby on Rails application with a single command +(see the [other available templates](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-rails#rails-application-templates)). You'll need to have an Elasticsearch cluster running on your system before generating the app. The easiest way of getting this set up is by running it with Docker with this command: + +```bash + docker run \ + --name elasticsearch-rails-searchapp \ + --publish 9200:9200 \ + --env "discovery.type=single-node" \ + --env "cluster.name=elasticsearch-rails" \ + --env "cluster.routing.allocation.disk.threshold_enabled=false" \ + --rm \ + docker.elastic.co/elasticsearch/elasticsearch:7.6.0 +``` + +Once Elasticsearch is running, you can generate the simple app with this command: ```bash -rails new searchapp --skip --skip-bundle --template https://raw.github.com/elasticsearch/elasticsearch-rails/master/elasticsearch-rails/lib/rails/templates/01-basic.rb +rails new searchapp --skip --skip-bundle --template https://raw.github.com/elasticsearch/elasticsearch-rails/main/elasticsearch-rails/lib/rails/templates/01-basic.rb +``` + +Example of using Elasticsearch as a repository for a Ruby domain object: + +```ruby +class Article + attr_accessor :title +end + +require 'elasticsearch/persistence' +repository = Elasticsearch::Persistence::Repository.new + +repository.save Article.new(title: 'Test') +# POST http://localhost:9200/repository/article +# => {"_index"=>"repository", "_id"=>"Ak75E0U9Q96T5Y999_39NA", ...} ``` -Please refer to each library documentation for detailed information and examples. +**Please refer to each library documentation for detailed information and examples.** ### Model -* [[README]](https://github.com/elasticsearch/elasticsearch-rails/blob/master/elasticsearch-model/README.md) +* [[README]](https://github.com/elastic/elasticsearch-rails/blob/main/elasticsearch-model/README.md) * [[Documentation]](http://rubydoc.info/gems/elasticsearch-model/) -* [[Test Suite]](https://github.com/elasticsearch/elasticsearch-rails/blob/master/elasticsearch-model/test) +* [[Test Suite]](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-model/spec/elasticsearch/model) + +### Persistence + +* [[README]](https://github.com/elastic/elasticsearch-rails/blob/main/elasticsearch-persistence/README.md) +* [[Documentation]](http://rubydoc.info/gems/elasticsearch-persistence/) +* [[Test Suite]](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-persistence/spec) ### Rails -* [[README]](https://github.com/elasticsearch/elasticsearch-rails/blob/master/elasticsearch-rails/README.md) +* [[README]](https://github.com/elastic/elasticsearch-rails/blob/main/elasticsearch-rails/README.md) * [[Documentation]](http://rubydoc.info/gems/elasticsearch-rails) -* [[Test Suite]](https://github.com/elasticsearch/elasticsearch-rails/blob/master/elasticsearch-rails/test) +* [[Test Suite]](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-rails/spec) + +## Development + +To work on the code, clone the repository and install all dependencies first: + +``` +git clone https://github.com/elastic/elasticsearch-rails.git +cd elasticsearch-rails/ +bundle install +rake bundle:install +``` + +### Running the Test Suite + +You can run unit and integration tests for each sub-project by running the respective Rake tasks in their folders. + +You can also unit, integration, or both tests for all sub-projects from the top-level directory: + + rake test:all + +The test suite expects an Elasticsearch cluster running on port 9250, and **will delete all the data**. ## License This software is licensed under the Apache 2 license, quoted below. - Copyright (c) 2014 Elasticsearch - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. + Licensed to Elasticsearch B.V. under one or more contributor + license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright + ownership. Elasticsearch B.V. licenses this file to you under + the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. diff --git a/Rakefile b/Rakefile index 7c5ef852c..55c294486 100644 --- a/Rakefile +++ b/Rakefile @@ -1,136 +1,184 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + require 'pathname' +require 'elasticsearch' + +subprojects = ['elasticsearch-rails', 'elasticsearch-persistence'] +subprojects << 'elasticsearch-model' unless defined?(JRUBY_VERSION) + +__current__ = Pathname(File.expand_path(__dir__)) -subprojects = %w| elasticsearch-model elasticsearch-rails | +def admin_client + $admin_client ||= begin + transport_options = {} + test_suite = ENV['TEST_SUITE'].freeze -__current__ = Pathname( File.expand_path('..', __FILE__) ) + if hosts = ENV['TEST_ES_SERVER'] || ENV['ELASTICSEARCH_HOSTS'] + split_hosts = hosts.split(',').map do |host| + /(http\:\/\/)?(\S+)/.match(host)[2] + end + + host, port = split_hosts.first.split(':') + end + + if test_suite == 'security' + transport_options.merge!(:ssl => { verify: false, + ca_path: defined?(CERT_DIR) ? CERT_DIR : nil + }.compact) + + password = ENV['ELASTIC_PASSWORD'] + user = ENV['ELASTIC_USER'] || 'elastic' + url = "https://#{user}:#{password}@#{host || 'localhost'}:#{port || 9200}" + else + url = "http://#{host || 'localhost'}:#{port || 9200}" + end + ENV['ELASTICSEARCH_URL'] ||= url + Elasticsearch::Client.new(host: url, transport_options: transport_options) + end +end task :default do - system "rake --tasks" + system 'rake --tasks' end +desc 'Show subprojects information' task :subprojects do - puts '-'*80 + puts '-' * 80 subprojects.each do |project| commit = `git log --pretty=format:'%h %ar: %s' -1 #{project}` - version = Gem::Specification::load(__current__.join(project, "#{project}.gemspec").to_s).version.to_s - puts "[#{version}] \e[1m#{project.ljust(subprojects.map {|s| s.length}.max)}\e[0m | #{commit[ 0..80]}..." + version = Gem::Specification.load(__current__.join(project, "#{project}.gemspec").to_s).version.to_s + puts "[#{version}] \e[1m#{project.ljust(subprojects.map(&:length).max)}\e[0m | #{commit[0..80]}..." end end -desc "Alias for `bundle:install`" -task :bundle => 'bundle:install' +desc 'Alias for `bundle:install`' +task bundle: 'bundle:install' namespace :bundle do - desc "Run `bundle install` in all subprojects" + desc 'Run `bundle install` in all subprojects' task :install do - puts '-'*80 - sh "bundle install --gemfile #{__current__}/Gemfile" - puts subprojects.each do |project| puts '-'*80 - sh "bundle install --gemfile #{__current__.join(project)}/Gemfile" + sh "cd #{__current__.join(project)} && bundle exec rake bundle:install" puts end - puts '-'*80 - sh "bundle install --gemfile #{__current__.join('elasticsearch-model/gemfiles')}/3.0.gemfile" - puts '-'*80 - sh "bundle install --gemfile #{__current__.join('elasticsearch-model/gemfiles')}/4.0.gemfile" end desc "Remove Gemfile.lock in all subprojects" task :clean do - sh "rm -f Gemfile.lock" subprojects.each do |project| sh "rm -f #{__current__.join(project)}/Gemfile.lock" end - sh "rm -f #{__current__.join('elasticsearch-model/gemfiles')}/3.0.gemfile.lock" - sh "rm -f #{__current__.join('elasticsearch-model/gemfiles')}/4.0.gemfile.lock" + sh "rm -f #{__current__.join('elasticsearch-model/gemfiles')}/*.lock" end + sh "rm -f Gemfile.lock" end namespace :test do - task :bundle => 'bundle:install' + task bundle: 'bundle:install' desc "Run unit tests in all subprojects" task :unit do - Rake::Task['test:ci_reporter'].invoke if ENV['CI'] subprojects.each do |project| puts '-'*80 sh "cd #{__current__.join(project)} && unset BUNDLE_GEMFILE && bundle exec rake test:unit" puts "\n" end - Rake::Task['test:coveralls'].invoke if ENV['CI'] && defined?(RUBY_VERSION) && RUBY_VERSION > '1.9' end - desc "Run integration tests in all subprojects" - task :integration do - Rake::Task['test:ci_reporter'].invoke if ENV['CI'] + desc "Setup MongoDB (Docker)" + task :setup_mongodb_docker do + begin + if ENV['MONGODB_VERSION'] + sh <<-COMMAND.gsub(/^\s*/, '').gsub(/\s{1,}/, ' ') + wget http://fastdl.mongodb.org/linux/mongodb-linux-x86_64-${MONGODB_VERSION}.tgz -O /tmp/mongodb.tgz && + tar -xvf /tmp/mongodb.tgz && + mkdir /tmp/data && + ${PWD}/mongodb-linux-x86_64-${MONGODB_VERSION}/bin/mongod --setParameter enableTestCommands=1 --dbpath /tmp/data --bind_ip 127.0.0.1 --auth &> /dev/null & + COMMAND + end + rescue + end + end + desc "Run integration tests in all subprojects" + task integration: :setup_elasticsearch do # 1/ elasticsearch-model # puts '-'*80 sh "cd #{__current__.join('elasticsearch-model')} && unset BUNDLE_GEMFILE &&" + - %Q| #{ ENV['TEST_BUNDLE_GEMFILE'] ? "BUNDLE_GEMFILE=#{ENV['TEST_BUNDLE_GEMFILE']}" : '' }| + + %Q| #{ ENV['TEST_BUNDLE_GEMFILE'] ? "BUNDLE_GEMFILE='#{ENV['TEST_BUNDLE_GEMFILE']}'" : '' }| + " bundle exec rake test:integration" puts "\n" - # 2/ elasticsearch-rails + # 2/ elasticsearch-persistence # puts '-'*80 - sh "cd #{__current__.join('elasticsearch-rails')} && unset BUNDLE_GEMFILE &&" + + sh "cd #{__current__.join('elasticsearch-persistence')} && unset BUNDLE_GEMFILE &&" + " bundle exec rake test:integration" puts "\n" - Rake::Task['test:coveralls'].invoke if ENV['CI'] && defined?(RUBY_VERSION) && RUBY_VERSION > '1.9' + # 3/ elasticsearch-rails + # + puts '-'*80 + sh "cd #{__current__.join('elasticsearch-rails')} && unset BUNDLE_GEMFILE &&" + + " bundle exec rake test:integration" + puts "\n" end desc "Run all tests in all subprojects" - task :all do - Rake::Task['test:ci_reporter'].invoke if ENV['CI'] - - Rake::Task['test:unit'].invoke - Rake::Task['test:integration'].invoke - end - - task :coveralls do - require 'coveralls/rake/task' - Coveralls::RakeTask.new - Rake::Task['coveralls:push'].invoke - end - - task :ci_reporter do - ENV['CI_REPORTS'] ||= 'tmp/reports' - if defined?(RUBY_VERSION) && RUBY_VERSION < '1.9' - require 'ci/reporter/rake/test_unit' - Rake::Task['ci:setup:testunit'].invoke - else - require 'ci/reporter/rake/minitest' - Rake::Task['ci:setup:minitest'].invoke + task all: :wait_for_green_or_yellow do + subprojects.each do |project| + puts '-'*80 + sh "cd #{project} && " + + "unset BUNDLE_GEMFILE && " + + "bundle exec rake test:all" + puts "\n" end end +end - namespace :cluster do - desc "Start Elasticsearch nodes for tests" - task :start do - require 'elasticsearch/extensions/test/cluster' - Elasticsearch::Extensions::Test::Cluster.start - end - - desc "Stop Elasticsearch nodes for tests" - task :stop do - require 'elasticsearch/extensions/test/cluster' - Elasticsearch::Extensions::Test::Cluster.stop - end +desc "Wait for elasticsearch cluster to be in green or yellow state" +task :wait_for_green_or_yellow do + require 'elasticsearch' - task :status do - require 'elasticsearch/extensions/test/cluster' - (puts "\e[31m[!] Test cluster not running\e[0m"; exit(1)) unless Elasticsearch::Extensions::Test::Cluster.running? - Elasticsearch::Extensions::Test::Cluster.__print_cluster_info(ENV['TEST_CLUSTER_PORT'] || 9250) + ready = nil + 5.times do |i| + begin + puts "Attempting to wait for green or yellow status: #{i + 1}" + if admin_client.cluster.health(wait_for_status: 'yellow', timeout: '50s') + ready = true + break + end + rescue Elastic::Transport::Transport::Errors::RequestTimeout => ex + puts "Couldn't confirm green or yellow status.\n#{ex.inspect}." + rescue Faraday::ConnectionFailed => ex + puts "Couldn't connect to Elasticsearch.\n#{ex.inspect}." + sleep(30) end end + unless ready + puts "Couldn't connect to Elasticsearch, aborting program." + exit(1) + end end -desc "Generate documentation for all subprojects" +desc 'Generate documentation for all subprojects' task :doc do subprojects.each do |project| sh "cd #{__current__.join(project)} && rake doc" @@ -138,10 +186,124 @@ task :doc do end end -desc "Release all subprojects to Rubygems" +desc 'Release all subprojects to Rubygems' task :release do subprojects.each do |project| sh "cd #{__current__.join(project)} && rake release" - puts '-'*80 + puts '-' * 80 + end +end + +desc <<-DESC + Update Rubygems versions in version.rb and *.gemspec files + + Example: + + $ rake update_version[5.0.0,5.0.1] +DESC +task :update_version, :old, :new do |_, args| + require 'ansi' + + puts '[!!!] Required argument [old] missing'.ansi(:red) unless args[:old] + puts '[!!!] Required argument [new] missing'.ansi(:red) unless args[:new] + + files = Dir['**/**/version.rb', '**/**/*.gemspec'] + + longest_line = files.map(&:size).max + + puts "\n", '= FILES '.ansi(:faint) + ('=' * 92).ansi(:faint), "\n" + + files.each do |file| + begin + File.open(file, 'r+') do |f| + content = f.read + if content.match Regexp.new(args[:old]) + content.gsub! Regexp.new(args[:old]), args[:new] + puts "+ [#{file}]".ansi(:green).ljust(longest_line + 20) + " [#{args[:old]}] -> [#{args[:new]}]".ansi(:green, :bold) + f.rewind + f.write content + else + puts "- [#{file}]".ansi(:yellow).ljust(longest_line+20) + " -".ansi(:faint,:strike) + end + end + rescue Exception => e + puts "[!!!] #{e.class} : #{e.message}".ansi(:red,:bold) + raise e + end + end + + puts "\n\n", "= CHANGELOG ".ansi(:faint) + ('='*88).ansi(:faint), "\n" + + log = `git --no-pager log --reverse --no-color --pretty='* %s' HEAD --not v#{args[:old]} elasticsearch-*`.split("\n") + + puts log.join("\n") + + log_entries = {} + log_entries[:common] = log.reject { |l| l =~ /^* \[/ } + log_entries[:model] = log.select { |l| l =~ /^* \[MODEL\]/ } + log_entries[:store] = log.select { |l| l =~ /^* \[STORE\]/ } + log_entries[:rails] = log.select { |l| l =~ /^* \[RAILS\]/ } + + changelog = File.read(File.open('CHANGELOG.md', 'r')) + + changelog_update = '' + + changelog_update << "## #{args[:new]}\n\n" + + unless log_entries[:common].empty? + changelog_update << log_entries[:common] + .map { |l| "#{l}" } + .join("\n") + changelog_update << "\n\n" + end + + unless log_entries[:model].empty? + changelog_update << "### ActiveModel\n\n" + changelog_update << log_entries[:model] + .map { |l| l.gsub /\[.+\] /, '' } + .map { |l| "#{l}" } + .join("\n") + changelog_update << "\n\n" + end + + unless log_entries[:store].empty? + changelog_update << "### Persistence\n\n" + changelog_update << log_entries[:store] + .map { |l| l.gsub /\[.+\] /, '' } + .map { |l| "#{l}" } + .join("\n") + changelog_update << "\n\n" end + + unless log_entries[:rails].empty? + changelog_update << "### Ruby on Rails\n\n" + changelog_update << log_entries[:rails] + .map { |l| l.gsub /\[.+\] /, '' } + .map { |l| "#{l}" } + .join("\n") + changelog_update << "\n\n" + end + + unless changelog =~ /^## #{args[:new]}/ + File.open('CHANGELOG.md', 'w+') { |f| f.write changelog_update and f.write changelog } + end + + puts "\n\n", "= DIFF ".ansi(:faint) + ('='*93).ansi(:faint) + + diff = `git --no-pager diff --patch --word-diff=color --minimal elasticsearch-*`.split("\n") + + puts diff + .reject { |l| l =~ /^\e\[1mdiff \-\-git/ } + .reject { |l| l =~ /^\e\[1mindex [a-z0-9]{7}/ } + .reject { |l| l =~ /^\e\[1m\-\-\- i/ } + .reject { |l| l =~ /^\e\[36m@@/ } + .map { |l| l =~ /^\e\[1m\+\+\+ w/ ? "\n#{l} " + '-'*(104-l.size) : l } + .join("\n") + + puts "\n\n", "= COMMIT ".ansi(:faint) + ('='*91).ansi(:faint), "\n" + + puts 'git add CHANGELOG.md elasticsearch-*', + "git commit --verbose --message='Release #{args[:new]}' --edit", + 'rake release' + "\n" end diff --git a/elasticsearch-model/.gitignore b/elasticsearch-model/.gitignore index 3934d7e55..37746eee1 100644 --- a/elasticsearch-model/.gitignore +++ b/elasticsearch-model/.gitignore @@ -16,5 +16,6 @@ test/tmp test/version_tmp tmp -gemfiles/3.0.gemfile.lock -gemfiles/4.0.gemfile.lock + +gemfiles/*.lock + diff --git a/elasticsearch-model/CHANGELOG.md b/elasticsearch-model/CHANGELOG.md new file mode 100644 index 000000000..1b2803383 --- /dev/null +++ b/elasticsearch-model/CHANGELOG.md @@ -0,0 +1,74 @@ +## 0.1.9 + +* Added a `suggest` method to wrap the suggestions in response +* Added the `:includes` option to Adapter::ActiveRecord::Records for eagerly loading associated models +* Delegated `max_pages` method properly for Kaminari's `next_page` +* Fixed `#dup` behaviour for Elasticsearch::Model +* Fixed typos in the README and examples + +## 0.1.8 + +* Added "default per page" methods for pagination with multi model searches +* Added a convenience accessor for the `aggregations` part of response +* Added a full example with mapping for the completion suggester +* Added an integration test for paginating multiple models +* Added proper support for the new "multi_fields" in the mapping DSL +* Added the `no_timeout` option for `__find_in_batches` in the Mongoid adapter +* Added, that index settings can be loaded from any object that responds to `:read` +* Added, that index settings/mappings can be loaded from a YAML or JSON file +* Added, that String pagination parameters are converted to numbers +* Added, that empty block is not required for setting mapping options +* Added, that on MyModel#import, an exception is raised if the index does not exists +* Changed the Elasticsearch port in the Mongoid example to 9200 +* Cleaned up the tests for multiple fields/properties in mapping DSL +* Fixed a bug where continuous `#save` calls emptied the `@__changed_attributes` variable +* Fixed a buggy test introduced in #335 +* Fixed incorrect deserialization of records in the Multiple adapter +* Fixed incorrect examples and documentation +* Fixed unreliable order of returned results/records in the integration test for the multiple adapter +* Fixed, that `param_name` is used when paginating with WillPaginate +* Fixed the problem where `document_type` configuration was not propagated to mapping [6 months ago by Miguel Ferna +* Refactored the code in `__find_in_batches` to use Enumerable#each_slice +* Refactored the string queries in multiple_models_test.rb to avoid quote escaping + +## 0.1.7 + +* Improved examples and instructions in README and code annotations +* Prevented index methods to swallow all exceptions +* Added the `:validate` option to the `save` method for models +* Added support for searching across multiple models (elastic/elasticsearch-rails#345), + including documentation, examples and tests + +## 0.1.6 + +* Improved documentation +* Added dynamic getter/setter (block/proc) for `MyModel.index_name` +* Added the `update_document_attributes` method +* Added, that records to import can be limited by the `query` option + +## 0.1.5 + +* Improved documentation +* Fixes and improvements to the "will_paginate" integration +* Added a `:preprocess` option to the `import` method +* Changed, that attributes are fetched from `as_indexed_json` in the `update_document` method +* Added an option to the import method to return an array of error messages instead of just count +* Fixed many problems with dependency hell +* Fixed tests so they run on Ruby 2.2 + +## 0.1.2 + +* Properly delegate existence methods like `result.foo?` to `result._source.foo` +* Exception is raised when `type` is not passed to Mappings#new +* Allow passing an ActiveRecord scope to the `import` method +* Added, that `each_with_hit` and `map_with_hit` in `Elasticsearch::Model::Response::Records` call `to_a` +* Added support for [`will_paginate`](https://github.com/mislav/will_paginate) pagination library +* Added the ability to transform models during indexing +* Added explicit `type` and `id` methods to Response::Result, aliasing `_type` and `_id` + +## 0.1.1 + +* Improved documentation and tests +* Fixed Kaminari implementation bugs and inconsistencies + +## 0.1.0 (Initial Version) diff --git a/elasticsearch-model/Gemfile b/elasticsearch-model/Gemfile index a54f5084e..58f5b6b92 100644 --- a/elasticsearch-model/Gemfile +++ b/elasticsearch-model/Gemfile @@ -1,4 +1,27 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + source 'https://rubygems.org' # Specify your gem's dependencies in elasticsearch-model.gemspec gemspec + +group :development, :testing do + gem 'debug' unless defined?(JRUBY_VERSION) + gem 'pry-nav' + gem 'rspec' +end diff --git a/elasticsearch-model/LICENSE.txt b/elasticsearch-model/LICENSE.txt index 7dc94b3e5..d64569567 100644 --- a/elasticsearch-model/LICENSE.txt +++ b/elasticsearch-model/LICENSE.txt @@ -1,13 +1,202 @@ -Copyright (c) 2014 Elasticsearch -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ - http://www.apache.org/licenses/LICENSE-2.0 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/elasticsearch-model/README.md b/elasticsearch-model/README.md index 0061a457b..4103ffa8b 100644 --- a/elasticsearch-model/README.md +++ b/elasticsearch-model/README.md @@ -1,13 +1,24 @@ # Elasticsearch::Model -The `elasticsearch-model` library builds on top of the -the [`elasticsearch`](https://github.com/elasticsearch/elasticsearch-ruby) library. +The `elasticsearch-model` library builds on top of the the [`elasticsearch`](https://github.com/elastic/elasticsearch-ruby) library. -It aims to simplify integration of Ruby classes ("models"), commonly found -e.g. in [Ruby on Rails](http://rubyonrails.org) applications, with the -[Elasticsearch](http://www.elasticsearch.org) search and analytics engine. +It aims to simplify integration of Ruby classes ("models"), commonly found e.g. in [Ruby on Rails](http://rubyonrails.org) applications, with the [Elasticsearch](https://www.elastic.co) search and analytics engine. -The library is compatible with Ruby 1.9.3 and higher. +## Compatibility + +This library is compatible with Ruby 3 and higher. + +The version numbers follow the Elasticsearch major versions. Currently the `main` branch is compatible with version `8.x` of the Elasticsearch stack. + +| Rubygem | | Elasticsearch | +|:-------:|:-:|:-------------:| +| 0.1 | → | 1.x | +| 2.x | → | 2.x | +| 5.x | → | 5.x | +| 6.x | → | 6.x | +| 7.x | → | 7.x | +| 8.x | → | 8.x | +| main | → | 8.x | ## Installation @@ -17,11 +28,11 @@ Install the package from [Rubygems](https://rubygems.org): To use an unreleased version, either add it to your `Gemfile` for [Bundler](http://bundler.io): - gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git' + gem 'elasticsearch-model', git: 'git://github.com/elastic/elasticsearch-rails.git', branch: '5.x' or install it from a source code checkout: - git clone https://github.com/elasticsearch/elasticsearch-rails.git + git clone https://github.com/elastic/elasticsearch-rails.git cd elasticsearch-rails/elasticsearch-model bundle install rake install @@ -60,9 +71,7 @@ This will extend the model with functionality related to Elasticsearch. #### Feature Extraction Pattern -Instead of including the `Elasticsearch::Model` module directly in your model, -you can include it in a "concern" or "trait" module, which is quite common pattern in Rails applications, -using e.g. `ActiveSupport::Concern` as the instrumentation: +Instead of including the `Elasticsearch::Model` module directly in your model, you can include it in a "concern" or "trait" module, which is quite common pattern in Rails applications, using e.g. `ActiveSupport::Concern` as the instrumentation: ```ruby # In: app/models/concerns/searchable.rb @@ -97,8 +106,8 @@ all its functionality. To prevent polluting your model namespace, this functiona available via the `__elasticsearch__` class and instance level proxy methods; see the `Elasticsearch::Model::Proxy` class documentation for technical information. -The module will include important methods, such as `search`, into the includeing class or module -only when they haven't been defined already. Following two calls are thus functionally equivalent: +The module will include important methods, such as `search`, into the class or module only +when they haven't been defined already. Following two calls are thus functionally equivalent: ```ruby Article.__elasticsearch__.search 'fox' @@ -109,7 +118,7 @@ See the `Elasticsearch::Model` module documentation for technical information. ### The Elasticsearch client -The module will set up a [client](https://github.com/elasticsearch/elasticsearch-ruby/tree/master/elasticsearch), +The module will set up a [client](https://github.com/elastic/elasticsearch-ruby/tree/main/elasticsearch), connected to `localhost:9200`, by default. You can access and use it as any other `Elasticsearch::Client`: ```ruby @@ -126,26 +135,29 @@ Article.__elasticsearch__.client = Elasticsearch::Client.new host: 'api.server.o Or configure the client for all models: ```ruby -Elasticsearch::Model.client = Elasticsearch::Client.new log:true +Elasticsearch::Model.client = Elasticsearch::Client.new log: true ``` -You might want to do this during you application bootstrap process, e.g. in a Rails initializer. +You might want to do this during your application bootstrap process, e.g. in a Rails initializer. Please refer to the -[`elasticsearch-transport`](https://github.com/elasticsearch/elasticsearch-ruby/tree/master/elasticsearch-transport) +[`elastic-transport`](https://github.com/elastic/elastic-transport-ruby/) library documentation for all the configuration options, and to the [`elasticsearch-api`](http://rubydoc.info/gems/elasticsearch-api) library documentation for information about the Ruby client API. ### Importing the data -The first thing you'll want to do is importing your data into the index: +The first thing you'll want to do is import your data into the index: ```ruby Article.import # => 0 ``` +It's possible to import only records from a specific `scope` or `query`, transform the batch with the `transform` +and `preprocess` options, or re-create the index by deleting it and creating it with correct mapping with the `force` option -- look for examples in the method documentation. + No errors were reported during importing, so... let's search the index! @@ -194,6 +206,13 @@ response.any? { |r| r.title =~ /fox|dog/ } # => true ``` +To use `Array`'s methods (including any _ActiveSupport_ extensions), just call `to_a` on the object: + +```ruby +response.to_a.last.title +# "Fast black dogs" +``` + #### Search results as database records Instead of returning documents from Elasticsearch, the `records` method will return a collection @@ -206,8 +225,9 @@ response.records.to_a ``` The returned object is the genuine collection of model instances returned by your database, -i.e. `ActiveRecord::Relation` for ActiveRecord, or `Mongoid::Criteria` in case of MongoDB. This allows you to -chain other methods on top of search results, as you would normally do: +i.e. `ActiveRecord::Relation` for ActiveRecord, or `Mongoid::Criteria` in case of MongoDB. + +This allows you to chain other methods on top of search results, as you would normally do: ```ruby response.records.where(title: 'Quick brown fox').to_a @@ -218,7 +238,7 @@ response.records.records.class # => ActiveRecord::Relation::ActiveRecord_Relation_Article ``` -The ordering of the records by score will be preserved, unless you explicitely specify a different +The ordering of the records by score will be preserved, unless you explicitly specify a different order in your model query language: ```ruby @@ -230,7 +250,7 @@ response.records.order(:title).to_a The `records` method returns the real instances of your model, which is useful when you want to access your model methods -- at the expense of slowing down your application, of course. In most cases, working with `results` coming from Elasticsearch is sufficient, and much faster. See the -[`elasticsearch-rails`](https://github.com/elasticsearch/elasticsearch-rails/tree/master/elasticsearch-rails) +[`elasticsearch-rails`](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-rails) library for more information about compatibility with the Ruby on Rails framework. When you want to access both the database `records` and search `results`, use the `each_with_hit` @@ -242,19 +262,41 @@ response.records.each_with_hit { |record, hit| puts "* #{record.title}: #{hit._s # * Fast black dogs: 0.02250402 ``` +#### Searching multiple models + +It is possible to search across multiple models with the module method: + +```ruby +Elasticsearch::Model.search('fox', [Article, Comment]).results.to_a.map(&:to_hash) +# => [ +# {"_index"=>"articles", "_id"=>"1", "_score"=>0.35136628, "_source"=>...}, +# {"_index"=>"comments", "_id"=>"1", "_score"=>0.35136628, "_source"=>...} +# ] + +Elasticsearch::Model.search('fox', [Article, Comment]).records.to_a +# Article Load (0.3ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" IN (1) +# Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."id" IN (1,5) +# => [#
, #, ...] +``` + +By default, all models which include the `Elasticsearch::Model` module are searched. + +NOTE: It is _not_ possible to chain other methods on top of the `records` object, since it + is a heterogenous collection, with models potentially backed by different databases. + #### Pagination -You can implement pagination with the `from` and `size` search parameters. However, search results -can be automatically paginated with the [`kaminari`](http://rubygems.org/gems/kaminari) gem. +You can implement pagination with the `from` and `size` search parameters. However, search results can be automatically paginated with the [`kaminari`](http://rubygems.org/gems/kaminari) or [`will_paginate`](https://github.com/mislav/will_paginate) gems. +(The pagination gems must be added before the Elasticsearch gems in your Gemfile, or loaded first in your application.) -If Kaminari is loaded, use the familiar paging methods: +If Kaminari or WillPaginate is loaded, use the familiar paging methods: ```ruby response.page(2).results response.page(2).records ``` -In a Rails controller, use the the `params[:page]` parameter to paginate through results: +In a Rails controller, use the `params[:page]` parameter to paginate through results: ```ruby @articles = Article.search(params[:q]).page(params[:page]).records @@ -264,17 +306,16 @@ In a Rails controller, use the the `params[:page]` parameter to paginate through @articles.next_page # => 3 ``` -To initialize and include the pagination support manually: +To initialize and include the Kaminari pagination support manually: ```ruby -Kaminari::Hooks.init +Kaminari::Hooks.init if defined?(Kaminari::Hooks) Elasticsearch::Model::Response::Response.__send__ :include, Elasticsearch::Model::Response::Pagination::Kaminari ``` #### The Elasticsearch DSL -In most situation, you'll want to pass the search definition -in the Elasticsearch [domain-specific language](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl.html) to the client: +In most situations, you'll want to pass the search definition in the Elasticsearch [domain-specific language](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html) to the client: ```ruby response = Article.search query: { match: { title: "Fox Dogs" } }, @@ -284,8 +325,7 @@ response.results.first.highlight.title # ["Quick brown fox"] ``` -You can pass any object which implements a `to_hash` method, or you can use your favourite JSON builder -to build the search definition as a JSON string: +You can pass any object which implements a `to_hash` method, which is called automatically, so you can use a custom class or your favourite JSON builder to build the search definition: ```ruby require 'jbuilder' @@ -305,11 +345,33 @@ response.results.first.title # => "Quick brown fox" ``` +Also, you can use the [**`elasticsearch-dsl`**](https://github.com/elastic/elasticsearch-dsl-ruby) library, which provides a specialized Ruby API for the Elasticsearch Query DSL: + +```ruby +require 'elasticsearch/dsl' + +query = Elasticsearch::DSL::Search.search do + query do + match :title do + query 'fox dogs' + end + end +end + +response = Article.search query +response.results.first.title +# => "Quick brown fox" +``` + ### Index Configuration For proper search engine function, it's often necessary to configure the index properly. The `Elasticsearch::Model` integration provides class methods to set up index settings and mappings. +**NOTE**: Elasticsearch will automatically create an index when a document is indexed, + with default settings and mappings. Create the index in advance with the `create_index!` + method, so your index configuration is respected. + ```ruby class Article settings index: { number_of_shards: 1 } do @@ -353,13 +415,11 @@ Article.__elasticsearch__.create_index! force: true Article.__elasticsearch__.refresh_index! ``` -By default, index name and document type will be inferred from your class name, -you can set it explicitely, however: +By default, index name will be inferred from your class name, you can set it explicitly, however: ```ruby class Article index_name "articles-#{Rails.env}" - document_type "post" end ``` @@ -411,7 +471,7 @@ class Article end ``` -For ActiveRecord-based models, you need to hook into the `after_commit` callback, to protect +For ActiveRecord-based models, use the `after_commit` callback to protect your data against inconsistencies caused by transaction rollbacks: ```ruby @@ -419,15 +479,19 @@ class Article < ActiveRecord::Base include Elasticsearch::Model after_commit on: [:create] do - index_document if self.published? + __elasticsearch__.index_document if self.published? end after_commit on: [:update] do - update_document if self.published? + if self.published? + __elasticsearch__.update_document + else + __elasticsearch__.delete_document + end end after_commit on: [:destroy] do - delete_document if self.published? + __elasticsearch__.delete_document if self.published? end end ``` @@ -463,9 +527,13 @@ class Indexer case operation.to_s when /index/ record = Article.find(record_id) - Client.index index: 'articles', type: 'article', id: record.id, body: record.as_indexed_json + Client.index index: 'articles', id: record.id, body: record.__elasticsearch__.as_indexed_json when /delete/ - Client.delete index: 'articles', type: 'article', id: record_id + begin + Client.delete index: 'articles', id: record_id + rescue Elastic::Transport::Transport::Errors::NotFound + logger.debug "Article not found, ID: #{record_id}" + end else raise ArgumentError, "Unknown operation '#{operation}'" end end @@ -485,7 +553,7 @@ You'll see the job being processed in the console where you started the _Sidekiq Indexer JID-eb7e2daf389a1e5e83697128 DEBUG: ["index", "ID: 7"] Indexer JID-eb7e2daf389a1e5e83697128 INFO: PUT http://localhost:9200/articles/article/1 [status:200, request:0.004s, query:n/a] Indexer JID-eb7e2daf389a1e5e83697128 DEBUG: > {"id":1,"title":"Updated", ...} -Indexer JID-eb7e2daf389a1e5e83697128 DEBUG: < {"ok":true,"_index":"articles","_type":"article","_id":"1","_version":6} +Indexer JID-eb7e2daf389a1e5e83697128 DEBUG: < {"ok":true,"_index":"articles","_id":"1","_version":6} Indexer JID-eb7e2daf389a1e5e83697128 INFO: done: 0.006 sec ``` @@ -499,7 +567,8 @@ Article.first.__elasticsearch__.as_indexed_json # => {"id"=>1, "title"=>"Quick brown fox"} ``` -If you want to customize the serialization, just implement the `as_indexed_json` method yourself: +If you want to customize the serialization, just implement the `as_indexed_json` method yourself, +for instance with the [`as_json`](http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html#method-i-as_json) method: ```ruby class Article @@ -516,6 +585,9 @@ Article.first.as_indexed_json The re-defined method will be used in the indexing methods, such as `index_document`. +Please note that in Rails 3, you need to either set `include_root_in_json: false`, or prevent adding +the "root" in the JSON representation with other means. + #### Relationships and Associations When you have a more complicated structure/schema, you need to customize the `as_indexed_json` method - @@ -599,7 +671,7 @@ module DataMapperAdapter # module Records def records - klass.all(id: @ids) + klass.all(id: ids) end # ... @@ -645,6 +717,13 @@ response.records.records.class More examples can be found in the `examples` folder. Please see the `Elasticsearch::Model::Adapter` module and its submodules for technical information. +### Settings + +The module provides a common `settings` method to customize various features. + +Before version 7.0.0 of the gem, the only supported setting was `:inheritance_enabled`. This setting has been deprecated +and removed. + ## Development and Community For local development, clone the repository and run `bundle install`. See `rake -T` for a list of @@ -661,20 +740,34 @@ curl -# https://download.elasticsearch.org/elasticsearch/elasticsearch/elasticse SERVER=start TEST_CLUSTER_COMMAND=$PWD/tmp/elasticsearch-1.0.0.RC1/bin/elasticsearch bundle exec rake test:all ``` +### Single Table Inheritance support + +Versions < 7.0.0 of this gem supported inheritance-- more specifically, `Single Table Inheritance`. With this feature, +elasticsearch settings (index mappings, etc) on a parent model could be inherited by a child model leading to different +model documents being indexed into the same Elasticsearch index. This feature depended on the ability to set a `type` +for a document in Elasticsearch. The Elasticsearch team has deprecated support for `types`, as is described +[here.](https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html) +This gem will also remove support for types and `Single Table Inheritance` in version 7.0 as it enables an anti-pattern. +Please save different model documents in separate indices. If you want to use STI, you can include an artificial +`type` field manually in each document and use it in other operations. + ## License This software is licensed under the Apache 2 license, quoted below. - Copyright (c) 2014 Elasticsearch - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. + Licensed to Elasticsearch B.V. under one or more contributor + license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright + ownership. Elasticsearch B.V. licenses this file to you under + the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. diff --git a/elasticsearch-model/Rakefile b/elasticsearch-model/Rakefile index 3cf581a91..9efdda0af 100644 --- a/elasticsearch-model/Rakefile +++ b/elasticsearch-model/Rakefile @@ -1,38 +1,63 @@ -require "bundler/gem_tasks" +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. -desc "Run unit tests" -task :default => 'test:unit' -task :test => 'test:unit' +require 'bundler/gem_tasks' -# ----- Test tasks ------------------------------------------------------------ +desc 'Run unit tests' +task default: 'test:all' +task test: 'test:all' -require 'rake/testtask' -namespace :test do - task :ci_reporter do - ENV['CI_REPORTS'] ||= 'tmp/reports' - require 'ci/reporter/rake/minitest' - Rake::Task['ci:setup:minitest'].invoke - end +gemfiles = ['6.1.gemfile', '7.0.gemfile'] +GEMFILES = gemfiles.freeze - Rake::TestTask.new(:unit) do |test| - Rake::Task['test:ci_reporter'].invoke if ENV['CI'] - test.libs << 'lib' << 'test' - test.test_files = FileList["test/unit/**/*_test.rb"] - # test.verbose = true - # test.warning = true +namespace :bundle do + desc 'Install dependencies for all the Gemfiles in /gemfiles. Optionally define env variable RAILS_VERSIONS. E.g. RAILS_VERSIONS=3.0,5.0' + task :install do + unless defined?(JRUBY_VERSION) + puts '-' * 80 + gemfiles = ENV['RAILS_VERSIONS'] ? ENV['RAILS_VERSIONS'].split(',').map { |v| "#{v}.gemfile"} : GEMFILES + gemfiles.each do |gemfile| + puts "GEMFILE: #{gemfile}" + Bundler.with_unbundled_env do + sh "bundle install --gemfile #{File.expand_path('../gemfiles/'+gemfile, __FILE__)}" + end + puts '-' * 80 + end + end end +end - Rake::TestTask.new(:integration) do |test| - Rake::Task['test:ci_reporter'].invoke if ENV['CI'] - test.libs << 'lib' << 'test' - test.test_files = FileList["test/integration/**/*_test.rb"] - end +# ----- Test tasks ------------------------------------------------------------ - Rake::TestTask.new(:all) do |test| - Rake::Task['test:ci_reporter'].invoke if ENV['CI'] - test.libs << 'lib' << 'test' - test.test_files = FileList["test/unit/**/*_test.rb", "test/integration/**/*_test.rb"] +require 'rake/testtask' +namespace :test do + desc 'Run all tests. Optionally define env variable RAILS_VERSIONS. E.g. RAILS_VERSIONS=3.0,5.0' + task :all do |task, args| + gemfiles = ENV['RAILS_VERSIONS'] ? ENV['RAILS_VERSIONS'].split(',').map { |v| "#{v}.gemfile" } : GEMFILES + puts '-' * 80 + gemfiles.each do |gemfile| + puts "GEMFILE: #{gemfile}" + sh "BUNDLE_GEMFILE='#{File.expand_path("../gemfiles/#{gemfile}", __FILE__)}' " \ + ' bundle exec rspec' + puts '-' * 80 + end end + + task unit: :all end # ----- Documentation tasks --------------------------------------------------- diff --git a/elasticsearch-model/elasticsearch-model.gemspec b/elasticsearch-model/elasticsearch-model.gemspec index 2e9c81630..57f95b6ae 100644 --- a/elasticsearch-model/elasticsearch-model.gemspec +++ b/elasticsearch-model/elasticsearch-model.gemspec @@ -1,55 +1,68 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # coding: utf-8 -lib = File.expand_path('../lib', __FILE__) + +lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'elasticsearch/model/version' Gem::Specification.new do |s| - s.name = "elasticsearch-model" + s.name = 'elasticsearch-model' s.version = Elasticsearch::Model::VERSION - s.authors = ["Karel Minarik"] - s.email = ["karel.minarik@elasticsearch.org"] - s.description = "ActiveModel/Record integrations for Elasticsearch." - s.summary = "ActiveModel/Record integrations for Elasticsearch." - s.homepage = "https://github.com/elasticsearch/elasticsearch-rails/" - s.license = "Apache 2" + s.authors = ['Elastic Client Library Maintainers'] + s.email = ['client-libs@elastic.co'] + s.description = 'ActiveModel/Record integrations for Elasticsearch.' + s.summary = 'ActiveModel/Record integrations for Elasticsearch.' + s.homepage = 'https://github.com/elasticsearch/elasticsearch-rails/' + s.license = 'Apache 2' s.files = `git ls-files`.split($/) s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } s.test_files = s.files.grep(%r{^(test|spec|features)/}) - s.require_paths = ["lib"] - - s.extra_rdoc_files = [ "README.md", "LICENSE.txt" ] - s.rdoc_options = [ "--charset=UTF-8" ] - - s.add_dependency "elasticsearch", '> 0.4' - s.add_dependency "activesupport", '> 3' - s.add_dependency "hashie" - - s.add_development_dependency "bundler", "~> 1.3" - s.add_development_dependency "rake" - - s.add_development_dependency "elasticsearch-extensions" + s.require_paths = ['lib'] - s.add_development_dependency "sqlite3" - s.add_development_dependency "activemodel", "> 3.0" - s.add_development_dependency "activerecord", "> 4.0" + s.extra_rdoc_files = ['README.md', 'LICENSE.txt'] + s.rdoc_options = ['--charset=UTF-8'] - s.add_development_dependency "oj" - s.add_development_dependency "kaminari" - # NOTE: Do not add Mongoid here, keep only in 3/4 files + s.required_ruby_version = '>= 3' - s.add_development_dependency "shoulda-context" - s.add_development_dependency "mocha" - s.add_development_dependency "turn" - s.add_development_dependency "yard" - s.add_development_dependency "ruby-prof" - s.add_development_dependency "pry" - s.add_development_dependency "ci_reporter" + s.add_dependency 'activesupport', '> 3' + s.add_dependency 'elasticsearch', '~> 8' + s.add_dependency 'hashie' - if defined?(RUBY_VERSION) && RUBY_VERSION > '1.9' - s.add_development_dependency "simplecov" - s.add_development_dependency "cane" - s.add_development_dependency "require-prof" - s.add_development_dependency "coveralls" + s.add_development_dependency 'activemodel', '> 3' + s.add_development_dependency 'bundler' + s.add_development_dependency 'cane' + s.add_development_dependency 'kaminari' + s.add_development_dependency 'minitest' + s.add_development_dependency 'mocha' + s.add_development_dependency 'pry' + s.add_development_dependency 'rake', '~> 12' + s.add_development_dependency 'require-prof' + s.add_development_dependency 'shoulda-context' + s.add_development_dependency 'simplecov' + s.add_development_dependency 'test-unit' + s.add_development_dependency 'turn' + s.add_development_dependency 'will_paginate' + s.add_development_dependency 'yard' + unless defined?(JRUBY_VERSION) + s.add_development_dependency 'oj' + s.add_development_dependency 'ruby-prof' + s.add_development_dependency 'sqlite3', '~> 1.4' end end diff --git a/elasticsearch-model/examples/activerecord_article.rb b/elasticsearch-model/examples/activerecord_article.rb index b18ee9c7b..1e584fd2c 100644 --- a/elasticsearch-model/examples/activerecord_article.rb +++ b/elasticsearch-model/examples/activerecord_article.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # ActiveRecord and Elasticsearch # ============================== # @@ -26,7 +43,7 @@ end end -Kaminari::Hooks.init +Kaminari::Hooks.init if defined?(Kaminari::Hooks) if defined?(Kaminari::Hooks) class Article < ActiveRecord::Base end diff --git a/elasticsearch-model/examples/activerecord_associations.rb b/elasticsearch-model/examples/activerecord_associations.rb index aba804b8f..b8100b33b 100644 --- a/elasticsearch-model/examples/activerecord_associations.rb +++ b/elasticsearch-model/examples/activerecord_associations.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # ActiveRecord associations and Elasticsearch # =========================================== # @@ -12,12 +29,12 @@ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) require 'pry' -Pry.config.history.file = File.expand_path('../../tmp/elasticsearch_development.pry', __FILE__) require 'logger' require 'ansi/core' require 'active_record' +require 'json' require 'elasticsearch/model' ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDOUT) @@ -28,23 +45,24 @@ ActiveRecord::Schema.define(version: 1) do create_table :categories do |t| t.string :title - t.timestamps + t.timestamps null: false end create_table :authors do |t| t.string :first_name, :last_name - t.timestamps + t.string :department + t.timestamps null: false end create_table :authorships do |t| t.references :article t.references :author - t.timestamps + t.timestamps null: false end create_table :articles do |t| t.string :title - t.timestamps + t.timestamps null: false end create_table :articles_categories, id: false do |t| @@ -54,20 +72,73 @@ create_table :comments do |t| t.string :text t.references :article - t.timestamps + t.timestamps null: false + end + + add_index(:comments, :article_id) unless index_exists?(:comments, :article_id) +end + +# ----- Elasticsearch client setup ---------------------------------------------------------------- + +Elasticsearch::Model.client = Elasticsearch::Client.new log: true +Elasticsearch::Model.client.transport.logger.formatter = proc { |s, d, p, m| "\e[2m#{m}\n\e[0m" } + +# ----- Search integration ------------------------------------------------------------------------ + +module Searchable + extend ActiveSupport::Concern + + included do + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + include Indexing + after_touch() { __elasticsearch__.index_document } + end + + module Indexing + + #Index only the specified fields + settings do + mappings dynamic: false do + indexes :categories, type: :object do + indexes :title + end + indexes :authors, type: :object do + indexes :full_name + indexes :department + end + indexes :comments, type: :object do + indexes :text + end + end + end + + # Customize the JSON serialization for Elasticsearch + def as_indexed_json(options={}) + self.as_json( + include: { categories: { only: :title}, + authors: { methods: [:full_name, :department], only: [:full_name, :department] }, + comments: { only: :text } + }) + end end - add_index(:comments, :article_id) end # ----- Model definitions ------------------------------------------------------------------------- class Category < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + has_and_belongs_to_many :articles end class Author < ActiveRecord::Base has_many :authorships + after_update { self.authorships.each(&:touch) } + def full_name [first_name, last_name].compact.join(' ') end @@ -79,6 +150,8 @@ class Authorship < ActiveRecord::Base end class Article < ActiveRecord::Base + include Searchable + has_and_belongs_to_many :categories, after_add: [ lambda { |a,c| a.__elasticsearch__.index_document } ], after_remove: [ lambda { |a,c| a.__elasticsearch__.index_document } ] has_many :authorships @@ -86,43 +159,13 @@ class Article < ActiveRecord::Base has_many :comments end -class Article < ActiveRecord::Base; delegate :size, to: :comments, prefix: true; end - class Comment < ActiveRecord::Base - belongs_to :article, touch: true -end - -# ----- Search integration ------------------------------------------------------------------------ - -module Searchable - extend ActiveSupport::Concern - - included do - include Elasticsearch::Model - include Elasticsearch::Model::Callbacks + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks - __elasticsearch__.client = Elasticsearch::Client.new log: true - __elasticsearch__.client.transport.logger.formatter = proc { |s, d, p, m| "\e[32m#{m}\n\e[0m" } - - include Indexing - after_touch() { __elasticsearch__.index_document } - end - - module Indexing - - # Customize the JSON serialization for Elasticsearch - def as_indexed_json(options={}) - self.as_json( - include: { categories: { only: :title}, - authors: { methods: [:full_name], only: [:full_name] }, - comments: { only: :text } - }) - end - end + belongs_to :article, touch: true end -Article.__send__ :include, Searchable - # ----- Insert data ------------------------------------------------------------------------------- # Create category @@ -131,7 +174,7 @@ def as_indexed_json(options={}) # Create author # -author = Author.create first_name: 'John', last_name: 'Smith' +author = Author.create first_name: 'John', last_name: 'Smith', department: 'Business' # Create article @@ -147,14 +190,57 @@ def as_indexed_json(options={}) # Add comment # -article.comments.create text: 'First comment' +article.comments.create text: 'First comment for article One' +article.comments.create text: 'Second comment for article One' -# Load +Elasticsearch::Model.client.indices.refresh index: Elasticsearch::Model::Registry.all.map(&:index_name) + +# Search for a term and return records # -article = Article.all.includes(:categories, :authors, :comments).first +puts "", + "Articles containing 'one':".ansi(:bold), + Article.search('one').records.to_a.map(&:inspect), + "" + +puts "", + "All Models containing 'one':".ansi(:bold), + Elasticsearch::Model.search('one').records.to_a.map(&:inspect), + "" + +# Difference between `records` and `results` +# +response = Article.search query: { match: { title: 'first' } } + +puts "", + "Search results are wrapped in the <#{response.class}> class", + "" + +puts "", + "Access the instances with the `#records` method:".ansi(:bold), + response.records.map { |r| "* #{r.title} | Authors: #{r.authors.map(&:full_name) } | Comment count: #{r.comments.size}" }.join("\n"), + "" + +puts "", + "Access the Elasticsearch documents with the `#results` method (without touching the database):".ansi(:bold), + response.results.map { |r| "* #{r.title} | Authors: #{r.authors.map(&:full_name) } | Comment count: #{r.comments.size}" }.join("\n"), + "" + +puts "", + "The whole indexed document (according to `Article#as_indexed_json`):".ansi(:bold), + JSON.pretty_generate(response.results.first._source.to_hash), + "" + +# Retrieve only selected fields from Elasticsearch +# +response = Article.search query: { match: { title: 'first' } }, _source: ['title', 'authors.full_name'] + +puts "", + "Retrieve only selected fields from Elasticsearch:".ansi(:bold), + JSON.pretty_generate(response.results.first._source.to_hash), + "" # ----- Pry --------------------------------------------------------------------------------------- Pry.start(binding, prompt: lambda { |obj, nest_level, _| '> ' }, - input: StringIO.new('puts "\n\narticle.as_indexed_json\n"; article.as_indexed_json'), + input: StringIO.new('response.records.first'), quiet: true) diff --git a/elasticsearch-model/examples/activerecord_custom_analyzer.rb b/elasticsearch-model/examples/activerecord_custom_analyzer.rb new file mode 100644 index 000000000..886eaab91 --- /dev/null +++ b/elasticsearch-model/examples/activerecord_custom_analyzer.rb @@ -0,0 +1,152 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Custom Analyzer for ActiveRecord integration with Elasticsearch +# =============================================================== + +$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) + +require 'ansi' +require 'logger' + +require 'active_record' +require 'elasticsearch/model' + +ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDOUT) +ActiveRecord::Base.establish_connection( adapter: 'sqlite3', database: ":memory:" ) + +ActiveRecord::Schema.define(version: 1) do + create_table :articles do |t| + t.string :title + t.date :published_at + t.timestamps + end +end + +Elasticsearch::Model.client.transport.logger = ActiveSupport::Logger.new(STDOUT) +Elasticsearch::Model.client.transport.logger.formatter = lambda { |s, d, p, m| "#{m.ansi(:faint)}\n" } + +class Article < ActiveRecord::Base + include Elasticsearch::Model + + settings index: { + number_of_shards: 1, + number_of_replicas: 0, + analysis: { + analyzer: { + pattern: { + type: 'pattern', + pattern: "\\s|_|-|\\.", + lowercase: true + }, + trigram: { + tokenizer: 'trigram' + } + }, + tokenizer: { + trigram: { + type: 'ngram', + min_gram: 3, + max_gram: 3, + token_chars: ['letter', 'digit'] + } + } + } } do + mapping do + indexes :title, type: 'text', analyzer: 'english' do + indexes :keyword, analyzer: 'keyword' + indexes :pattern, analyzer: 'pattern' + indexes :trigram, analyzer: 'trigram' + end + end + end +end + +# Create example records +# +Article.delete_all +Article.create title: 'Foo' +Article.create title: 'Foo-Bar' +Article.create title: 'Foo_Bar_Bazooka' +Article.create title: 'Foo.Bar' + +# Index records +# +errors = Article.import force: true, refresh: true, return: 'errors' +puts "[!] Errors importing records: #{errors.map { |d| d['index']['error'] }.join(', ')}".ansi(:red) && exit(1) unless errors.empty? + +puts '', '-'*80 + +puts "English analyzer [Foo_Bar_1_Bazooka]".ansi(:bold), + "Tokens: " + + Article.__elasticsearch__.client.indices + .analyze(index: Article.index_name, body: { field: 'title', text: 'Foo_Bar_1_Bazooka' })['tokens'] + .map { |d| "[#{d['token']}]" }.join(' '), + "\n" + +puts "Keyword analyzer [Foo_Bar_1_Bazooka]".ansi(:bold), + "Tokens: " + + Article.__elasticsearch__.client.indices + .analyze(index: Article.index_name, body: { field: 'title.keyword', text: 'Foo_Bar_1_Bazooka' })['tokens'] + .map { |d| "[#{d['token']}]" }.join(' '), + "\n" + +puts "Pattern analyzer [Foo_Bar_1_Bazooka]".ansi(:bold), + "Tokens: " + + Article.__elasticsearch__.client.indices + .analyze(index: Article.index_name, body: { field: 'title.pattern', text: 'Foo_Bar_1_Bazooka' })['tokens'] + .map { |d| "[#{d['token']}]" }.join(' '), + "\n" + +puts "Trigram analyzer [Foo_Bar_1_Bazooka]".ansi(:bold), + "Tokens: " + + Article.__elasticsearch__.client.indices + .analyze(index: Article.index_name, body: { field: 'title.trigram', text: 'Foo_Bar_1_Bazooka' })['tokens'] + .map { |d| "[#{d['token']}]" }.join(' '), + "\n" + +puts '', '-'*80 + +response = Article.search query: { match: { 'title' => 'foo' } } ; + +puts "English search for 'foo'".ansi(:bold), + "#{response.response.hits.total} matches: " + + response.records.map { |d| d.title }.join(', '), + "\n" + +puts '', '-'*80 + +response = Article.search query: { match: { 'title.pattern' => 'foo' } } ; + +puts "Pattern search for 'foo'".ansi(:bold), + "#{response.response.hits.total} matches: " + + response.records.map { |d| d.title }.join(', '), + "\n" + +puts '', '-'*80 + +response = Article.search query: { match: { 'title.trigram' => 'zoo' } } ; + +puts "Trigram search for 'zoo'".ansi(:bold), + "#{response.response.hits.total} matches: " + + response.records.map { |d| d.title }.join(', '), + "\n" + +puts '', '-'*80 + + +require 'pry'; binding.pry; diff --git a/elasticsearch-model/examples/activerecord_mapping_completion.rb b/elasticsearch-model/examples/activerecord_mapping_completion.rb new file mode 100644 index 000000000..264746180 --- /dev/null +++ b/elasticsearch-model/examples/activerecord_mapping_completion.rb @@ -0,0 +1,86 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'ansi' +require 'active_record' +require 'elasticsearch/model' + +ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDOUT) +ActiveRecord::Base.establish_connection( adapter: 'sqlite3', database: ":memory:" ) + +ActiveRecord::Schema.define(version: 1) do + create_table :articles do |t| + t.string :title + t.date :published_at + t.timestamps + end +end + +class Article < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + mapping do + indexes :title, type: 'text' do + indexes :suggest, type: 'completion' + end + indexes :url, type: 'keyword' + end + + def as_indexed_json(options={}) + as_json.merge 'url' => "/articles/#{id}" + end +end + +Article.__elasticsearch__.client = Elasticsearch::Client.new log: true + +# Create index + +Article.__elasticsearch__.create_index! force: true + +# Store data + +Article.delete_all +Article.create title: 'Foo' +Article.create title: 'Bar' +Article.create title: 'Foo Foo' +Article.__elasticsearch__.refresh_index! + +# Search and suggest + +response_1 = Article.search 'foo'; + +puts "Article search:".ansi(:bold), + response_1.to_a.map { |d| "Title: #{d.title}" }.inspect.ansi(:bold, :yellow) + +response_2 = Article.search \ + query: { + match: { title: 'foo' } + }, + suggest: { + articles: { + text: 'foo', + completion: { field: 'title.suggest' } + } + }, + _source: ['title', 'url'] + +puts "Article search with suggest:".ansi(:bold), + response_2.response['suggest']['articles'].first['options'].map { |d| "#{d['text']} -> #{d['_source']['url']}" }. + inspect.ansi(:bold, :blue) + +require 'pry'; binding.pry; diff --git a/elasticsearch-model/examples/activerecord_mapping_edge_ngram.rb b/elasticsearch-model/examples/activerecord_mapping_edge_ngram.rb new file mode 100644 index 000000000..c1a714f80 --- /dev/null +++ b/elasticsearch-model/examples/activerecord_mapping_edge_ngram.rb @@ -0,0 +1,118 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'ansi' +require 'sqlite3' +require 'active_record' +require 'elasticsearch/model' + +ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDOUT) +ActiveRecord::Base.establish_connection( adapter: 'sqlite3', database: ":memory:" ) + +ActiveRecord::Schema.define(version: 1) do + create_table :articles do |t| + t.string :title + t.date :published_at + t.timestamps + end +end + +class Article < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + article_es_settings = { + index: { + analysis: { + filter: { + autocomplete_filter: { + type: "edge_ngram", + min_gram: 1, + max_gram: 20 + } + }, + analyzer:{ + autocomplete: { + type: "custom", + tokenizer: "standard", + filter: ["lowercase", "autocomplete_filter"] + } + } + } + } + } + + settings article_es_settings do + mapping do + indexes :title + indexes :suggestable_title, type: 'string', analyzer: 'autocomplete' + end + end + + def as_indexed_json(options={}) + as_json.merge(suggestable_title: title) + end +end + +Article.__elasticsearch__.client = Elasticsearch::Client.new log: true + +# Create index + +Article.__elasticsearch__.create_index! force: true + +# Store data + +Article.delete_all +Article.create title: 'Foo' +Article.create title: 'Bar' +Article.create title: 'Foo Foo' +Article.__elasticsearch__.refresh_index! + +# Search and suggest +fulltext_search_response = Article.search(query: { match: { title: 'foo'} } ) + +puts "", "Article search for 'foo':".ansi(:bold), + fulltext_search_response.to_a.map { |d| "Title: #{d.title}" }.inspect.ansi(:bold, :yellow), + "" + +fulltext_search_response_2 = Article.search(query: { match: { title: 'fo'} } ) + +puts "", "Article search for 'fo':".ansi(:bold), + fulltext_search_response_2.to_a.map { |d| "Title: #{d.title}" }.inspect.ansi(:bold, :red), + "" + +autocomplete_search_response = Article.search(query: { match: { suggestable_title: { query: 'fo', analyzer: 'standard'} } } ) + +puts "", "Article autocomplete for 'fo':".ansi(:bold), + autocomplete_search_response.to_a.map { |d| "Title: #{d.suggestable_title}" }.inspect.ansi(:bold, :green), + "" + +puts "", "Text 'Foo Bar' analyzed with the default analyzer:".ansi(:bold), + Article.__elasticsearch__.client.indices.analyze( + index: Article.__elasticsearch__.index_name, + field: 'title', + text: 'Foo Bar')['tokens'].map { |t| t['token'] }.inspect.ansi(:bold, :yellow), + "" + +puts "", "Text 'Foo Bar' analyzed with the autocomplete filter:".ansi(:bold), + Article.__elasticsearch__.client.indices.analyze( + index: Article.__elasticsearch__.index_name, + field: 'suggestable_title', + text: 'Foo Bar')['tokens'].map { |t| t['token'] }.inspect.ansi(:bold, :yellow), + "" + +require 'pry'; binding.pry; diff --git a/elasticsearch-model/examples/couchbase_article.rb b/elasticsearch-model/examples/couchbase_article.rb index 57cc421b0..cfbab22d2 100644 --- a/elasticsearch-model/examples/couchbase_article.rb +++ b/elasticsearch-model/examples/couchbase_article.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # Couchbase and Elasticsearch # =========================== # diff --git a/elasticsearch-model/examples/datamapper_article.rb b/elasticsearch-model/examples/datamapper_article.rb index 383b3738f..d29460966 100644 --- a/elasticsearch-model/examples/datamapper_article.rb +++ b/elasticsearch-model/examples/datamapper_article.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # DataMapper and Elasticsearch # ============================ # @@ -50,11 +67,21 @@ module DataMapperAdapter # module Records def records - klass.all(id: @ids) + klass.all(id: ids) end # ... end + + module Callbacks + def self.included(model) + model.class_eval do + after(:create) { __elasticsearch__.index_document } + after(:save) { __elasticsearch__.update_document } + after(:destroy) { __elasticsearch__.delete_document } + end + end + end end # Register the adapter diff --git a/elasticsearch-model/examples/mongoid_article.rb b/elasticsearch-model/examples/mongoid_article.rb index fac9fa849..e2f3ae464 100644 --- a/elasticsearch-model/examples/mongoid_article.rb +++ b/elasticsearch-model/examples/mongoid_article.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # Mongoid and Elasticsearch # ========================= # @@ -21,7 +38,7 @@ Mongoid.connect_to 'articles' -Elasticsearch::Model.client = Elasticsearch::Client.new host: 'localhost:9250', log: true +Elasticsearch::Model.client = Elasticsearch::Client.new host: 'localhost:9200', log: true class Article include Mongoid::Document @@ -49,7 +66,7 @@ def as_indexed_json(options={}) # Index data # -client = Elasticsearch::Client.new host:'localhost:9250', log:true +client = Elasticsearch::Client.new host:'localhost:9200', log:true client.indices.delete index: 'articles' rescue nil client.bulk index: 'articles', diff --git a/elasticsearch-model/examples/ohm_article.rb b/elasticsearch-model/examples/ohm_article.rb index 3145085e7..1c50877f8 100644 --- a/elasticsearch-model/examples/ohm_article.rb +++ b/elasticsearch-model/examples/ohm_article.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # Ohm for Redis and Elasticsearch # =============================== # diff --git a/elasticsearch-model/examples/riak_article.rb b/elasticsearch-model/examples/riak_article.rb index 8013cda7e..8fd024e41 100644 --- a/elasticsearch-model/examples/riak_article.rb +++ b/elasticsearch-model/examples/riak_article.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # Riak and Elasticsearch # ====================== # diff --git a/elasticsearch-model/gemfiles/3.0.gemfile b/elasticsearch-model/gemfiles/3.0.gemfile deleted file mode 100644 index e2bc44b02..000000000 --- a/elasticsearch-model/gemfiles/3.0.gemfile +++ /dev/null @@ -1,12 +0,0 @@ -# Usage: -# -# $ BUNDLE_GEMFILE=./gemfiles/3.0.gemfile bundle install -# $ BUNDLE_GEMFILE=./gemfiles/3.0.gemfile bundle exec rake test:integration - -source 'https://rubygems.org' - -gemspec path: '../' - -gem 'activemodel', '>= 3.0' -gem 'activerecord', '~> 3.2' -gem 'mongoid', '>= 3.0' diff --git a/elasticsearch-model/gemfiles/4.0.gemfile b/elasticsearch-model/gemfiles/4.0.gemfile deleted file mode 100644 index 0d59abc98..000000000 --- a/elasticsearch-model/gemfiles/4.0.gemfile +++ /dev/null @@ -1,12 +0,0 @@ -# Usage: -# -# $ BUNDLE_GEMFILE=./gemfiles/4.0.gemfile bundle install -# $ BUNDLE_GEMFILE=./gemfiles/4.0.gemfile bundle exec rake test:integration - -source 'https://rubygems.org' - -gemspec path: '../' - -gem 'activemodel', '~> 4' -gem 'activerecord', '~> 4' -gem 'mongoid', '~> 4.0.0.beta1' diff --git a/elasticsearch-model/gemfiles/6.1.gemfile b/elasticsearch-model/gemfiles/6.1.gemfile new file mode 100644 index 000000000..79a13d5c4 --- /dev/null +++ b/elasticsearch-model/gemfiles/6.1.gemfile @@ -0,0 +1,36 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Usage: +# +# $ BUNDLE_GEMFILE=./gemfiles/6.0.gemfile bundle install +# $ BUNDLE_GEMFILE=./gemfiles/6.0.gemfile bundle exec rake test:integration + +source 'https://rubygems.org' + +gemspec path: '../' + +gem 'activemodel', '6.1' +gem 'activerecord', '6.1' +gem 'sqlite3', '~> 1.4' unless defined?(JRUBY_VERSION) +# gem 'mongoid', '~> 6' + +group :development, :testing do + gem 'debug' + gem 'pry-nav' + gem 'rspec' +end diff --git a/elasticsearch-model/gemfiles/7.0.gemfile b/elasticsearch-model/gemfiles/7.0.gemfile new file mode 100644 index 000000000..f1b5e3ba0 --- /dev/null +++ b/elasticsearch-model/gemfiles/7.0.gemfile @@ -0,0 +1,36 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Usage: +# +# $ BUNDLE_GEMFILE=./gemfiles/7.0.gemfile bundle install +# $ BUNDLE_GEMFILE=./gemfiles/7.0.gemfile bundle exec rake test:integration + +source 'https://rubygems.org' + +gemspec path: '../' + +gem 'activemodel', '~> 7' +gem 'activerecord', '~> 7' +gem 'sqlite3', '~> 1.4' unless defined?(JRUBY_VERSION) +# gem 'mongoid', '~> 6' + +group :development, :testing do + gem 'debug' + gem 'pry-nav' + gem 'rspec' +end diff --git a/elasticsearch-model/gemfiles/7.1.gemfile b/elasticsearch-model/gemfiles/7.1.gemfile new file mode 100644 index 000000000..6a8e59da9 --- /dev/null +++ b/elasticsearch-model/gemfiles/7.1.gemfile @@ -0,0 +1,36 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Usage: +# +# $ BUNDLE_GEMFILE=./gemfiles/7.0.gemfile bundle install +# $ BUNDLE_GEMFILE=./gemfiles/7.0.gemfile bundle exec rake test:integration + +source 'https://rubygems.org' + +gemspec path: '../' + +gem 'activemodel', '~> 7.1' +gem 'activerecord', '~> 7.1' +gem 'sqlite3', '~> 1.4' unless defined?(JRUBY_VERSION) +# gem 'mongoid', '~> 6' + +group :development, :testing do + gem 'debug' + gem 'pry-nav' + gem 'rspec' +end diff --git a/elasticsearch-model/lib/elasticsearch/model.rb b/elasticsearch-model/lib/elasticsearch/model.rb index c69d21ac9..6cc595050 100644 --- a/elasticsearch-model/lib/elasticsearch/model.rb +++ b/elasticsearch-model/lib/elasticsearch/model.rb @@ -1,17 +1,38 @@ -require 'elasticsearch' - -require 'hashie' +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'hashie/mash' require 'active_support/core_ext/module/delegation' +require 'elasticsearch' + require 'elasticsearch/model/version' +require 'elasticsearch/model/hash_wrapper' require 'elasticsearch/model/client' +require 'elasticsearch/model/multimodel' + require 'elasticsearch/model/adapter' require 'elasticsearch/model/adapters/default' require 'elasticsearch/model/adapters/active_record' require 'elasticsearch/model/adapters/mongoid' +require 'elasticsearch/model/adapters/multiple' require 'elasticsearch/model/importing' require 'elasticsearch/model/indexing' @@ -28,15 +49,19 @@ require 'elasticsearch/model/response/results' require 'elasticsearch/model/response/records' require 'elasticsearch/model/response/pagination' +require 'elasticsearch/model/response/aggregations' +require 'elasticsearch/model/response/suggestions' require 'elasticsearch/model/ext/active_record' -if defined?(::Kaminari) +case +when defined?(::Kaminari) Elasticsearch::Model::Response::Response.__send__ :include, Elasticsearch::Model::Response::Pagination::Kaminari +when defined?(::WillPaginate) + Elasticsearch::Model::Response::Response.__send__ :include, Elasticsearch::Model::Response::Pagination::WillPaginate end module Elasticsearch - # Elasticsearch integration for Ruby models # ========================================= # @@ -59,12 +84,14 @@ module Elasticsearch # # ... # module Model + METHODS = [:search, :mapping, :mappings, :settings, :index_name, :import] # Adds the `Elasticsearch::Model` functionality to the including class. # - # * Creates the `__elasticsearch__` class and instance methods, pointing to the proxy object - # * Includes the necessary modules in the proxy classes - # * Sets up delegation for crucial methods such as `search`, etc. + # * Creates the `__elasticsearch__` class and instance method. These methods return a proxy object with + # other common methods defined on them. + # * The module includes other modules with further functionality. + # * Sets up delegation for common methods such as `import` and `search`. # # @example Include the module in the `Article` model definition # @@ -80,56 +107,30 @@ module Model def self.included(base) base.class_eval do include Elasticsearch::Model::Proxy - - Elasticsearch::Model::Proxy::ClassMethodsProxy.class_eval do - include Elasticsearch::Model::Client::ClassMethods - include Elasticsearch::Model::Naming::ClassMethods - include Elasticsearch::Model::Indexing::ClassMethods - include Elasticsearch::Model::Searching::ClassMethods - end - - Elasticsearch::Model::Proxy::InstanceMethodsProxy.class_eval do - include Elasticsearch::Model::Client::InstanceMethods - include Elasticsearch::Model::Naming::InstanceMethods - include Elasticsearch::Model::Indexing::InstanceMethods - include Elasticsearch::Model::Serializing::InstanceMethods - end - - Elasticsearch::Model::Proxy::InstanceMethodsProxy.class_eval <<-CODE, __FILE__, __LINE__ + 1 - def as_indexed_json(options={}) - target.respond_to?(:as_indexed_json) ? target.__send__(:as_indexed_json, options) : super - end - CODE - - # Delegate important methods to the `__elasticsearch__` proxy, unless they are defined already - # + # Delegate common methods to the `__elasticsearch__` ClassMethodsProxy, unless they are defined already class << self - delegate :search, to: :__elasticsearch__ unless respond_to?(:search) - delegate :mapping, to: :__elasticsearch__ unless respond_to?(:mapping) - delegate :mappings, to: :__elasticsearch__ unless respond_to?(:mappings) - delegate :settings, to: :__elasticsearch__ unless respond_to?(:settings) - delegate :index_name, to: :__elasticsearch__ unless respond_to?(:index_name) - delegate :document_type, to: :__elasticsearch__ unless respond_to?(:document_type) - delegate :import, to: :__elasticsearch__ unless respond_to?(:import) - end + METHODS.each do |method| + delegate method, to: :__elasticsearch__ unless self.public_instance_methods.include?(method) + end - # Mix the importing module into the proxy - # - self.__elasticsearch__.class_eval do - include Elasticsearch::Model::Importing::ClassMethods - include Adapter.from_class(base).importing_mixin + def inherited(subclass) + super + Registry.add(subclass) if subclass.is_a?(Class) + end end end + + # Add to the model to the registry if it's a class (and not in intermediate module) + Registry.add(base) if base.is_a?(Class) end module ClassMethods - # Get the client common for all models # # @example Get the client # # Elasticsearch::Model.client - # => # + # => # # def client @client ||= Elasticsearch::Client.new @@ -139,8 +140,8 @@ def client # # @example Configure (set) the client for all models # - # Elasticsearch::Model.client Elasticsearch::Client.new host: 'http://localhost:9200', tracer: true - # => # + # Elasticsearch::Model.client = Elasticsearch::Client.new host: 'http://localhost:9200', tracer: true + # => # # # @note You have to set the client before you call Elasticsearch methods on the model, # or set it directly on the model; see {Elasticsearch::Model::Client::ClassMethods#client} @@ -149,6 +150,36 @@ def client=(client) @client = client end + # Search across multiple models + # + # By default, all models which include the `Elasticsearch::Model` module are searched + # + # @param query_or_payload [String,Hash,Object] The search request definition + # (string, JSON, Hash, or object responding to `to_hash`) + # @param models [Array] The Array of Model objects to search + # @param options [Hash] Optional parameters to be passed to the Elasticsearch client + # + # @return [Elasticsearch::Model::Response::Response] + # + # @example Search across specific models + # + # Elasticsearch::Model.search('foo', [Author, Article]) + # + # @example Search across all models which include the `Elasticsearch::Model` module + # + # Elasticsearch::Model.search('foo') + # + def search(query_or_payload, models=[], options={}) + models = Multimodel.new(models) + request = Searching::SearchRequest.new(models, query_or_payload, options) + Response::Response.new(models, request) + end + + # Access the module settings + # + def settings + @settings ||= {} + end end extend ClassMethods diff --git a/elasticsearch-model/lib/elasticsearch/model/adapter.rb b/elasticsearch-model/lib/elasticsearch/model/adapter.rb index 3a25e5d97..3283112e9 100644 --- a/elasticsearch-model/lib/elasticsearch/model/adapter.rb +++ b/elasticsearch-model/lib/elasticsearch/model/adapter.rb @@ -1,6 +1,22 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model - # Contains an adapter which provides OxM-specific implementations for common behaviour: # # * {Adapter::Adapter#records_mixin Fetching records from the database} @@ -12,7 +28,6 @@ module Model # @see Elasticsearch::Model::Adapter::Mongoid # module Adapter - # Returns an adapter based on the Ruby class passed # # @example Create an adapter for an ActiveRecord-based model diff --git a/elasticsearch-model/lib/elasticsearch/model/adapters/active_record.rb b/elasticsearch-model/lib/elasticsearch/model/adapters/active_record.rb index e589a5830..20dee0505 100644 --- a/elasticsearch-model/lib/elasticsearch/model/adapters/active_record.rb +++ b/elasticsearch-model/lib/elasticsearch/model/adapters/active_record.rb @@ -1,32 +1,61 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model module Adapter - # An adapter for ActiveRecord-based models # module ActiveRecord - Adapter.register self, - lambda { |klass| !!defined?(::ActiveRecord::Base) && klass.ancestors.include?(::ActiveRecord::Base) } + lambda { |klass| !!defined?(::ActiveRecord::Base) && klass.respond_to?(:ancestors) && klass.ancestors.include?(::ActiveRecord::Base) } module Records + attr_writer :options + + def options + @options ||= {} + end + # Returns an `ActiveRecord::Relation` instance # def records sql_records = klass.where(klass.primary_key => ids) + sql_records = sql_records.includes(self.options[:includes]) if self.options[:includes] # Re-order records based on the order from Elasticsearch hits # by redefining `to_a`, unless the user has called `order()` # sql_records.instance_exec(response.response['hits']['hits']) do |hits| - define_singleton_method :to_a do + ar_records_method_name = :to_a + ar_records_method_name = :records if defined?(::ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 5 + + define_singleton_method(ar_records_method_name) do if defined?(::ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 4 self.load else self.__send__(:exec_queries) end - @records.sort_by { |record| hits.index { |hit| hit['_id'].to_s == record.id.to_s } } - end + if !self.order_values.present? + @records.sort_by { |record| hits.index { |hit| hit['_id'].to_s == record.id.to_s } } + else + @records + end + end if self end sql_records @@ -35,33 +64,11 @@ def records # Prevent clash with `ActiveSupport::Dependencies::Loadable` # def load - records.load - end - - # Intercept call to the `order` method, so we can ignore the order from Elasticsearch - # - def order(*args) - sql_records = records.__send__ :order, *args - - # Redefine the `to_a` method to the original one - # - sql_records.instance_exec do - define_singleton_method(:to_a) do - if defined?(::ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 4 - self.load - else - self.__send__(:exec_queries) - end - @records - end - end - - sql_records + records.__send__(:load) end end module Callbacks - # Handle index updates (creating, updating or deleting documents) # when the model changes, by hooking into the lifecycle # @@ -77,21 +84,31 @@ def self.included(base) end module Importing - - # Fetch batches of records from the database + # Fetch batches of records from the database (used by the import method) + # # # @see http://api.rubyonrails.org/classes/ActiveRecord/Batches.html ActiveRecord::Batches.find_in_batches # def __find_in_batches(options={}, &block) - find_in_batches(options) do |batch| - batch_for_bulk = batch.map { |a| { index: { _id: a.id, data: a.__elasticsearch__.as_indexed_json } } } - yield batch_for_bulk + query = options.delete(:query) + named_scope = options.delete(:scope) + preprocess = options.delete(:preprocess) + + scope = self + scope = scope.__send__(named_scope) if named_scope + scope = scope.instance_exec(&query) if query + + scope.find_in_batches(**options) do |batch| + batch = self.__send__(preprocess, batch) if preprocess + yield(batch) if batch.present? end end - end + def __transform + lambda { |model| { index: { _id: model.id, data: model.__elasticsearch__.as_indexed_json } } } + end + end end - end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/adapters/default.rb b/elasticsearch-model/lib/elasticsearch/model/adapters/default.rb index e04ffd0ec..914514136 100644 --- a/elasticsearch-model/lib/elasticsearch/model/adapters/default.rb +++ b/elasticsearch-model/lib/elasticsearch/model/adapters/default.rb @@ -1,15 +1,29 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model module Adapter - # The default adapter for models which haven't one registered # module Default - # Module for implementing methods and logic related to fetching records from the database # module Records - # Return the collection of records fetched from the database # # By default uses `MyModel#find[1, 2, 3]` @@ -36,8 +50,13 @@ module Importing def __find_in_batches(options={}, &block) raise NotImplemented, "Method not implemented for default adapter" end - end + # @abstract Implement this method in your adapter + # + def __transform + raise NotImplemented, "Method not implemented for default adapter" + end + end end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/adapters/mongoid.rb b/elasticsearch-model/lib/elasticsearch/model/adapters/mongoid.rb index cd052c8ff..850ea50ea 100644 --- a/elasticsearch-model/lib/elasticsearch/model/adapters/mongoid.rb +++ b/elasticsearch-model/lib/elasticsearch/model/adapters/mongoid.rb @@ -1,18 +1,38 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model module Adapter - # An adapter for Mongoid-based models # # @see http://mongoid.org # module Mongoid - - Adapter.register self, - lambda { |klass| !!defined?(::Mongoid::Document) && klass.ancestors.include?(::Mongoid::Document) } + Adapter.register( + self, + lambda do |klass| + !!defined?(::Mongoid::Document) && + klass.respond_to?(:ancestors) && + klass.ancestors.include?(::Mongoid::Document) + end + ) module Records - # Return a `Mongoid::Criteria` instance # def records @@ -42,7 +62,6 @@ def records end module Callbacks - # Handle index updates (creating, updating or deleting documents) # when the model changes, by hooking into the lifecycle # @@ -56,35 +75,30 @@ def self.included(base) end module Importing - # Fetch batches of records from the database # # @see https://github.com/mongoid/mongoid/issues/1334 # @see https://github.com/karmi/retire/pull/724 # def __find_in_batches(options={}, &block) - options[:batch_size] ||= 1_000 - items = [] - - all.each do |item| - items << item - - if items.length % options[:batch_size] == 0 - batch_for_bulk = items.map { |a| { index: { _id: a.id.to_s, data: a.as_indexed_json } } } - yield batch_for_bulk - items = [] - end + batch_size = options[:batch_size] || 1_000 + query = options[:query] + named_scope = options[:scope] + preprocess = options[:preprocess] + + scope = all + scope = scope.send(named_scope) if named_scope + scope = query.is_a?(Proc) ? scope.class_exec(&query) : scope.where(query) if query + scope.no_timeout.each_slice(batch_size) do |items| + yield (preprocess ? self.__send__(preprocess, items) : items) end + end - unless items.empty? - batch_for_bulk = items.map { |a| { index: { _id: a.id.to_s, data: a.as_indexed_json } } } - yield batch_for_bulk - end + def __transform + lambda { |a| { index: { _id: a.id.to_s, data: a.as_indexed_json } } } end end - end - end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/adapters/multiple.rb b/elasticsearch-model/lib/elasticsearch/model/adapters/multiple.rb new file mode 100644 index 000000000..9a8657f1d --- /dev/null +++ b/elasticsearch-model/lib/elasticsearch/model/adapters/multiple.rb @@ -0,0 +1,136 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Model + module Adapter + # An adapter to be used for deserializing results from multiple models, + # retrieved through `Elasticsearch::Model.search` + # + # @see Elasticsearch::Model.search + # + module Multiple + Adapter.register self, lambda { |klass| klass.is_a? Multimodel } + + module Records + # Returns a collection of model instances, possibly of different classes (ActiveRecord, Mongoid, ...) + # + # @note The order of results in the Elasticsearch response is preserved + # + def records + records_by_type = __records_by_type + + records = response.response["hits"]["hits"].map do |hit| + records_by_type[ __type_for_hit(hit) ][ hit[:_id] ] + end + + records.compact + end + + # Returns the collection of records grouped by class based on `_type` + # + # Example: + # + # { + # Foo => {"1"=> # {"1"=> # ids) + when Elasticsearch::Model::Adapter::Mongoid.equal?(adapter) + klass.where(:id.in => ids) + else + klass.find(ids) + end + end + + # Returns the record IDs grouped by class based on type `_type` + # + # Example: + # + # { Foo => ["1"], Bar => ["1", "5"] } + # + # @api private + # + def __ids_by_type + ids_by_type = {} + + response.response["hits"]["hits"].each do |hit| + type = __type_for_hit(hit) + ids_by_type[type] ||= [] + ids_by_type[type] << hit[:_id] + end + ids_by_type + end + + # Returns the class of the model corresponding to a specific `hit` in Elasticsearch results + # + # @see Elasticsearch::Model::Registry + # + # @api private + # + def __type_for_hit(hit) + @@__types ||= {} + + key = "#{hit[:_index]}::#{hit[:_type]}" if hit[:_type] && hit[:_type] != '_doc' + key = hit[:_index] unless key + + @@__types[key] ||= begin + Registry.all.detect do |model| + (model.index_name == hit[:_index] && __no_type?(hit)) || + (model.index_name == hit[:_index] && model.document_type == hit[:_type]) + end + end + end + + def __no_type?(hit) + hit[:_type].nil? || hit[:_type] == '_doc' + end + + # Returns the adapter registered for a particular `klass` or `nil` if not available + # + # @api private + # + def __adapter_for_klass(klass) + Adapter.adapters.select { |name, checker| checker.call(klass) }.keys.first + end + end + end + end + end +end diff --git a/elasticsearch-model/lib/elasticsearch/model/callbacks.rb b/elasticsearch-model/lib/elasticsearch/model/callbacks.rb index 1b72cb2a0..cff3d1c9d 100644 --- a/elasticsearch-model/lib/elasticsearch/model/callbacks.rb +++ b/elasticsearch-model/lib/elasticsearch/model/callbacks.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model diff --git a/elasticsearch-model/lib/elasticsearch/model/client.rb b/elasticsearch-model/lib/elasticsearch/model/client.rb index c1a9b4ed9..b47a0925f 100644 --- a/elasticsearch-model/lib/elasticsearch/model/client.rb +++ b/elasticsearch-model/lib/elasticsearch/model/client.rb @@ -1,12 +1,26 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model - # Contains an `Elasticsearch::Client` instance # module Client - module ClassMethods - # Get the client for a specific model class # # @example Get the client for `Article` and perform API request @@ -31,7 +45,6 @@ def client=(client) end module InstanceMethods - # Get or set the client for a specific model instance # # @example Get the client for a specific record and perform API request @@ -55,7 +68,6 @@ def client=(client) @client = client end end - end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/ext/active_record.rb b/elasticsearch-model/lib/elasticsearch/model/ext/active_record.rb index ffa6cc385..2cdbe7f35 100644 --- a/elasticsearch-model/lib/elasticsearch/model/ext/active_record.rb +++ b/elasticsearch-model/lib/elasticsearch/model/ext/active_record.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # Prevent `MyModel.inspect` failing with `ActiveRecord::ConnectionNotEstablished` # (triggered by elasticsearch-model/lib/elasticsearch/model.rb:79:in `included') # diff --git a/elasticsearch-model/lib/elasticsearch/model/hash_wrapper.rb b/elasticsearch-model/lib/elasticsearch/model/hash_wrapper.rb new file mode 100644 index 000000000..18f4dad23 --- /dev/null +++ b/elasticsearch-model/lib/elasticsearch/model/hash_wrapper.rb @@ -0,0 +1,32 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Model + + # Subclass of `Hashie::Mash` to wrap Hash-like structures + # (responses from Elasticsearch, search definitions, etc) + # + # The primary goal of the subclass is to disable the + # warning being printed by Hashie for re-defined + # methods, such as `sort`. + # + class HashWrapper < ::Hashie::Mash + disable_warnings if respond_to?(:disable_warnings) + end + end +end diff --git a/elasticsearch-model/lib/elasticsearch/model/importing.rb b/elasticsearch-model/lib/elasticsearch/model/importing.rb index 56063f4f2..26e05458c 100644 --- a/elasticsearch-model/lib/elasticsearch/model/importing.rb +++ b/elasticsearch-model/lib/elasticsearch/model/importing.rb @@ -1,13 +1,28 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model - # Provides support for easily and efficiently importing large amounts of # records from the including class into the index. # # @see ClassMethods#import # module Importing - # When included in a model, adds the importing methods. # # @example Import all records from the `Article` model @@ -25,18 +40,38 @@ def self.included(base) end module ClassMethods - # Import all model records into the index # # The method will pick up correct strategy based on the `Importing` module # defined in the corresponding adapter. # - # @param options [Hash] Options passed to the underlying `__find_in_batches`method + # @param options [Hash] Options passed to the underlying `__find_in_batches` method # @param block [Proc] Optional block to evaluate for each batch # # @yield [Hash] Gives the Hash with the Elasticsearch response to the block # - # @return [Fixnum] Number of errors encountered during importing + # @return [Fixnum] default, number of errors encountered during importing + # @return [Array] if +return+ option is specified to be +"errors"+, + # contains only those failed items in the response +items+ key, e.g.: + # + # [ + # { + # "index" => { + # "error" => 'FAILED', + # "_index" => "test", + # "_id" => '1', + # "_version" => 1, + # "result" => "foo", + # "_shards" => { + # "total" => 1, + # "successful" => 0, + # "failed" => 1 + # }, + # "status" => 400 + # } + # } + # ] + # # # @example Import all records into the index # @@ -64,35 +99,86 @@ module ClassMethods # # Article.import index: 'my-new-index', type: 'my-other-type' # + # @example Pass an ActiveRecord scope to limit the imported records + # + # Article.import scope: 'published' + # + # @example Pass an ActiveRecord query to limit the imported records + # + # Article.import query: -> { where(author_id: author_id) } + # + # @example Transform records during the import with a lambda + # + # transform = lambda do |a| + # {index: {_id: a.id, _parent: a.author_id, data: a.__elasticsearch__.as_indexed_json}} + # end + # + # Article.import transform: transform + # + # @example Update the batch before yielding it + # + # class Article + # # ... + # def self.enrich(batch) + # batch.each do |item| + # item.metadata = MyAPI.get_metadata(item.id) + # end + # batch + # end + # end + # + # Article.import preprocess: :enrich + # + # @example Return an array of error elements instead of the number of errors, e.g. to try importing these records again + # + # Article.import return: 'errors' + # def import(options={}, &block) - errors = 0 - refresh = options.delete(:refresh) || false - target_index = options.delete(:index) || index_name - target_type = options.delete(:type) || document_type + errors = [] + refresh = options.delete(:refresh) || false + target_index = options.delete(:index) || index_name + transform = options.delete(:transform) || __transform + pipeline = options.delete(:pipeline) + return_value = options.delete(:return) || 'count' + + unless transform.respond_to?(:call) + raise ArgumentError, + "Pass an object responding to `call` as the :transform option, #{transform.class} given" + end if options.delete(:force) self.create_index! force: true, index: target_index + elsif !self.index_exists? index: target_index + raise ArgumentError, + "#{target_index} does not exist to be imported into. Use create_index! or the :force option to create it." end __find_in_batches(options) do |batch| - response = client.bulk \ - index: target_index, - type: target_type, - body: batch - + params = { + index: target_index, + body: __batch_to_bulk(batch, transform) + } + params[:pipeline] = pipeline if pipeline + response = client.bulk params yield response if block_given? - errors += response['items'].map { |k, v| k.values.first['error'] }.compact.length + errors += response['items'].select { |k, v| k.values.first['error'] } end - self.refresh_index! if refresh + self.refresh_index! index: target_index if refresh - return errors + case return_value + when 'errors' + errors + else + errors.size + end end + def __batch_to_bulk(batch, transform) + batch.map { |model| transform.call(model) } + end end - end - end end diff --git a/elasticsearch-model/lib/elasticsearch/model/indexing.rb b/elasticsearch-model/lib/elasticsearch/model/indexing.rb index 84aebcc75..0e9c68b30 100644 --- a/elasticsearch-model/lib/elasticsearch/model/indexing.rb +++ b/elasticsearch-model/lib/elasticsearch/model/indexing.rb @@ -1,6 +1,22 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model - # Provides the necessary support to set up index options (mappings, settings) # as well as instance methods to create, update or delete documents in the index. # @@ -12,8 +28,7 @@ module Model # @see InstanceMethods#delete_document # module Indexing - - # Wraps the [index settings](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/setup-configuration.html#configuration-index-settings) + # Wraps the [index settings](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) # class Settings attr_accessor :settings @@ -31,23 +46,24 @@ def as_json(options={}) end end - # Wraps the [index mappings](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping.html) + # Wraps the [index mappings](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html) # class Mappings attr_accessor :options - def initialize(type, options={}) - @type = type + TYPES_WITH_EMBEDDED_PROPERTIES = %w(object nested) + + def initialize(options={}) @options = options @mapping = {} end - def indexes(name, options = {}, &block) + def indexes(name, options={}, &block) @mapping[name] = options if block_given? @mapping[name][:type] ||= 'object' - properties = @mapping[name][:type] == 'multi_field' ? :fields : :properties + properties = TYPES_WITH_EMBEDDED_PROPERTIES.include?(@mapping[name][:type].to_s) ? :properties : :fields @mapping[name][properties] ||= {} @@ -60,15 +76,14 @@ def indexes(name, options = {}, &block) end end - # Set the type to `string` by default - # - @mapping[name][:type] ||= 'string' + # Set the type to `text` by default + @mapping[name][:type] ||= 'text' self end def to_hash - { @type.to_sym => @options.merge( properties: @mapping ) } + @options.merge( properties: @mapping ) end def as_json(options={}) @@ -77,7 +92,6 @@ def as_json(options={}) end module ClassMethods - # Defines mappings for the index # # @example Define mapping for model @@ -126,14 +140,14 @@ module ClassMethods # # => {:article=>{:dynamic=>"strict", :properties=>{:foo=>{:type=>"long"}}}} # # The `mappings` and `settings` methods are accessible directly on the model class, - # when it doesn't already defines them. Use the `__elasticsearch__` proxy otherwise. + # when it doesn't already define them. Use the `__elasticsearch__` proxy otherwise. # def mapping(options={}, &block) - @mapping ||= Mappings.new(document_type, options) + @mapping ||= Mappings.new(options) - if block_given? - @mapping.options.update(options) + @mapping.options.update(options) unless options.empty? + if block_given? @mapping.instance_eval(&block) return self else @@ -151,7 +165,39 @@ def mapping(options={}, &block) # # # => {:index=>{:number_of_shards=>1}} # + # You can read settings from any object that responds to :read + # as long as its return value can be parsed as either YAML or JSON. + # + # @example Define index settings from YAML file + # + # # config/elasticsearch/articles.yml: + # # + # # index: + # # number_of_shards: 1 + # # + # + # Article.settings File.open("config/elasticsearch/articles.yml") + # + # Article.settings.to_hash + # + # # => { "index" => { "number_of_shards" => 1 } } + # + # + # @example Define index settings from JSON file + # + # # config/elasticsearch/articles.json: + # # + # # { "index": { "number_of_shards": 1 } } + # # + # + # Article.settings File.open("config/elasticsearch/articles.json") + # + # Article.settings.to_hash + # + # # => { "index" => { "number_of_shards" => 1 } } + # def settings(settings={}, &block) + settings = YAML.load(settings.read) if settings.respond_to?(:read) @settings ||= Settings.new(settings) @settings.settings.update(settings) unless settings.empty? @@ -164,6 +210,10 @@ def settings(settings={}, &block) end end + def load_settings_from_io(settings) + YAML.load(settings.read) + end + # Creates an index with correct name, automatically passing # `settings` and `mappings` defined in the model # @@ -180,25 +230,40 @@ def settings(settings={}, &block) # Article.__elasticsearch__.create_index! index: 'my-index' # def create_index!(options={}) - target_index = options.delete(:index) || self.index_name + options = options.clone + + target_index = options.delete(:index) || self.index_name + settings = options.delete(:settings) || self.settings.to_hash + mappings = options.delete(:mappings) || self.mappings.to_hash delete_index!(options.merge index: target_index) if options[:force] - unless ( self.client.indices.exists(index: target_index) rescue false ) - begin - self.client.indices.create index: target_index, + unless index_exists?(index: target_index) + options.delete(:force) + self.client.indices.create({ index: target_index, body: { - settings: self.settings.to_hash, - mappings: self.mappings.to_hash } - rescue Exception => e - unless e.class.to_s =~ /NotFound/ && options[:force] - STDERR.puts "[!!!] Error when creating the index: #{e.class}", "#{e.message}" - end - end - else + settings: settings, + mappings: mappings } + }.merge(options)) end end + # Returns true if the index exists + # + # @example Check whether the model's index exists + # + # Article.__elasticsearch__.index_exists? + # + # @example Check whether a specific index exists + # + # Article.__elasticsearch__.index_exists? index: 'my-index' + # + def index_exists?(options={}) + target_index = options[:index] || self.index_name + + self.client.indices.exists(index: target_index, ignore: 404) + end + # Deletes the index with corresponding name # # @example Delete the index for the `Article` model @@ -215,8 +280,11 @@ def delete_index!(options={}) begin self.client.indices.delete index: target_index rescue Exception => e - unless e.class.to_s =~ /NotFound/ && options[:force] - STDERR.puts "[!!!] Error when deleting the index: #{e.class}", "#{e.message}" + if e.class.to_s =~ /NotFound/ && options[:force] + client.transport.logger.debug("[!!!] Index does not exist (#{e.class})") if client.transport.logger + nil + else + raise e end end end @@ -231,7 +299,7 @@ def delete_index!(options={}) # # Article.__elasticsearch__.refresh_index! index: 'my-index' # - # @see http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/indices-refresh.html + # @see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html # def refresh_index!(options={}) target_index = options.delete(:index) || self.index_name @@ -239,8 +307,11 @@ def refresh_index!(options={}) begin self.client.indices.refresh index: target_index rescue Exception => e - unless e.class.to_s =~ /NotFound/ && options[:force] - STDERR.puts "[!!!] Error when refreshing the index: #{e.class}", "#{e.message}" + if e.class.to_s =~ /NotFound/ && options[:force] + client.transport.logger.debug("[!!!] Index does not exist (#{e.class})") if client.transport.logger + nil + else + raise e end end end @@ -250,17 +321,27 @@ module InstanceMethods def self.included(base) # Register callback for storing changed attributes for models - # which implement `before_save` and `changed_attributes` methods + # which implement `before_save` and return changed attributes + # (ie. when `Elasticsearch::Model` is included) # # @note This is typically triggered only when the module would be # included in the model directly, not within the proxy. # # @see #update_document # - base.before_save do |instance| - instance.instance_variable_set(:@__changed_attributes, - Hash[ instance.changes.map { |key, value| [key, value.last] } ]) - end if base.respond_to?(:before_save) && base.instance_methods.include?(:changed_attributes) + base.before_save do |obj| + if obj.respond_to?(:changes_to_save) # Rails 5.1 + changes_to_save = obj.changes_to_save + elsif obj.respond_to?(:changes) + changes_to_save = obj.changes + end + + if changes_to_save + attrs = obj.instance_variable_get(:@__changed_model_attributes) || {} + latest_changes = changes_to_save.inject({}) { |latest_changes, (k,v)| latest_changes.merge!(k => v.last) } + obj.instance_variable_set(:@__changed_model_attributes, attrs.merge(latest_changes)) + end + end if base.respond_to?(:before_save) end # Serializes the model instance into JSON (by calling `as_indexed_json`), @@ -278,14 +359,12 @@ def self.included(base) # @see http://rubydoc.info/gems/elasticsearch-api/Elasticsearch/API/Actions:index # def index_document(options={}) - document = self.as_indexed_json - - client.index( - { index: index_name, - type: document_type, - id: self.id, - body: document }.merge(options) - ) + document = as_indexed_json + request = { index: index_name, + id: id, + body: document } + + client.index(request.merge!(options)) end # Deletes the model instance from the index @@ -302,11 +381,10 @@ def index_document(options={}) # @see http://rubydoc.info/gems/elasticsearch-api/Elasticsearch/API/Actions:delete # def delete_document(options={}) - client.delete( - { index: index_name, - type: document_type, - id: self.id }.merge(options) - ) + request = { index: index_name, + id: self.id } + + client.delete(request.merge!(options)) end # Tries to gather the changed attributes of a model instance @@ -315,6 +393,8 @@ def delete_document(options={}) # # When the changed attributes are not available, performs full re-index of the record. # + # See the {#update_document_attributes} method for updating specific attributes directly. + # # @param options [Hash] Optional arguments for passing to the client # # @example Update a document corresponding to the record @@ -332,17 +412,46 @@ def delete_document(options={}) # @see http://rubydoc.info/gems/elasticsearch-api/Elasticsearch/API/Actions:update # def update_document(options={}) - if changed_attributes = self.instance_variable_get(:@__changed_attributes) - client.update( - { index: index_name, - type: document_type, - id: self.id, - body: { doc: changed_attributes } }.merge(options) - ) + if attributes_in_database = self.instance_variable_get(:@__changed_model_attributes).presence + attributes = if respond_to?(:as_indexed_json) + self.as_indexed_json.select { |k,v| attributes_in_database.keys.map(&:to_s).include? k.to_s } + else + attributes_in_database + end + + unless attributes.empty? + request = { index: index_name, + id: self.id, + body: { doc: attributes } } + + client.update(request.merge!(options)) + end else index_document(options) end end + + # Perform a _partial_ update of specific document attributes + # (without consideration for changed attributes as in {#update_document}) + # + # @param attributes [Hash] Attributes to be updated + # @param options [Hash] Optional arguments for passing to the client + # + # @example Update the `title` attribute + # + # @article = Article.first + # @article.title = "New title" + # @article.__elasticsearch__.update_document_attributes title: "New title" + # + # @return [Hash] The response from Elasticsearch + # + def update_document_attributes(attributes, options={}) + request = { index: index_name, + id: self.id, + body: { doc: attributes } } + + client.update(request.merge!(options)) + end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/multimodel.rb b/elasticsearch-model/lib/elasticsearch/model/multimodel.rb new file mode 100644 index 000000000..c519ea968 --- /dev/null +++ b/elasticsearch-model/lib/elasticsearch/model/multimodel.rb @@ -0,0 +1,91 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Model + # Keeps a global registry of classes that include `Elasticsearch::Model` + # + class Registry + def initialize + @models = [] + end + + # Returns the unique instance of the registry (Singleton) + # + # @api private + # + def self.__instance + @instance ||= new + end + + # Adds a model to the registry + # + def self.add(klass) + __instance.add(klass) + end + + # Returns an Array of registered models + # + def self.all + __instance.models + end + + # Adds a model to the registry + # + def add(klass) + @models << klass + end + + # Returns a copy of the registered models + # + def models + @models.dup + end + end + + # Wraps a collection of models when querying multiple indices + # + # @see Elasticsearch::Model.search + # + class Multimodel + attr_reader :models + + # @param models [Class] The list of models across which the search will be performed + # + def initialize(*models) + @models = models.flatten + @models = Model::Registry.all if @models.empty? + end + + # Get an Array of index names used for retrieving documents when doing a search across multiple models + # + # @return [Array] the list of index names used for retrieving documents + # + def index_name + models.map { |m| m.index_name } + end + + # Get the client common for all models + # + # @return Elastic::Transport::Client + # + def client + Elasticsearch::Model.client + end + end + end +end diff --git a/elasticsearch-model/lib/elasticsearch/model/naming.rb b/elasticsearch-model/lib/elasticsearch/model/naming.rb index caae5c47c..643f3559b 100644 --- a/elasticsearch-model/lib/elasticsearch/model/naming.rb +++ b/elasticsearch-model/lib/elasticsearch/model/naming.rb @@ -1,12 +1,26 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model - - # Provides methods for getting and setting index name and document type for the model + # Provides methods for getting and setting index and name for the model # module Naming - module ClassMethods - # Get or set the name of the index # # @example Set the index name for the `Article` model @@ -15,14 +29,27 @@ module ClassMethods # index_name "articles-#{Rails.env}" # end # + # @example Set the index name for the `Article` model and re-evaluate it on each call + # + # class Article + # index_name { "articles-#{Time.now.year}" } + # end + # # @example Directly set the index name for the `Article` model # # Article.index_name "articles-#{Rails.env}" # - # TODO: Dynamic names a la Tire -- `Article.index_name { "articles-#{Time.now.year}" }` # - def index_name name=nil - @index_name = name || @index_name || self.model_name.collection.gsub(/\//, '-') + def index_name name=nil, &block + if name || block_given? + return (@index_name = name || block) + end + + if @index_name.respond_to?(:call) + @index_name.call + else + @index_name || implicit(:index_name) + end end # Set the index name @@ -32,34 +59,18 @@ def index_name=(name) @index_name = name end - # Get or set the document type - # - # @example Set the document type for the `Article` model - # - # class Article - # document_type "my-article" - # end - # - # @example Directly set the document type for the `Article` model - # - # Article.document_type "my-article" - # - def document_type name=nil - @document_type = name || @document_type || self.model_name.element - end + private + def implicit(prop) + self.send("default_#{prop}") + end - # Set the document type - # - # @see document_type - # - def document_type=(name) - @document_type = name + def default_index_name + self.model_name.collection.gsub(/\//, '-') end end module InstanceMethods - # Get or set the index name for the model instance # # @example Set the index name for an instance of the `Article` model @@ -67,8 +78,16 @@ module InstanceMethods # @article.index_name "articles-#{@article.user_id}" # @article.__elasticsearch__.update_document # - def index_name name=nil - @index_name = name || @index_name || self.class.index_name + def index_name name=nil, &block + if name || block_given? + return (@index_name = name || block) + end + + if @index_name.respond_to?(:call) + @index_name.call + else + @index_name || self.class.index_name + end end # Set the index name @@ -77,25 +96,7 @@ def index_name name=nil def index_name=(name) @index_name = name end - - # @example Set the document type for an instance of the `Article` model - # - # @article.document_type "my-article" - # @article.__elasticsearch__.update_document - # - def document_type name=nil - @document_type = name || @document_type || self.class.document_type - end - - # Set the document type - # - # @see document_type - # - def document_type=(name) - @document_type = name - end end - end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/proxy.rb b/elasticsearch-model/lib/elasticsearch/model/proxy.rb index 90e1a9a44..8a93bc359 100644 --- a/elasticsearch-model/lib/elasticsearch/model/proxy.rb +++ b/elasticsearch-model/lib/elasticsearch/model/proxy.rb @@ -1,16 +1,32 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model - # This module provides a proxy interfacing between the including class and - # {Elasticsearch::Model}, preventing the pollution of the including class namespace. + # `Elasticsearch::Model`, preventing the pollution of the including class namespace. # # The only "gateway" between the model and Elasticsearch::Model is the - # `__elasticsearch__` class and instance method. + # `#__elasticsearch__` class and instance method. # # The including class must be compatible with # [ActiveModel](https://github.com/rails/rails/tree/master/activemodel). # - # @example Include the {Elasticsearch::Model} module into an `Article` model + # @example Include the `Elasticsearch::Model` module into an `Article` model # # class Article < ActiveRecord::Base # include Elasticsearch::Model @@ -28,7 +44,6 @@ module Model # # => true # module Proxy - # Define the `__elasticsearch__` class and instance methods in the including class # and register a callback for intercepting changes in the model. # @@ -37,34 +52,56 @@ module Proxy # def self.included(base) base.class_eval do - # {ClassMethodsProxy} instance, accessed as `MyModel.__elasticsearch__` - # + # `ClassMethodsProxy` instance, accessed as `MyModel.__elasticsearch__` def self.__elasticsearch__ &block @__elasticsearch__ ||= ClassMethodsProxy.new(self) @__elasticsearch__.instance_eval(&block) if block_given? @__elasticsearch__ end - # {InstanceMethodsProxy}, accessed as `@mymodel.__elasticsearch__` - # - def __elasticsearch__ &block - @__elasticsearch__ ||= InstanceMethodsProxy.new(self) - @__elasticsearch__.instance_eval(&block) if block_given? - @__elasticsearch__ + # Mix the importing module into the `ClassMethodsProxy` + self.__elasticsearch__.class_eval do + include Adapter.from_class(base).importing_mixin end # Register a callback for storing changed attributes for models which implement - # `before_save` and `changed_attributes` methods (when `Elasticsearch::Model` is included) + # `before_save` method and return changed attributes (ie. when `Elasticsearch::Model` is included) # # @see http://api.rubyonrails.org/classes/ActiveModel/Dirty.html # - before_save do |i| - i.__elasticsearch__.instance_variable_set(:@__changed_attributes, - Hash[ i.changes.map { |key, value| [key, value.last] } ]) - end if respond_to?(:before_save) && instance_methods.include?(:changed_attributes) + before_save do |obj| + if obj.respond_to?(:changes_to_save) # Rails 5.1 + changes_to_save = obj.changes_to_save + elsif obj.respond_to?(:changes) + changes_to_save = obj.changes + end + + if changes_to_save + attrs = obj.__elasticsearch__.instance_variable_get(:@__changed_model_attributes) || {} + latest_changes = changes_to_save.inject({}) { |latest_changes, (k,v)| latest_changes.merge!(k => v.last) } + obj.__elasticsearch__.instance_variable_set(:@__changed_model_attributes, attrs.merge(latest_changes)) + end + end if respond_to?(:before_save) + end + + # {InstanceMethodsProxy}, accessed as `@mymodel.__elasticsearch__` + # + def __elasticsearch__ &block + @__elasticsearch__ ||= InstanceMethodsProxy.new(self) + @__elasticsearch__.instance_eval(&block) if block_given? + @__elasticsearch__ end end + # @overload dup + # + # Returns a copy of this object. Resets the __elasticsearch__ proxy so + # the duplicate will build its own proxy. + def initialize_dup(_) + @__elasticsearch__ = nil + super + end + # Common module for the proxy classes # module Base @@ -74,15 +111,18 @@ def initialize(target) @target = target end - # Delegate methods to `@target` + def self.ruby2_keywords(*) # :nodoc: + end if RUBY_VERSION < "2.7" + + # Delegate methods to `@target`. As per [the Ruby 3.0 explanation for keyword arguments](https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/), the only way to work on Ruby <2.7, and 2.7, and 3.0+ is to use `ruby2_keywords`. # - def method_missing(method_name, *arguments, &block) + ruby2_keywords def method_missing(method_name, *arguments, &block) target.respond_to?(method_name) ? target.__send__(method_name, *arguments, &block) : super end # Respond to methods from `@target` # - def respond_to?(method_name, include_private = false) + def respond_to_missing?(method_name, include_private = false) target.respond_to?(method_name) || super end @@ -97,6 +137,11 @@ def inspect # class ClassMethodsProxy include Base + include Elasticsearch::Model::Client::ClassMethods + include Elasticsearch::Model::Naming::ClassMethods + include Elasticsearch::Model::Indexing::ClassMethods + include Elasticsearch::Model::Searching::ClassMethods + include Elasticsearch::Model::Importing::ClassMethods end # A proxy interfacing between Elasticsearch::Model instance methods and model instance methods @@ -105,6 +150,10 @@ class ClassMethodsProxy # class InstanceMethodsProxy include Base + include Elasticsearch::Model::Client::InstanceMethods + include Elasticsearch::Model::Naming::InstanceMethods + include Elasticsearch::Model::Indexing::InstanceMethods + include Elasticsearch::Model::Serializing::InstanceMethods def klass target.class @@ -120,8 +169,11 @@ def class def as_json(options={}) target.as_json(options) end - end + def as_indexed_json(options={}) + target.respond_to?(:as_indexed_json) ? target.__send__(:as_indexed_json, options) : super + end + end end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/response.rb b/elasticsearch-model/lib/elasticsearch/model/response.rb index 04e802eda..5d79a1827 100644 --- a/elasticsearch-model/lib/elasticsearch/model/response.rb +++ b/elasticsearch-model/lib/elasticsearch/model/response.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model @@ -10,8 +27,7 @@ module Response # Implements Enumerable and forwards its methods to the {#results} object. # class Response - attr_reader :klass, :search, :response, - :took, :timed_out, :shards + attr_reader :klass, :search include Enumerable @@ -27,7 +43,7 @@ def initialize(klass, search, options={}) # @return [Hash] # def response - @response ||= search.execute! + @response ||= HashWrapper.new(search.execute!) end # Returns the collection of "hits" from Elasticsearch @@ -42,26 +58,42 @@ def results # # @return [Records] # - def records - @records ||= Records.new(klass, self) + def records(options = {}) + @records ||= Records.new(klass, self, options) end # Returns the "took" time # def took - response['took'] + raw_response['took'] end # Returns whether the response timed out # def timed_out - response['timed_out'] + raw_response['timed_out'] end # Returns the statistics on shards # def shards - Hashie::Mash.new(response['_shards']) + @shards ||= response['_shards'] + end + + # Returns a Hashie::Mash of the aggregations + # + def aggregations + @aggregations ||= Aggregations.new(raw_response['aggregations']) + end + + # Returns a Hashie::Mash of the suggestions + # + def suggestions + @suggestions ||= Suggestions.new(raw_response['suggest']) + end + + def raw_response + @raw_response ||= @response ? @response.to_hash : search.execute! end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/response/aggregations.rb b/elasticsearch-model/lib/elasticsearch/model/response/aggregations.rb new file mode 100644 index 000000000..6fc4a8e89 --- /dev/null +++ b/elasticsearch-model/lib/elasticsearch/model/response/aggregations.rb @@ -0,0 +1,55 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Model + module Response + + class Aggregations < HashWrapper + disable_warnings if respond_to?(:disable_warnings) + + def initialize(attributes={}) + __redefine_enumerable_methods super(attributes) + end + + # Fix the problem of Hashie::Mash returning unexpected values for `min` and `max` methods + # + # People can define names for aggregations such as `min` and `max`, but these + # methods are defined in `Enumerable#min` and `Enumerable#max` + # + # { foo: 'bar' }.min + # # => [:foo, "bar"] + # + # Therefore, any Hashie::Mash instance value has the `min` and `max` + # methods redefined to return the real value + # + def __redefine_enumerable_methods(h) + if h.respond_to?(:each_pair) + h.each_pair { |k, v| v = __redefine_enumerable_methods(v) } + end + if h.is_a?(Hashie::Mash) + class << h + define_method(:min) { self[:min] } + define_method(:max) { self[:max] } + end + end + end + end + + end + end +end diff --git a/elasticsearch-model/lib/elasticsearch/model/response/base.rb b/elasticsearch-model/lib/elasticsearch/model/response/base.rb index 3bb8005b6..21853d5bb 100644 --- a/elasticsearch-model/lib/elasticsearch/model/response/base.rb +++ b/elasticsearch-model/lib/elasticsearch/model/response/base.rb @@ -1,10 +1,27 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model module Response # Common funtionality for classes in the {Elasticsearch::Model::Response} module # module Base - attr_reader :klass, :response + attr_reader :klass, :response, :raw_response # @param klass [Class] The name of the model class # @param response [Hash] The full response returned from Elasticsearch client @@ -12,7 +29,8 @@ module Base # def initialize(klass, response, options={}) @klass = klass - @response = response + @raw_response = response + @response = response end # @abstract Implement this method in specific class @@ -30,7 +48,11 @@ def records # Returns the total number of hits # def total - response.response['hits']['total'] + if response.response['hits']['total'].respond_to?(:keys) + response.response['hits']['total']['value'] + else + response.response['hits']['total'] + end end # Returns the max_score diff --git a/elasticsearch-model/lib/elasticsearch/model/response/pagination.rb b/elasticsearch-model/lib/elasticsearch/model/response/pagination.rb index fa312fdde..f12604179 100644 --- a/elasticsearch-model/lib/elasticsearch/model/response/pagination.rb +++ b/elasticsearch-model/lib/elasticsearch/model/response/pagination.rb @@ -1,96 +1,19 @@ -module Elasticsearch - module Model - module Response - - # Pagination for search results/records - # - module Pagination - # Allow models to be paginated with the "kaminari" gem [https://github.com/amatsuda/kaminari] - # - module Kaminari - def self.included(base) - # Include the Kaminari configuration and paging method in response - # - base.__send__ :include, ::Kaminari::ConfigurationMethods::ClassMethods - base.__send__ :include, ::Kaminari::PageScopeMethods - - # Include the Kaminari paging methods in results and records - # - Elasticsearch::Model::Response::Results.__send__ :include, ::Kaminari::ConfigurationMethods::ClassMethods - Elasticsearch::Model::Response::Results.__send__ :include, ::Kaminari::PageScopeMethods - Elasticsearch::Model::Response::Records.__send__ :include, ::Kaminari::PageScopeMethods - - Elasticsearch::Model::Response::Results.__send__ :delegate, :limit_value, :offset_value, :total_count, to: :response - Elasticsearch::Model::Response::Records.__send__ :delegate, :limit_value, :offset_value, :total_count, to: :response - - base.class_eval <<-RUBY, __FILE__, __LINE__ + 1 - # Define the `page` Kaminari method - # - def #{::Kaminari.config.page_method_name}(num=nil) - @results = nil - @records = nil - @response = nil - self.search.definition.update size: klass.default_per_page, - from: klass.default_per_page * ([num.to_i, 1].max - 1) - self - end - RUBY - end - - # Returns the current "limit" (`size`) value - # - def limit_value - case - when search.definition[:body] && search.definition[:body][:size] - search.definition[:body][:size] - when search.definition[:size] - search.definition[:size] - else - 0 - end - end - - # Returns the current "offset" (`from`) value - # - def offset_value - case - when search.definition[:body] && search.definition[:body][:from] - search.definition[:body][:from] - when search.definition[:from] - search.definition[:from] - else - 0 - end - end - - # Set the "limit" (`size`) value - # - def limit(value) - @results = nil - @records = nil - @response = nil - search.definition.update :size => value - self - end - - # Set the "offset" (`from`) value - # - def offset(value) - @results = nil - @records = nil - @response = nil - search.definition.update :from => value - self - end - - # Returns the total number of results - # - def total_count - results.total - end - end - end - - end - end -end +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'elasticsearch/model/response/pagination/kaminari' +require 'elasticsearch/model/response/pagination/will_paginate' diff --git a/elasticsearch-model/lib/elasticsearch/model/response/pagination/kaminari.rb b/elasticsearch-model/lib/elasticsearch/model/response/pagination/kaminari.rb new file mode 100644 index 000000000..66de07ccb --- /dev/null +++ b/elasticsearch-model/lib/elasticsearch/model/response/pagination/kaminari.rb @@ -0,0 +1,126 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Model + module Response + + # Pagination for search results/records + # + module Pagination + # Allow models to be paginated with the "kaminari" gem [https://github.com/amatsuda/kaminari] + # + module Kaminari + def self.included(base) + # Include the Kaminari configuration and paging method in response + # + base.__send__ :include, ::Kaminari::ConfigurationMethods::ClassMethods + base.__send__ :include, ::Kaminari::PageScopeMethods + + # Include the Kaminari paging methods in results and records + # + Elasticsearch::Model::Response::Results.__send__ :include, ::Kaminari::ConfigurationMethods::ClassMethods + Elasticsearch::Model::Response::Results.__send__ :include, ::Kaminari::PageScopeMethods + Elasticsearch::Model::Response::Records.__send__ :include, ::Kaminari::PageScopeMethods + + Elasticsearch::Model::Response::Results.__send__ :delegate, :limit_value, :offset_value, :total_count, :max_pages, to: :response + Elasticsearch::Model::Response::Records.__send__ :delegate, :limit_value, :offset_value, :total_count, :max_pages, to: :response + + base.class_eval <<-RUBY, __FILE__, __LINE__ + 1 + # Define the `page` Kaminari method + # + def #{::Kaminari.config.page_method_name}(num=nil) + @results = nil + @records = nil + @response = nil + @page = [num.to_i, 1].max + @per_page ||= __default_per_page + + self.search.definition.update size: @per_page, + from: @per_page * (@page - 1) + + self + end + RUBY + end + + # Returns the current "limit" (`size`) value + # + def limit_value + case + when search.definition[:size] + search.definition[:size] + else + __default_per_page + end + end + + # Returns the current "offset" (`from`) value + # + def offset_value + case + when search.definition[:from] + search.definition[:from] + else + 0 + end + end + + # Set the "limit" (`size`) value + # + def limit(value) + return self if value.to_i <= 0 + @results = nil + @records = nil + @response = nil + @per_page = value.to_i + + search.definition.update :size => @per_page + search.definition.update :from => @per_page * (@page - 1) if @page + self + end + + # Set the "offset" (`from`) value + # + def offset(value) + return self if value.to_i < 0 + @results = nil + @records = nil + @response = nil + @page = nil + search.definition.update :from => value.to_i + self + end + + # Returns the total number of results + # + def total_count + results.total + end + + # Returns the models's `per_page` value or the default + # + # @api private + # + def __default_per_page + klass.respond_to?(:default_per_page) && klass.default_per_page || ::Kaminari.config.default_per_page + end + end + end + end + end +end diff --git a/elasticsearch-model/lib/elasticsearch/model/response/pagination/will_paginate.rb b/elasticsearch-model/lib/elasticsearch/model/response/pagination/will_paginate.rb new file mode 100644 index 000000000..2d4658544 --- /dev/null +++ b/elasticsearch-model/lib/elasticsearch/model/response/pagination/will_paginate.rb @@ -0,0 +1,112 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Model + module Response + + # Pagination for search results/records + # + module Pagination + + + # Allow models to be paginated with the "will_paginate" gem [https://github.com/mislav/will_paginate] + # + module WillPaginate + def self.included(base) + base.__send__ :include, ::WillPaginate::CollectionMethods + + # Include the paging methods in results and records + # + methods = [:current_page, :offset, :length, :per_page, :total_entries, :total_pages, :previous_page, :next_page, :out_of_bounds?] + Elasticsearch::Model::Response::Results.__send__ :delegate, *methods, to: :response + Elasticsearch::Model::Response::Records.__send__ :delegate, *methods, to: :response + end + + def offset + (current_page - 1) * per_page + end + + def length + search.definition[:size] + end + + # Main pagination method + # + # @example + # + # Article.search('foo').paginate(page: 1, per_page: 30) + # + def paginate(options) + param_name = options[:param_name] || :page + page = [options[param_name].to_i, 1].max + per_page = (options[:per_page] || __default_per_page).to_i + + search.definition.update size: per_page, + from: (page - 1) * per_page + self + end + + # Return the current page + # + def current_page + search.definition[:from] / per_page + 1 if search.definition[:from] && per_page + end + + # Pagination method + # + # @example + # + # Article.search('foo').page(2) + # + def page(num) + paginate(page: num, per_page: per_page) # shorthand + end + + # Return or set the "size" value + # + # @example + # + # Article.search('foo').per_page(15).page(2) + # + def per_page(num = nil) + if num.nil? + search.definition[:size] + else + paginate(page: current_page, per_page: num) # shorthand + end + end + + # Returns the total number of results + # + def total_entries + results.total + end + + # Returns the models's `per_page` value or the default + # + # @api private + # + def __default_per_page + klass.respond_to?(:per_page) && klass.per_page || ::WillPaginate.per_page + end + end + end + + end + end +end diff --git a/elasticsearch-model/lib/elasticsearch/model/response/records.rb b/elasticsearch-model/lib/elasticsearch/model/response/records.rb index 6383e7e9d..e18ecc83d 100644 --- a/elasticsearch-model/lib/elasticsearch/model/response/records.rb +++ b/elasticsearch-model/lib/elasticsearch/model/response/records.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model module Response @@ -12,6 +29,8 @@ class Records delegate :each, :empty?, :size, :slice, :[], :to_a, :to_ary, to: :records + attr_accessor :options + include Base # @see Base#initialize @@ -25,7 +44,7 @@ def initialize(klass, response, options={}) metaclass = class << self; self; end metaclass.__send__ :include, adapter.records_mixin - self + self.options = options end # Returns the hit IDs @@ -43,13 +62,13 @@ def results # Yields [record, hit] pairs to the block # def each_with_hit(&block) - records.zip(results).each(&block) + records.to_a.zip(results).each(&block) end # Yields [record, hit] pairs and returns the result # def map_with_hit(&block) - records.zip(results).map(&block) + records.to_a.zip(results).map(&block) end # Delegate methods to `@records` diff --git a/elasticsearch-model/lib/elasticsearch/model/response/result.rb b/elasticsearch-model/lib/elasticsearch/model/response/result.rb index 9cb7ccb82..d293e6efa 100644 --- a/elasticsearch-model/lib/elasticsearch/model/response/result.rb +++ b/elasticsearch-model/lib/elasticsearch/model/response/result.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model module Response @@ -14,17 +31,25 @@ class Result # @param attributes [Hash] A Hash with document properties # def initialize(attributes={}) - @result = Hashie::Mash.new(attributes) + @result = HashWrapper.new(attributes) + end + + # Return document `_id` as `id` + # + def id + @result['_id'] end # Delegate methods to `@result` or `@result._source` # - def method_missing(method_name, *arguments) + def method_missing(name, *arguments) case - when @result.respond_to?(method_name.to_sym) - @result.__send__ method_name.to_sym, *arguments - when @result._source && @result._source.respond_to?(method_name.to_sym) - @result._source.__send__ method_name.to_sym, *arguments + when name.to_s.end_with?('?') + @result.__send__(name, *arguments) || ( @result._source && @result._source.__send__(name, *arguments) ) + when @result.respond_to?(name) + @result.__send__ name, *arguments + when @result._source && @result._source.respond_to?(name) + @result._source.__send__ name, *arguments else super end @@ -32,7 +57,7 @@ def method_missing(method_name, *arguments) # Respond to methods from `@result` or `@result._source` # - def respond_to?(method_name, include_private = false) + def respond_to_missing?(method_name, include_private = false) @result.respond_to?(method_name.to_sym) || \ @result._source && @result._source.respond_to?(method_name.to_sym) || \ super @@ -43,7 +68,6 @@ def as_json(options={}) end # TODO: #to_s, #inspect, with support for Pry - end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/response/results.rb b/elasticsearch-model/lib/elasticsearch/model/response/results.rb index d185eae6f..564a684a3 100644 --- a/elasticsearch-model/lib/elasticsearch/model/response/results.rb +++ b/elasticsearch-model/lib/elasticsearch/model/response/results.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model module Response @@ -22,9 +39,10 @@ def initialize(klass, response, options={}) # def results # TODO: Configurable custom wrapper - @results = response.response['hits']['hits'].map { |hit| Result.new(hit) } + response.response['hits']['hits'].map { |hit| Result.new(hit) } end + alias records results end end end diff --git a/elasticsearch-model/lib/elasticsearch/model/response/suggestions.rb b/elasticsearch-model/lib/elasticsearch/model/response/suggestions.rb new file mode 100644 index 000000000..72261a930 --- /dev/null +++ b/elasticsearch-model/lib/elasticsearch/model/response/suggestions.rb @@ -0,0 +1,32 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Model + module Response + + class Suggestions < HashWrapper + disable_warnings if respond_to?(:disable_warnings) + + def terms + self.to_a.map { |k,v| v.first['options'] }.flatten.map {|v| v['text']}.uniq + end + end + + end + end +end diff --git a/elasticsearch-model/lib/elasticsearch/model/searching.rb b/elasticsearch-model/lib/elasticsearch/model/searching.rb index b71ebbab2..e913444dd 100644 --- a/elasticsearch-model/lib/elasticsearch/model/searching.rb +++ b/elasticsearch-model/lib/elasticsearch/model/searching.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model @@ -8,7 +25,7 @@ module Searching # Wraps a search request definition # class SearchRequest - attr_reader :klass, :definition + attr_reader :klass, :definition, :options # @param klass [Class] The class of the model # @param query_or_payload [String,Hash,Object] The search request definition @@ -17,9 +34,9 @@ class SearchRequest # def initialize(klass, query_or_payload, options={}) @klass = klass + @options = options __index_name = options[:index] || klass.index_name - __document_type = options[:type] || klass.document_type case # search query: ... @@ -36,9 +53,9 @@ def initialize(klass, query_or_payload, options={}) end if body - @definition = { index: __index_name, type: __document_type, body: body }.update options + @definition = { index: __index_name, body: body }.update options else - @definition = { index: __index_name, type: __document_type, q: q }.update options + @definition = { index: __index_name, q: q }.update options end end @@ -78,7 +95,8 @@ module ClassMethods # fields: { # title: {} # } - # } + # }, + # size: 50 # # response.results.first.title # # => "Foo" diff --git a/elasticsearch-model/lib/elasticsearch/model/serializing.rb b/elasticsearch-model/lib/elasticsearch/model/serializing.rb index 659a58bb2..9433dfa12 100644 --- a/elasticsearch-model/lib/elasticsearch/model/serializing.rb +++ b/elasticsearch-model/lib/elasticsearch/model/serializing.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model diff --git a/elasticsearch-model/lib/elasticsearch/model/version.rb b/elasticsearch-model/lib/elasticsearch/model/version.rb index f1c6ffe1c..eb3aebc12 100644 --- a/elasticsearch-model/lib/elasticsearch/model/version.rb +++ b/elasticsearch-model/lib/elasticsearch/model/version.rb @@ -1,5 +1,22 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Model - VERSION = "0.1.0" + VERSION = '8.0.0'.freeze end end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapter_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapter_spec.rb new file mode 100644 index 000000000..a2f2f850a --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapter_spec.rb @@ -0,0 +1,125 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Adapter do + before(:all) do + class ::DummyAdapterClass; end + class ::DummyAdapterClassWithAdapter; end + class ::DummyAdapter + Records = Module.new + Callbacks = Module.new + Importing = Module.new + end + end + + after(:all) do + [DummyAdapterClassWithAdapter, DummyAdapterClass, DummyAdapter].each do |adapter| + Elasticsearch::Model::Adapter::Adapter.adapters.delete(adapter) + end + remove_classes(DummyAdapterClass, DummyAdapterClassWithAdapter, DummyAdapter) + end + + describe '#from_class' do + it 'should return an Adapter instance' do + expect(Elasticsearch::Model::Adapter.from_class(DummyAdapterClass)).to be_a(Elasticsearch::Model::Adapter::Adapter) + end + end + + describe 'register' do + before do + expect(Elasticsearch::Model::Adapter::Adapter).to receive(:register).and_call_original + Elasticsearch::Model::Adapter.register(:foo, lambda { |c| false }) + end + + it 'should register an adapter' do + expect(Elasticsearch::Model::Adapter::Adapter.adapters[:foo]).to be_a(Proc) + end + + context 'when a specific adapter class is set' do + before do + expect(Elasticsearch::Model::Adapter::Adapter).to receive(:register).and_call_original + Elasticsearch::Model::Adapter::Adapter.register(DummyAdapter, + lambda { |c| c == DummyAdapterClassWithAdapter }) + end + + let(:adapter) do + Elasticsearch::Model::Adapter::Adapter.new(DummyAdapterClassWithAdapter) + end + + it 'should register the adapter' do + expect(adapter.adapter).to eq(DummyAdapter) + end + end + end + + describe 'default adapter' do + let(:adapter) do + Elasticsearch::Model::Adapter::Adapter.new(DummyAdapterClass) + end + + it 'sets a default adapter' do + expect(adapter.adapter).to eq(Elasticsearch::Model::Adapter::Default) + end + end + + describe '#records_mixin' do + before do + Elasticsearch::Model::Adapter::Adapter.register(DummyAdapter, + lambda { |c| c == DummyAdapterClassWithAdapter }) + end + + let(:adapter) do + Elasticsearch::Model::Adapter::Adapter.new(DummyAdapterClassWithAdapter) + end + + it 'returns a Module' do + expect(adapter.records_mixin).to be_a(Module) + end + end + + describe '#callbacks_mixin' do + before do + Elasticsearch::Model::Adapter::Adapter.register(DummyAdapter, + lambda { |c| c == DummyAdapterClassWithAdapter }) + end + + let(:adapter) do + Elasticsearch::Model::Adapter::Adapter.new(DummyAdapterClassWithAdapter) + end + + it 'returns a Module' do + expect(adapter.callbacks_mixin).to be_a(Module) + end + end + + describe '#importing_mixin' do + before do + Elasticsearch::Model::Adapter::Adapter.register(DummyAdapter, + lambda { |c| c == DummyAdapterClassWithAdapter }) + end + + let(:adapter) do + Elasticsearch::Model::Adapter::Adapter.new(DummyAdapterClassWithAdapter) + end + + it 'returns a Module' do + expect(adapter.importing_mixin).to be_a(Module) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/associations_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/associations_spec.rb new file mode 100644 index 000000000..14000b693 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/associations_spec.rb @@ -0,0 +1,323 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Adapter::ActiveRecord Associations' do + before(:all) do + ActiveRecord::Schema.define(version: 1) do + create_table :categories do |t| + t.string :title + t.timestamps null: false + end + + create_table :categories_posts do |t| + t.references :post, :category + end + + create_table :authors do |t| + t.string :first_name, :last_name + t.timestamps null: false + end + + create_table :authorships do |t| + t.string :first_name, :last_name + t.references :post + t.references :author + t.timestamps null: false + end + + create_table :comments do |t| + t.string :text + t.string :author + t.references :post + t.timestamps null: false + end + + add_index(:comments, :post_id) unless index_exists?(:comments, :post_id) + + create_table :posts do |t| + t.string :title + t.text :text + t.boolean :published + t.timestamps null: false + end + end + + Comment.__send__ :include, Elasticsearch::Model + Comment.__send__ :include, Elasticsearch::Model::Callbacks + end + + before do + clear_tables(:categories, :categories_posts, :authors, :authorships, :comments, :posts) + clear_indices(Post) + Post.__elasticsearch__.create_index!(force: true) + Comment.__elasticsearch__.create_index!(force: true) + end + + after do + clear_tables(Post, Category) + clear_indices(Post) + end + + context 'when a document is created' do + before do + Post.create!(title: 'Test') + Post.create!(title: 'Testing Coding') + Post.create!(title: 'Coding') + Post.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Post.search('title:test') + end + + it 'indexes the document' do + expect(search_result.results.size).to eq(2) + expect(search_result.results.first.title).to eq('Test') + expect(search_result.records.size).to eq(2) + expect(search_result.records.first.title).to eq('Test') + end + end + + describe 'has_many_and_belongs_to association' do + context 'when an association is updated' do + before do + post.categories = [category_a, category_b] + Post.__elasticsearch__.refresh_index! + end + + let(:category_a) do + Category.where(title: "One").first_or_create! + end + + let(:category_b) do + Category.where(title: "Two").first_or_create! + end + + let(:post) do + Post.create! title: "First Post", text: "This is the first post..." + end + + let(:search_result) do + Post.search(query: { + bool: { + must: { + multi_match: { + fields: ['title'], + query: 'first' + } + }, + filter: { + terms: { + categories: ['One'] + } + } + } + } ) + end + + it 'applies the update with' do + expect(search_result.results.size).to eq(1) + expect(search_result.results.first.title).to eq('First Post') + expect(search_result.records.size).to eq(1) + expect(search_result.records.first.title).to eq('First Post') + end + end + + context 'when an association is deleted' do + before do + post.categories = [category_a, category_b] + post.categories = [category_b] + Post.__elasticsearch__.refresh_index! + end + + let(:category_a) do + Category.where(title: "One").first_or_create! + end + + let(:category_b) do + Category.where(title: "Two").first_or_create! + end + + let(:post) do + Post.create! title: "First Post", text: "This is the first post..." + end + + let(:search_result) do + Post.search(query: { + bool: { + must: { + multi_match: { + fields: ['title'], + query: 'first' + } + }, + filter: { + terms: { + categories: ['One'] + } + } + } + } ) + end + + it 'applies the update with a reindex' do + expect(search_result.results.size).to eq(0) + expect(search_result.records.size).to eq(0) + end + end + end + + describe 'has_many through association' do + context 'when the association is updated' do + before do + author_a = Author.where(first_name: "John", last_name: "Smith").first_or_create! + author_b = Author.where(first_name: "Mary", last_name: "Smith").first_or_create! + author_c = Author.where(first_name: "Kobe", last_name: "Griss").first_or_create! + + # Create posts + post_1 = Post.create!(title: "First Post", text: "This is the first post...") + post_2 = Post.create!(title: "Second Post", text: "This is the second post...") + post_3 = Post.create!(title: "Third Post", text: "This is the third post...") + + # Assign authors + post_1.authors = [author_a, author_b] + post_2.authors = [author_a] + post_3.authors = [author_c] + + Post.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Post.search('authors.full_name:john') + end + + it 'applies the update' do + expect(search_result.results.size).to eq(2) + expect(search_result.records.size).to eq(2) + end + end + + context 'when an association is added' do + before do + author_a = Author.where(first_name: "John", last_name: "Smith").first_or_create! + author_b = Author.where(first_name: "Mary", last_name: "Smith").first_or_create! + + # Create posts + post_1 = Post.create!(title: "First Post", text: "This is the first post...") + + # Assign authors + post_1.authors = [author_a] + post_1.authors << author_b + Post.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Post.search('authors.full_name:john') + end + + it 'adds the association' do + expect(search_result.results.size).to eq(1) + expect(search_result.records.size).to eq(1) + end + end + end + + describe 'has_many association' do + context 'when an association is added' do + before do + # Create posts + post_1 = Post.create!(title: "First Post", text: "This is the first post...") + post_2 = Post.create!(title: "Second Post", text: "This is the second post...") + + # Add comments + post_1.comments.create!(author: 'John', text: 'Excellent') + post_1.comments.create!(author: 'Abby', text: 'Good') + + post_2.comments.create!(author: 'John', text: 'Terrible') + + post_1.comments.create!(author: 'John', text: 'Or rather just good...') + Post.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Post.search(query: { + nested: { + path: 'comments', + query: { + bool: { + must: [ + { match: { 'comments.author' => 'john' } }, + { match: { 'comments.text' => 'good' } } + ] + } + } + } + }) + end + + it 'adds the association' do + expect(search_result.results.size).to eq(1) + end + end + end + + describe '#touch' do + context 'when a touch callback is defined on the model' do + before do + # Create categories + category_a = Category.where(title: "One").first_or_create! + + # Create post + post = Post.create!(title: "First Post", text: "This is the first post...") + + # Assign category + post.categories << category_a + category_a.update_attribute(:title, "Updated") + category_a.posts.each { |p| p.touch } + + Post.__elasticsearch__.refresh_index! + end + + it 'executes the callback after #touch' do + expect(Post.search('categories:One').size).to eq(0) + expect(Post.search('categories:Updated').size).to eq(1) + end + end + end + + describe '#includes' do + before do + post_1 = Post.create(title: 'One') + post_2 = Post.create(title: 'Two') + post_1.comments.create(text: 'First comment') + post_2.comments.create(text: 'Second comment') + + Comment.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Comment.search('first').records(includes: :post) + end + + it 'eager loads associations' do + expect(search_result.first.association(:post)).to be_loaded + expect(search_result.first.post.title).to eq('One') + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/basic_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/basic_spec.rb new file mode 100644 index 000000000..d334685b2 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/basic_spec.rb @@ -0,0 +1,323 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Adapter::ActiveRecord do + context 'for the Model' do + before(:all) do + ActiveRecord::Schema.define(:version => 1) do + create_table :articles do |t| + t.string :title + t.string :body + t.integer :clicks, :default => 0 + t.datetime :created_at, :default => 'NOW()' + end + end + + Article.delete_all + Article.__elasticsearch__.create_index!(force: true) + + Article.create!(title: 'Test', body: '', clicks: 1) + Article.create!(title: 'Testing Coding', body: '', clicks: 2) + Article.create!(title: 'Coding', body: '', clicks: 3) + + Article.__elasticsearch__.refresh_index! + end + + describe 'indexing a document' do + let(:search_result) do + Article.search('title:test') + end + + it 'allows searching for documents' do + expect(search_result.results.size).to be(2) + expect(search_result.records.size).to be(2) + end + end + + describe '#results' do + let(:search_result) do + Article.search('title:test') + end + + it 'returns an instance of Response::Result' do + expect(search_result.results.first).to be_a(Elasticsearch::Model::Response::Result) + end + + it 'properly loads the document' do + expect(search_result.results.first.title).to eq('Test') + end + + context 'when the result contains other data' do + let(:search_result) do + Article.search(query: { match: { title: 'test' } }, highlight: { fields: { title: {} } }) + end + + it 'allows access to the Elasticsearch result' do + expect(search_result.results.first.title).to eq('Test') + expect(search_result.results.first.title?).to be(true) + expect(search_result.results.first.boo?).to be(false) + expect(search_result.results.first.highlight?).to be(true) + expect(search_result.results.first.highlight.title?).to be(true) + expect(search_result.results.first.highlight.boo?).to be(false) + end + end + end + + describe '#records' do + let(:search_result) do + Article.search('title:test') + end + + it 'returns an instance of the model' do + expect(search_result.records.first).to be_a(Article) + end + + it 'prooperly loads the document' do + expect(search_result.records.first.title).to eq('Test') + end + end + + describe 'Enumerable' do + let(:search_result) do + Article.search('title:test') + end + + it 'allows iteration over results' do + expect(search_result.results.map(&:_id)).to eq(['1', '2']) + end + + it 'allows iteration over records' do + expect(search_result.records.map(&:id)).to eq([1, 2]) + end + end + + describe '#id' do + let(:search_result) do + Article.search('title:test') + end + + it 'returns the id' do + expect(search_result.results.first.id).to eq('1') + end + end + + describe '#each_with_hit' do + let(:search_result) do + Article.search('title:test') + end + + it 'returns the record with the Elasticsearch hit' do + search_result.records.each_with_hit do |r, h| + expect(h._score).not_to be_nil + expect(h._source.title).not_to be_nil + end + end + end + + describe 'search results order' do + let(:search_result) do + Article.search(query: { match: { title: 'code' }}, sort: { clicks: :desc }) + end + + it 'preserves the search results order when accessing a single record' do + expect(search_result.records[0].clicks).to be(3) + expect(search_result.records[1].clicks).to be(2) + expect(search_result.records.first).to eq(search_result.records[0]) + end + + it 'preserves the search results order for the list of records' do + search_result.records.each_with_hit do |r, h| + expect(r.id.to_s).to eq(h._id) + end + + search_result.records.map_with_hit do |r, h| + expect(r.id.to_s).to eq(h._id) + end + end + end + + describe 'a paged collection' do + let(:search_result) do + Article.search(query: { match: { title: { query: 'test' } } }, + size: 2, + from: 1) + end + + it 'applies the paged options to the search' do + expect(search_result.results.size).to eq(1) + expect(search_result.results.first.title).to eq('Testing Coding') + expect(search_result.records.size).to eq(1) + expect(search_result.records.first.title).to eq('Testing Coding') + end + end + + describe '#destroy' do + before do + Article.create!(title: 'destroy', body: '', clicks: 1) + Article.__elasticsearch__.refresh_index! + Article.where(title: 'destroy').first.destroy + + Article.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Article.search('title:test') + end + + it 'removes the document from the index' do + expect(Article.count).to eq(3) + expect(search_result.results.size).to eq(2) + expect(search_result.records.size).to eq(2) + end + end + + describe 'full document updates' do + before do + article = Article.create!(title: 'update', body: '', clicks: 1) + Article.__elasticsearch__.refresh_index! + article.title = 'Writing' + article.save + + Article.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Article.search('title:write') + end + + it 'applies the update' do + expect(search_result.results.size).to eq(1) + expect(search_result.records.size).to eq(1) + end + end + + describe 'attribute updates' do + before do + article = Article.create!(title: 'update', body: '', clicks: 1) + Article.__elasticsearch__.refresh_index! + article.title = 'special' + article.save + + Article.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Article.search('title:special') + end + + it 'applies the update' do + expect(search_result.results.size).to eq(1) + expect(search_result.records.size).to eq(1) + end + end + + describe '#save' do + before do + article = Article.create!(title: 'save', body: '', clicks: 1) + + ActiveRecord::Base.transaction do + article.body = 'dummy' + article.save + + article.title = 'special' + article.save + end + + article.__elasticsearch__.update_document + Article.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Article.search('body:dummy') + end + + it 'applies the save' do + expect(search_result.results.size).to eq(1) + expect(search_result.records.size).to eq(1) + end + end + + describe 'a DSL search' do + let(:search_result) do + Article.search(query: { match: { title: { query: 'test' } } }) + end + + it 'returns the results' do + expect(search_result.results.size).to eq(2) + expect(search_result.records.size).to eq(2) + end + end + + describe 'chaining SQL queries on response.records' do + let(:search_result) do + Article.search(query: { match: { title: { query: 'test' } } }) + end + + it 'executes the SQL request with the chained query criteria' do + expect(search_result.records.size).to eq(2) + expect(search_result.records.where(title: 'Test').size).to eq(1) + expect(search_result.records.where(title: 'Test').first.title).to eq('Test') + end + end + + describe 'ordering of SQL queries' do + context 'when order is called on the ActiveRecord query' do + let(:search_result) do + Article.search query: { match: { title: { query: 'test' } } } + end + + it 'allows the SQL query to be ordered independent of the Elasticsearch results order' do + expect(search_result.records.order(title: :desc).first.title).to eq('Testing Coding') + expect(search_result.records.order(title: :desc)[0].title).to eq('Testing Coding') + end + end + + context 'when more methods are chained on the ActiveRecord query' do + let(:search_result) do + Article.search query: {match: {title: {query: 'test'}}} + end + + it 'allows the SQL query to be ordered independent of the Elasticsearch results order' do + expect(search_result.records.distinct.order(title: :desc).first.title).to eq('Testing Coding') + expect(search_result.records.distinct.order(title: :desc)[0].title).to eq('Testing Coding') + end + end + end + + describe 'access to the response via methods' do + let(:search_result) do + Article.search(query: { match: { title: { query: 'test' } } }, + aggregations: { + dates: { date_histogram: { field: 'created_at', calendar_interval: 'hour' } }, + clicks: { global: {}, aggregations: { min: { min: { field: 'clicks' } } } } + }, + suggest: { text: 'tezt', title: { term: { field: 'title', suggest_mode: 'always' } } }) + end + + it 'allows document keys to be access via methods' do + expect(search_result.aggregations.dates.buckets.first.doc_count).to eq(2) + expect(search_result.aggregations.clicks.doc_count).to eq(6) + expect(search_result.aggregations.clicks.min.value).to eq(1.0) + expect(search_result.aggregations.clicks.max).to be_nil + expect(search_result.suggestions.title.first.options.size).to eq(1) + expect(search_result.suggestions.terms).to eq(['test']) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/dynamic_index_name_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/dynamic_index_name_spec.rb new file mode 100644 index 000000000..abb4a0bdc --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/dynamic_index_name_spec.rb @@ -0,0 +1,35 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Adapter::ActiveRecord Dynamic Index naming' do + + before do + ArticleWithDynamicIndexName.counter = 0 + end + + it 'exavlues the index_name value' do + expect(ArticleWithDynamicIndexName.index_name).to eq('articles-1') + end + + it 'revaluates the index name with each call' do + expect(ArticleWithDynamicIndexName.index_name).to eq('articles-1') + expect(ArticleWithDynamicIndexName.index_name).to eq('articles-2') + expect(ArticleWithDynamicIndexName.index_name).to eq('articles-3') + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/import_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/import_spec.rb new file mode 100644 index 000000000..73be18c59 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/import_spec.rb @@ -0,0 +1,197 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Adapter::ActiveRecord Importing' do + before(:all) do + ActiveRecord::Schema.define(version: 1) do + create_table :import_articles do |t| + t.string :title + t.integer :views + t.string :numeric # For the sake of invalid data sent to Elasticsearch + t.datetime :created_at, default: 'NOW()' + end + end + + ImportArticle.delete_all + ImportArticle.__elasticsearch__.client.cluster.health(wait_for_status: 'yellow') + end + + before do + ImportArticle.__elasticsearch__.create_index! + end + + after do + clear_indices(ImportArticle) + clear_tables(ImportArticle) + end + + describe '#import' do + context 'when no search criteria is specified' do + before do + 10.times { |i| ImportArticle.create! title: 'Test', views: i.to_s } + ImportArticle.import + ImportArticle.__elasticsearch__.refresh_index! + end + + it 'imports all documents' do + expect(ImportArticle.search('*').results.total).to eq(10) + end + + it "does not pollute the model's namespace" do + expect(ImportArticle.methods).not_to include(:__transform) + end + end + + context 'when batch size is specified' do + before do + 10.times { |i| ImportArticle.create! title: 'Test', views: "#{i}" } + end + + let!(:batch_count) do + batches = 0 + errors = ImportArticle.import(batch_size: 5) do |response| + batches += 1 + end + ImportArticle.__elasticsearch__.refresh_index! + batches + end + + it 'imports using the batch size' do + expect(batch_count).to eq(2) + end + + it 'imports all the documents' do + expect(ImportArticle.search('*').results.total).to eq(10) + end + end + + context 'when a scope is specified' do + before do + 10.times { |i| ImportArticle.create! title: 'Test', views: "#{i}" } + ImportArticle.import(scope: 'popular', force: true) + ImportArticle.__elasticsearch__.refresh_index! + end + + it 'applies the scope' do + expect(ImportArticle.search('*').results.total).to eq(5) + end + end + + context 'when a query is specified' do + before do + 10.times { |i| ImportArticle.create! title: 'Test', views: "#{i}" } + ImportArticle.import(query: -> { where('views >= 3') }) + ImportArticle.__elasticsearch__.refresh_index! + end + + it 'applies the query' do + expect(ImportArticle.search('*').results.total).to eq(7) + end + end + + context 'when there are invalid documents' do + let!(:result) do + 10.times { |i| ImportArticle.create! title: 'Test', views: "#{i}" } + new_article + batches = 0 + errors = ImportArticle.__elasticsearch__.import(batch_size: 5) do |response| + batches += 1 + end + ImportArticle.__elasticsearch__.refresh_index! + { batch_size: batches, errors: errors} + end + + let(:new_article) do + ImportArticle.create!(title: "Test INVALID", numeric: "INVALID") + end + + it 'does not import them' do + expect(ImportArticle.search('*').results.total).to eq(10) + expect(result[:batch_size]).to eq(3) + expect(result[:errors]).to eq(1) + end + end + + context 'when a transform proc is specified' do + before do + 10.times { |i| ImportArticle.create! title: 'Test', views: "#{i}" } + ImportArticle.import( transform: ->(a) {{ index: { data: { name: a.title, foo: 'BAR' } }}} ) + ImportArticle.__elasticsearch__.refresh_index! + end + + it 'transforms the documents' do + expect(ImportArticle.search('*').results.first._source.keys).to include('name') + expect(ImportArticle.search('*').results.first._source.keys).to include('foo') + end + + it 'imports all documents' do + expect(ImportArticle.search('test').results.total).to eq(10) + expect(ImportArticle.search('bar').results.total).to eq(10) + end + end + + context 'when the model has a default scope' do + around(:all) do |example| + 10.times { |i| ImportArticle.create! title: 'Test', views: "#{i}" } + ImportArticle.instance_eval { default_scope { where('views > 3') } } + example.run + ImportArticle.default_scopes.pop + end + + before do + ImportArticle.__elasticsearch__.import + ImportArticle.__elasticsearch__.refresh_index! + end + + it 'uses the default scope' do + expect(ImportArticle.search('*').results.total).to eq(6) + end + end + + context 'when there is a default scope and a query specified' do + around(:all) do |example| + 10.times { |i| ImportArticle.create! title: 'Test', views: "#{i}" } + ImportArticle.instance_eval { default_scope { where('views > 3') } } + example.run + ImportArticle.default_scopes.pop + end + + before do + ImportArticle.import(query: -> { where('views <= 4') }) + ImportArticle.__elasticsearch__.refresh_index! + end + + it 'combines the query and the default scope' do + expect(ImportArticle.search('*').results.total).to eq(1) + end + end + + context 'when the batch is empty' do + before do + ImportArticle.delete_all + ImportArticle.import + ImportArticle.__elasticsearch__.refresh_index! + end + + it 'does not make any requests to create documents' do + expect(ImportArticle.search('*').results.total).to eq(0) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/multi_model_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/multi_model_spec.rb new file mode 100644 index 000000000..c8196242a --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/multi_model_spec.rb @@ -0,0 +1,127 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Adapter::ActiveRecord MultiModel' do + + before(:all) do + ActiveRecord::Schema.define do + create_table Episode.table_name do |t| + t.string :name + t.datetime :created_at, :default => 'NOW()' + end + + create_table Series.table_name do |t| + t.string :name + t.datetime :created_at, :default => 'NOW()' + end + end + end + + before do + models = [ Episode, Series ] + clear_tables(models) + models.each do |model| + model.__elasticsearch__.create_index! force: true + model.create name: "The #{model.name}" + model.create name: "A great #{model.name}" + model.create name: "The greatest #{model.name}" + model.__elasticsearch__.refresh_index! + end + end + + after do + clear_indices(Episode, Series) + clear_tables(Episode, Series) + end + + context 'when the search is across multimodels' do + + let(:search_result) do + Elasticsearch::Model.search(%q<"The greatest Episode"^2 OR "The greatest Series">, [Series, Episode]) + end + + it 'executes the search across models' do + expect(search_result.results.size).to eq(2) + expect(search_result.records.size).to eq(2) + end + + describe '#results' do + + it 'returns an instance of Elasticsearch::Model::Response::Result' do + expect(search_result.results[0]).to be_a(Elasticsearch::Model::Response::Result) + expect(search_result.results[1]).to be_a(Elasticsearch::Model::Response::Result) + end + + it 'returns the correct model instance' do + expect(search_result.results[0].name).to eq('The greatest Episode') + expect(search_result.results[1].name).to eq('The greatest Series') + end + + it 'provides access to the results' do + expect(search_result.results[0].name).to eq('The greatest Episode') + expect(search_result.results[0].name?).to be(true) + expect(search_result.results[0].boo?).to be(false) + + expect(search_result.results[1].name).to eq('The greatest Series') + expect(search_result.results[1].name?).to be(true) + expect(search_result.results[1].boo?).to be(false) + end + end + + describe '#records' do + + it 'returns an instance of Elasticsearch::Model::Response::Result' do + expect(search_result.records[0]).to be_a(Episode) + expect(search_result.records[1]).to be_a(Series) + end + + it 'returns the correct model instance' do + expect(search_result.records[0].name).to eq('The greatest Episode') + expect(search_result.records[1].name).to eq('The greatest Series') + end + + context 'when the data store is changed' do + + before do + Series.find_by_name("The greatest Series").delete + Series.__elasticsearch__.refresh_index! + end + + it 'only returns matching records' do + expect(search_result.results.size).to eq(2) + expect(search_result.records.size).to eq(1 ) + expect(search_result.records[0].name).to eq('The greatest Episode') + end + end + end + + describe 'pagination' do + + let(:search_result) do + Elasticsearch::Model.search('series OR episode', [Series, Episode]) + end + + it 'properly paginates the results' do + expect(search_result.page(1).per(3).results.size).to eq(3) + expect(search_result.page(2).per(3).results.size).to eq(3) + expect(search_result.page(3).per(3).results.size).to eq(0) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/namespaced_model_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/namespaced_model_spec.rb new file mode 100644 index 000000000..09f4670a6 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/namespaced_model_spec.rb @@ -0,0 +1,51 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Adapter::ActiveRecord Namespaced Model' do + + before(:all) do + ActiveRecord::Schema.define(:version => 1) do + create_table :books do |t| + t.string :title + end + end + + MyNamespace::Book.delete_all + MyNamespace::Book.__elasticsearch__.create_index!(force: true) + MyNamespace::Book.create!(title: 'Test') + MyNamespace::Book.__elasticsearch__.refresh_index! + end + + after do + clear_indices(MyNamespace::Book) + clear_tables(MyNamespace::Book) + end + + context 'when the model is namespaced' do + + it 'has the proper index name' do + expect(MyNamespace::Book.index_name).to eq('my_namespace-books') + end + + it 'saves the document into the index' do + expect(MyNamespace::Book.search('title:test').results.size).to eq(1) + expect(MyNamespace::Book.search('title:test').results.first.title).to eq('Test') + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/pagination_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/pagination_spec.rb new file mode 100644 index 000000000..3cbd30b7f --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/pagination_spec.rb @@ -0,0 +1,324 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Adapter::ActiveRecord Pagination' do + before(:all) do + ActiveRecord::Schema.define(:version => 1) do + create_table ArticleForPagination.table_name do |t| + t.string :title + t.datetime :created_at, :default => 'NOW()' + t.boolean :published + end + end + + Kaminari::Hooks.init if defined?(Kaminari::Hooks) + + ArticleForPagination.__elasticsearch__.create_index! force: true + + 68.times do |i| + ArticleForPagination.create! title: "Test #{i}", published: (i % 2 == 0) + end + + ArticleForPagination.import + ArticleForPagination.__elasticsearch__.refresh_index! + end + + context 'when no other page is specified' do + let(:records) do + ArticleForPagination.search('title:test').page(1).records + end + + describe '#size' do + it 'returns the correct size' do + expect(records.size).to eq(25) + end + end + + describe '#current_page' do + it 'returns the correct current page' do + expect(records.current_page).to eq(1) + end + end + + describe '#prev_page' do + it 'returns the correct previous page' do + expect(records.prev_page).to be_nil + end + end + + describe '#next_page' do + it 'returns the correct next page' do + expect(records.next_page).to eq(2) + end + end + + describe '#total_pages' do + it 'returns the correct total pages' do + expect(records.total_pages).to eq(3) + end + end + + describe '#first_page?' do + it 'returns the correct first page' do + expect(records.first_page?).to be(true) + end + end + + describe '#last_page?' do + + it 'returns the correct last page' do + expect(records.last_page?).to be(false) + end + end + + describe '#out_of_range?' do + + it 'returns whether the pagination is out of range' do + expect(records.out_of_range?).to be(false) + end + end + end + + context 'when a specific page is specified' do + + let(:records) do + ArticleForPagination.search('title:test').page(2).records + end + + describe '#size' do + + it 'returns the correct size' do + expect(records.size).to eq(25) + end + end + + describe '#current_page' do + + it 'returns the correct current page' do + expect(records.current_page).to eq(2) + end + end + + describe '#prev_page' do + + it 'returns the correct previous page' do + expect(records.prev_page).to eq(1) + end + end + + describe '#next_page' do + + it 'returns the correct next page' do + expect(records.next_page).to eq(3) + end + end + + describe '#total_pages' do + + it 'returns the correct total pages' do + expect(records.total_pages).to eq(3) + end + end + + describe '#first_page?' do + + it 'returns the correct first page' do + expect(records.first_page?).to be(false) + end + end + + describe '#last_page?' do + + it 'returns the correct last page' do + expect(records.last_page?).to be(false) + end + end + + describe '#out_of_range?' do + + it 'returns whether the pagination is out of range' do + expect(records.out_of_range?).to be(false) + end + end + end + + context 'when a the last page is specified' do + + let(:records) do + ArticleForPagination.search('title:test').page(3).records + end + + describe '#size' do + + it 'returns the correct size' do + expect(records.size).to eq(18) + end + end + + describe '#current_page' do + + it 'returns the correct current page' do + expect(records.current_page).to eq(3) + end + end + + describe '#prev_page' do + + it 'returns the correct previous page' do + expect(records.prev_page).to eq(2) + end + end + + describe '#next_page' do + + it 'returns the correct next page' do + expect(records.next_page).to be_nil + end + end + + describe '#total_pages' do + + it 'returns the correct total pages' do + expect(records.total_pages).to eq(3) + end + end + + describe '#first_page?' do + + it 'returns the correct first page' do + expect(records.first_page?).to be(false) + end + end + + describe '#last_page?' do + + it 'returns the correct last page' do + expect(records.last_page?).to be(true) + end + end + + describe '#out_of_range?' do + + it 'returns whether the pagination is out of range' do + expect(records.out_of_range?).to be(false) + end + end + end + + context 'when an invalid page is specified' do + + let(:records) do + ArticleForPagination.search('title:test').page(6).records + end + + describe '#size' do + + it 'returns the correct size' do + expect(records.size).to eq(0) + end + end + + describe '#current_page' do + + it 'returns the correct current page' do + expect(records.current_page).to eq(6) + end + end + + describe '#next_page' do + + it 'returns the correct next page' do + expect(records.next_page).to be_nil + end + end + + describe '#total_pages' do + + it 'returns the correct total pages' do + expect(records.total_pages).to eq(3) + end + end + + describe '#first_page?' do + + it 'returns the correct first page' do + expect(records.first_page?).to be(false) + end + end + + describe '#last_page?' do + + it 'returns whether it is the last page', if: !(Kaminari::VERSION < '1') do + expect(records.last_page?).to be(false) + end + + it 'returns whether it is the last page', if: Kaminari::VERSION < '1' do + expect(records.last_page?).to be(true) # Kaminari returns current_page >= total_pages in version < 1.0 + end + end + + describe '#out_of_range?' do + + it 'returns whether the pagination is out of range' do + expect(records.out_of_range?).to be(true) + end + end + end + + context 'when a scope is also specified' do + + let(:records) do + ArticleForPagination.search('title:test').page(2).records.published + end + + describe '#size' do + + it 'returns the correct size' do + expect(records.size).to eq(12) + end + end + end + + context 'when a sorting is specified' do + + let(:search) do + ArticleForPagination.search({ query: { match: { title: 'test' } }, sort: [ { id: 'desc' } ] }) + end + + it 'applies the sort' do + expect(search.page(2).records.first.id).to eq(43) + expect(search.page(3).records.first.id).to eq(18) + expect(search.page(2).per(5).records.first.id).to eq(63) + end + end + + context 'when the model has a specific default per page set' do + + around do |example| + original_default = ArticleForPagination.instance_variable_get(:@_default_per_page) + ArticleForPagination.paginates_per 50 + example.run + ArticleForPagination.paginates_per original_default + end + + it 'uses the default per page setting' do + expect(ArticleForPagination.search('*').page(1).records.size).to eq(50) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/parent_child_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/parent_child_spec.rb new file mode 100644 index 000000000..a9feb9b58 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/parent_child_spec.rb @@ -0,0 +1,89 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Adapter::ActiveRecord Parent-Child' do + + before(:all) do + ActiveRecord::Schema.define(version: 1) do + create_table :questions do |t| + t.string :title + t.text :text + t.string :author + t.timestamps null: false + end + + create_table :answers do |t| + t.text :text + t.string :author + t.references :question + t.timestamps null: false + end + + add_index(:answers, :question_id) unless index_exists?(:answers, :question_id) + + clear_tables(Question) + ParentChildSearchable.create_index!(force: true) + + q_1 = Question.create!(title: 'First Question', author: 'John') + q_2 = Question.create!(title: 'Second Question', author: 'Jody') + + q_1.answers.create!(text: 'Lorem Ipsum', author: 'Adam') + q_1.answers.create!(text: 'Dolor Sit', author: 'Ryan') + + q_2.answers.create!(text: 'Amet Et', author: 'John') + + Question.__elasticsearch__.refresh_index! + end + end + + describe 'has_child search' do + let(:search_result) do + Question.search(query: { has_child: { type: 'answer', query: { match: { author: 'john' } } } }) + end + + it 'finds parents by matching on child search criteria' do + expect(search_result.records.first.title).to eq('Second Question') + end + end + + describe 'hash_parent search' do + let(:search_result) do + Answer.search(query: { has_parent: { parent_type: 'question', query: { match: { author: 'john' } } } }) + end + + it 'finds children by matching in parent criteria' do + expect(search_result.records.map(&:author)).to match(['Adam', 'Ryan']) + end + end + + context 'when a parent is deleted' do + before do + Question.where(title: 'First Question').each(&:destroy) + Question.__elasticsearch__.refresh_index! + end + + let(:search_result) do + Answer.search(query: { has_parent: { parent_type: 'question', query: { match_all: {} } } }) + end + + it 'deletes the children' do + expect(search_result.results.total).to eq(1) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/serialization_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/serialization_spec.rb new file mode 100644 index 000000000..3837169a9 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record/serialization_spec.rb @@ -0,0 +1,76 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Adapter::ActiveRecord Serialization' do + before(:all) do + ActiveRecord::Schema.define(:version => 1) do + create_table ArticleWithCustomSerialization.table_name do |t| + t.string :title + t.string :status + end + end + + ArticleWithCustomSerialization.delete_all + ArticleWithCustomSerialization.__elasticsearch__.create_index!(force: true) + end + + context 'when the model has a custom serialization defined' do + before do + ArticleWithCustomSerialization.create!(title: 'Test', status: 'green') + ArticleWithCustomSerialization.__elasticsearch__.refresh_index! + end + + context 'when a document is indexed' do + let(:search_result) do + ArticleWithCustomSerialization.__elasticsearch__.client.get( + index: 'article_with_custom_serializations', + id: '1' + ) + end + + it 'applies the serialization when indexing' do + expect(search_result['_source']).to eq('title' => 'Test') + end + end + + context 'when a document is updated' do + before do + article.update(title: 'UPDATED', status: 'yellow') + ArticleWithCustomSerialization.__elasticsearch__.refresh_index! + end + + let!(:article) do + art = ArticleWithCustomSerialization.create!(title: 'Test', status: 'red') + ArticleWithCustomSerialization.__elasticsearch__.refresh_index! + art + end + + let(:search_result) do + ArticleWithCustomSerialization.__elasticsearch__.client.get( + index: 'article_with_custom_serializations', + id: article.id + ) + end + + it 'applies the serialization when updating' do + expect(search_result['_source']).to eq('title' => 'UPDATED') + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/active_record_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record_spec.rb new file mode 100644 index 000000000..8a6fe0171 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/active_record_spec.rb @@ -0,0 +1,224 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Adapter::ActiveRecord do + + before(:all) do + class DummyClassForActiveRecord; end + end + + after(:all) do + Elasticsearch::Model::Adapter::Adapter.adapters.delete(DummyClassForActiveRecord) + remove_classes(DummyClassForActiveRecord) + end + + let(:model) do + DummyClassForActiveRecord.new.tap do |m| + allow(m).to receive(:response).and_return(double('response', response: response)) + allow(m).to receive(:ids).and_return(ids) + end + end + + let(:response) do + { 'hits' => {'hits' => [ {'_id' => 2 }, {'_id' => 1 } ]} } + end + + let(:ids) do + [2, 1] + end + + let(:record_1) do + double('record').tap do |rec| + allow(rec).to receive(:id).and_return(1) + end + end + + let(:record_2) do + double('record').tap do |rec| + allow(rec).to receive(:id).and_return(2) + end + end + + let(:records) do + [record_1, record_2].tap do |r| + allow(r).to receive(:load).and_return(true) + allow(r).to receive(:exec_queries).and_return(true) + end + end + + describe 'adapter registration' do + + before(:all) do + DummyClassForActiveRecord.__send__ :include, Elasticsearch::Model::Adapter::ActiveRecord::Records + end + + it 'can register an adapater' do + expect(Elasticsearch::Model::Adapter.adapters[Elasticsearch::Model::Adapter::ActiveRecord]).not_to be_nil + expect(Elasticsearch::Model::Adapter.adapters[Elasticsearch::Model::Adapter::ActiveRecord].call(DummyClassForActiveRecord)).to be(false) + end + end + + describe '#records' do + + before(:all) do + DummyClassForActiveRecord.__send__ :include, Elasticsearch::Model::Adapter::ActiveRecord::Records + end + + let(:instance) do + model.tap do |inst| + allow(inst).to receive(:klass).and_return(double('class', primary_key: :some_key, where: records)).at_least(:once) + allow(inst).to receive(:order).and_return(double('class', primary_key: :some_key, where: records)).at_least(:once) + end + end + + it 'returns the list of records' do + expect(instance.records).to eq(records) + end + + it 'loads the records' do + expect(instance.load).to eq(true) + end + + context 'when :includes is specified' do + + before do + expect(records).to receive(:includes).with([:submodel]).once.and_return(records) + instance.options[:includes] = [:submodel] + end + + it 'incorporates the includes option in the query' do + expect(instance.records).to eq(records) + end + end + end + + describe 'callbacks registration' do + + before do + expect(DummyClassForActiveRecord).to receive(:after_commit).exactly(3).times + end + + it 'should register the model class for callbacks' do + Elasticsearch::Model::Adapter::ActiveRecord::Callbacks.included(DummyClassForActiveRecord) + end + end + + describe 'importing' do + + before do + DummyClassForActiveRecord.__send__ :extend, Elasticsearch::Model::Adapter::ActiveRecord::Importing + end + + context 'when an invalid scope is specified' do + + it 'raises a NoMethodError' do + expect { + DummyClassForActiveRecord.__find_in_batches(scope: :not_found_method) + }.to raise_exception(NoMethodError) + end + end + + context 'when a valid scope is specified' do + + before do + expect(DummyClassForActiveRecord).to receive(:find_in_batches).once.and_return([]) + expect(DummyClassForActiveRecord).to receive(:published).once.and_return(DummyClassForActiveRecord) + end + + it 'uses the scope' do + expect(DummyClassForActiveRecord.__find_in_batches(scope: :published)).to eq([]) + end + end + + context 'allow query criteria to be specified' do + + before do + expect(DummyClassForActiveRecord).to receive(:find_in_batches).once.and_return([]) + expect(DummyClassForActiveRecord).to receive(:where).with(color: 'red').once.and_return(DummyClassForActiveRecord) + end + + it 'uses the scope' do + expect(DummyClassForActiveRecord.__find_in_batches(query: -> { where(color: 'red') })).to eq([]) + end + end + + context 'when preprocessing batches' do + + context 'if the query returns results' do + + before do + class << DummyClassForActiveRecord + def find_in_batches(options = {}, &block) + yield [:a, :b] + end + + def update_batch(batch) + batch.collect { |b| b.to_s + '!' } + end + end + end + + it 'applies the preprocessing method' do + DummyClassForActiveRecord.__find_in_batches(preprocess: :update_batch) do |batch| + expect(batch).to match(['a!', 'b!']) + end + end + end + + context 'if the query does not return results' do + + before do + class << DummyClassForActiveRecord + def find_in_batches(options = {}, &block) + yield [:a, :b] + end + + def update_batch(batch) + [] + end + end + end + + it 'applies the preprocessing method' do + DummyClassForActiveRecord.__find_in_batches(preprocess: :update_batch) do |batch| + expect(batch).to match([]) + end + end + end + end + + context 'when transforming models' do + + let(:instance) do + model.tap do |inst| + allow(inst).to receive(:id).and_return(1) + allow(inst).to receive(:__elasticsearch__).and_return(double('object', id: 1, as_indexed_json: {})) + end + end + + it 'returns an proc' do + expect(DummyClassForActiveRecord.__transform.respond_to?(:call)).to be(true) + end + + it 'provides a default transformation' do + expect(DummyClassForActiveRecord.__transform.call(instance)).to eq(index: { _id: 1, data: {} }) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/default_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/default_spec.rb new file mode 100644 index 000000000..cbc59fa70 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/default_spec.rb @@ -0,0 +1,58 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Adapter::Default do + + before(:all) do + class DummyClassForDefaultAdapter; end + DummyClassForDefaultAdapter.__send__ :include, Elasticsearch::Model::Adapter::Default::Records + DummyClassForDefaultAdapter.__send__ :include, Elasticsearch::Model::Adapter::Default::Importing + end + + after(:all) do + Elasticsearch::Model::Adapter::Adapter.adapters.delete(DummyClassForDefaultAdapter) + remove_classes(DummyClassForDefaultAdapter) + end + + let(:instance) do + DummyClassForDefaultAdapter.new.tap do |m| + allow(m).to receive(:klass).and_return(double('class', primary_key: :some_key, find: [1])).at_least(:once) + end + end + + it 'should have the default records implementation' do + expect(instance.records).to eq([1]) + end + + it 'should have the default Callback implementation' do + expect(Elasticsearch::Model::Adapter::Default::Callbacks).to be_a(Module) + end + + it 'should have the default Importing implementation' do + expect { + DummyClassForDefaultAdapter.new.__find_in_batches + }.to raise_exception(Elasticsearch::Model::NotImplemented) + end + + it 'should have the default transform implementation' do + expect { + DummyClassForDefaultAdapter.new.__transform + }.to raise_exception(Elasticsearch::Model::NotImplemented) + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/mongoid/basic_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/mongoid/basic_spec.rb new file mode 100644 index 000000000..0a0e71b58 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/mongoid/basic_spec.rb @@ -0,0 +1,284 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Adapter::Mongoid, if: test_mongoid? do + + before(:all) do + connect_mongoid('mongoid_test') + Elasticsearch::Model::Adapter.register \ + Elasticsearch::Model::Adapter::Mongoid, + lambda { |klass| !!defined?(::Mongoid::Document) && klass.respond_to?(:ancestors) && klass.ancestors.include?(::Mongoid::Document) } + + MongoidArticle.__elasticsearch__.create_index! force: true + + MongoidArticle.delete_all + + MongoidArticle.__elasticsearch__.refresh_index! + MongoidArticle.__elasticsearch__.client.cluster.health wait_for_status: 'yellow' + end + + after do + clear_indices(MongoidArticle) + clear_tables(MongoidArticle) + end + + describe 'searching' do + + before do + MongoidArticle.create! title: 'Test' + MongoidArticle.create! title: 'Testing Coding' + MongoidArticle.create! title: 'Coding' + MongoidArticle.__elasticsearch__.refresh_index! + end + + let(:search_result) do + MongoidArticle.search('title:test') + end + + it 'find the documents successfully' do + expect(search_result.results.size).to eq(2) + expect(search_result.records.size).to eq(2) + end + + describe '#results' do + + it 'returns a Elasticsearch::Model::Response::Result' do + expect(search_result.results.first).to be_a(Elasticsearch::Model::Response::Result) + end + + it 'retrieves the document from Elasticsearch' do + expect(search_result.results.first.title).to eq('Test') + end + + it 'retrieves all results' do + expect(search_result.results.collect(&:title)).to match(['Test', 'Testing Coding']) + end + end + + describe '#records' do + + it 'returns an instance of the model' do + expect(search_result.records.first).to be_a(MongoidArticle) + end + + it 'retrieves the document from Elasticsearch' do + expect(search_result.records.first.title).to eq('Test') + end + + it 'iterates over the records' do + expect(search_result.records.first.title).to eq('Test') + end + + it 'retrieves all records' do + expect(search_result.records.collect(&:title)).to match(['Test', 'Testing Coding']) + end + + describe '#each_with_hit' do + + it 'yields each hit with the model object' do + search_result.records.each_with_hit do |r, h| + expect(h._source).not_to be_nil + expect(h._source.title).not_to be_nil + end + end + + it 'preserves the search order' do + search_result.records.each_with_hit do |r, h| + expect(r.id.to_s).to eq(h._id) + end + end + end + + describe '#map_with_hit' do + + it 'yields each hit with the model object' do + search_result.records.map_with_hit do |r, h| + expect(h._source).not_to be_nil + expect(h._source.title).not_to be_nil + end + end + + it 'preserves the search order' do + search_result.records.map_with_hit do |r, h| + expect(r.id.to_s).to eq(h._id) + end + end + end + end + end + + describe '#destroy' do + + let(:article) do + MongoidArticle.create!(title: 'Test') + end + + before do + article + MongoidArticle.create!(title: 'Coding') + article.destroy + MongoidArticle.__elasticsearch__.refresh_index! + end + + it 'removes documents from the index' do + expect(MongoidArticle.search('title:test').results.total).to eq(0) + expect(MongoidArticle.search('title:code').results.total).to eq(1) + end + end + + describe 'updates to the document' do + + let(:article) do + MongoidArticle.create!(title: 'Test') + end + + before do + article.title = 'Writing' + article.save + MongoidArticle.__elasticsearch__.refresh_index! + end + + it 'indexes updates' do + expect(MongoidArticle.search('title:write').results.total).to eq(1) + expect(MongoidArticle.search('title:test').results.total).to eq(0) + end + end + + describe 'DSL search' do + + before do + MongoidArticle.create! title: 'Test' + MongoidArticle.create! title: 'Testing Coding' + MongoidArticle.create! title: 'Coding' + MongoidArticle.__elasticsearch__.refresh_index! + end + + let(:search_result) do + MongoidArticle.search(query: { match: { title: { query: 'test' } } }) + end + + it 'finds the matching documents' do + expect(search_result.results.size).to eq(2) + expect(search_result.records.size).to eq(2) + end + end + + describe 'paging a collection' do + + before do + MongoidArticle.create! title: 'Test' + MongoidArticle.create! title: 'Testing Coding' + MongoidArticle.create! title: 'Coding' + MongoidArticle.__elasticsearch__.refresh_index! + end + + let(:search_result) do + MongoidArticle.search(query: { match: { title: { query: 'test' } } }, + size: 2, + from: 1) + end + + it 'applies the size and from parameters' do + expect(search_result.results.size).to eq(1) + expect(search_result.results.first.title).to eq('Testing Coding') + expect(search_result.records.size).to eq(1) + expect(search_result.records.first.title).to eq('Testing Coding') + end + end + + describe 'importing' do + + before do + 97.times { |i| MongoidArticle.create! title: "Test #{i}" } + MongoidArticle.__elasticsearch__.create_index! force: true + MongoidArticle.__elasticsearch__.client.cluster.health wait_for_status: 'yellow' + end + + context 'when there is no default scope' do + + let!(:batch_count) do + batches = 0 + errors = MongoidArticle.import(batch_size: 10) do |response| + batches += 1 + end + MongoidArticle.__elasticsearch__.refresh_index! + batches + end + + it 'imports all the documents' do + expect(MongoidArticle.search('*').results.total).to eq(97) + end + + it 'uses the specified batch size' do + expect(batch_count).to eq(10) + end + end + + context 'when there is a default scope' do + + around(:all) do |example| + 10.times { |i| MongoidArticle.create! title: 'Test', views: "#{i}" } + MongoidArticle.default_scope -> { MongoidArticle.gt(views: 3) } + example.run + MongoidArticle.default_scoping = nil + end + + before do + MongoidArticle.__elasticsearch__.import + MongoidArticle.__elasticsearch__.refresh_index! + end + + it 'uses the default scope' do + expect(MongoidArticle.search('*').results.total).to eq(6) + end + end + + context 'when there is a default scope and a query specified' do + + around(:all) do |example| + 10.times { |i| MongoidArticle.create! title: 'Test', views: "#{i}" } + MongoidArticle.default_scope -> { MongoidArticle.gt(views: 3) } + example.run + MongoidArticle.default_scoping = nil + end + + before do + MongoidArticle.import(query: -> { lte(views: 4) }) + MongoidArticle.__elasticsearch__.refresh_index! + end + + it 'combines the query and the default scope' do + expect(MongoidArticle.search('*').results.total).to eq(1) + end + end + + context 'when the batch is empty' do + + before do + MongoidArticle.delete_all + MongoidArticle.import + MongoidArticle.__elasticsearch__.refresh_index! + end + + it 'does not make any requests to create documents' do + expect(MongoidArticle.search('*').results.total).to eq(0) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/mongoid/multi_model_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/mongoid/multi_model_spec.rb new file mode 100644 index 000000000..5614522c9 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/mongoid/multi_model_spec.rb @@ -0,0 +1,83 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Adapter::ActiveRecord Multimodel', if: test_mongoid? do + + before(:all) do + connect_mongoid('mongoid_test') + + begin + ActiveRecord::Schema.define(:version => 1) do + create_table Episode.table_name do |t| + t.string :name + t.datetime :created_at, :default => 'NOW()' + end + end + rescue + end + end + + before do + clear_tables(Episode, Image) + Episode.__elasticsearch__.create_index! force: true + Episode.create name: 'TheEpisode' + Episode.create name: 'A great Episode' + Episode.create name: 'The greatest Episode' + Episode.__elasticsearch__.refresh_index! + + Image.__elasticsearch__.create_index! force: true + Image.create! name: 'The Image' + Image.create! name: 'A great Image' + Image.create! name: 'The greatest Image' + Image.__elasticsearch__.refresh_index! + Image.__elasticsearch__.client.cluster.health wait_for_status: 'yellow' + end + + after do + [Episode, Image].each do |model| + model.__elasticsearch__.client.delete_by_query(index: model.index_name, q: '*', body: {}) + model.delete_all + model.__elasticsearch__.refresh_index! + end + end + + context 'when the search is across multimodels with different adapters' do + + let(:search_result) do + Elasticsearch::Model.search(%q<"greatest Episode" OR "greatest Image"^2>, [Episode, Image]) + end + + it 'executes the search across models' do + expect(search_result.results.size).to eq(2) + expect(search_result.records.size).to eq(2) + end + + it 'returns the correct type of model instance' do + expect(search_result.records[0]).to be_a(Image) + expect(search_result.records[1]).to be_a(Episode) + end + + it 'creates the model instances with the correct attributes' do + expect(search_result.results[0].name).to eq('The greatest Image') + expect(search_result.records[0].name).to eq('The greatest Image') + expect(search_result.results[1].name).to eq('The greatest Episode') + expect(search_result.records[1].name).to eq('The greatest Episode') + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/mongoid_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/mongoid_spec.rb new file mode 100644 index 000000000..88ffbd2a6 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/mongoid_spec.rb @@ -0,0 +1,252 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Adapter::Mongoid do + + before(:all) do + class DummyClassForMongoid; end + ::Symbol.class_eval { def in; self; end } + end + + after(:all) do + Elasticsearch::Model::Adapter::Adapter.adapters.delete(DummyClassForMongoid) + remove_classes(DummyClassForMongoid) + end + + let(:response) do + { 'hits' => {'hits' => [ {'_id' => 2}, {'_id' => 1} ]} } + end + + let(:ids) do + [2, 1] + end + + let(:record_1) do + double('record').tap do |rec| + allow(rec).to receive(:id).and_return(1) + end + end + + let(:record_2) do + double('record').tap do |rec| + allow(rec).to receive(:load).and_return(true) + allow(rec).to receive(:id).and_return(2) + end + end + + let(:records) do + [record_1, record_2] + end + + let(:model) do + DummyClassForMongoid.new.tap do |m| + allow(m).to receive(:response).and_return(double('response', response: response)) + allow(m).to receive(:ids).and_return(ids) + end + end + + describe 'adapter registration' do + + it 'registers an adapater' do + expect(Elasticsearch::Model::Adapter.adapters[Elasticsearch::Model::Adapter::Mongoid]).not_to be_nil + expect(Elasticsearch::Model::Adapter.adapters[Elasticsearch::Model::Adapter::Mongoid].call(DummyClassForMongoid)).to be(false) + end + + it 'registers the records module' do + expect(Elasticsearch::Model::Adapter::Mongoid::Records).to be_a(Module) + end + end + + describe '#records' do + + before(:all) do + DummyClassForMongoid.__send__ :include, Elasticsearch::Model::Adapter::Mongoid::Records + end + + let(:instance) do + model.tap do |inst| + allow(inst).to receive(:klass).and_return(double('class', where: records)).at_least(:once) + end + end + + it 'returns the records' do + expect(instance.records).to eq(records) + end + + context 'when an order is not defined for the Mongoid query' do + + context 'when the records have a different order than the hits' do + + before do + records.instance_variable_set(:@records, records) + end + + it 'reorders the records based on hits order' do + expect(records.collect(&:id)).to eq([1, 2]) + expect(instance.records.to_a.collect(&:id)).to eq([2, 1]) + end + end + + context 'when an order is defined for the Mongoid query' do + + context 'when the records have a different order than the hits' do + + before do + records.instance_variable_set(:@records, records) + expect(instance.records).to receive(:asc).and_return(records) + end + + it 'reorders the records based on hits order' do + expect(records.collect(&:id)).to eq([1, 2]) + expect(instance.records.to_a.collect(&:id)).to eq([2, 1]) + expect(instance.asc.to_a.collect(&:id)).to eq([1, 2]) + end + end + end + end + + describe 'callbacks registration' do + + before do + expect(DummyClassForMongoid).to receive(:after_create).once + expect(DummyClassForMongoid).to receive(:after_update).once + expect(DummyClassForMongoid).to receive(:after_destroy).once + end + + it 'should register the model class for callbacks' do + Elasticsearch::Model::Adapter::Mongoid::Callbacks.included(DummyClassForMongoid) + end + end + end + + describe 'importing' do + + before(:all) do + DummyClassForMongoid.__send__ :extend, Elasticsearch::Model::Adapter::Mongoid::Importing + end + + let(:relation) do + double('relation', each_slice: []).tap do |rel| + allow(rel).to receive(:published).and_return(rel) + allow(rel).to receive(:no_timeout).and_return(rel) + allow(rel).to receive(:class_exec).and_return(rel) + end + end + + before do + allow(DummyClassForMongoid).to receive(:all).and_return(relation) + end + + context 'when a scope is specified' do + + it 'applies the scope' do + expect(DummyClassForMongoid.__find_in_batches(scope: :published) do; end).to eq([]) + end + end + + context 'query criteria specified as a proc' do + + let(:query) do + Proc.new { where(color: "red") } + end + + it 'execites the query' do + expect(DummyClassForMongoid.__find_in_batches(query: query) do; end).to eq([]) + end + end + + context 'query criteria specified as a hash' do + + before do + expect(relation).to receive(:where).with({ color: 'red' }).and_return(relation) + end + + let(:query) do + { color: "red" } + end + + it 'execites the query' do + expect(DummyClassForMongoid.__find_in_batches(query: query) do; end).to eq([]) + end + end + + context 'when preprocessing batches' do + + context 'if the query returns results' do + + before do + class << DummyClassForMongoid + def find_in_batches(options = {}, &block) + yield [:a, :b] + end + + def update_batch(batch) + batch.collect { |b| b.to_s + '!' } + end + end + end + + it 'applies the preprocessing method' do + DummyClassForMongoid.__find_in_batches(preprocess: :update_batch) do |batch| + expect(batch).to match(['a!', 'b!']) + end + end + end + + context 'if the query does not return results' do + + before do + class << DummyClassForMongoid + def find_in_batches(options = {}, &block) + yield [:a, :b] + end + + def update_batch(batch) + [] + end + end + end + + it 'applies the preprocessing method' do + DummyClassForMongoid.__find_in_batches(preprocess: :update_batch) do |batch| + expect(batch).to match([]) + end + end + end + end + + context 'when transforming models' do + + let(:instance) do + model.tap do |inst| + allow(inst).to receive(:as_indexed_json).and_return({}) + allow(inst).to receive(:id).and_return(1) + end + end + + it 'returns an proc' do + expect(DummyClassForMongoid.__transform.respond_to?(:call)).to be(true) + end + + it 'provides a default transformation' do + expect(DummyClassForMongoid.__transform.call(instance)).to eq(index: { _id: '1', data: {} }) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/adapters/multiple_spec.rb b/elasticsearch-model/spec/elasticsearch/model/adapters/multiple_spec.rb new file mode 100644 index 000000000..86c001db8 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/adapters/multiple_spec.rb @@ -0,0 +1,132 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Adapter::Multiple do + before(:all) do + class DummyOne + include Elasticsearch::Model + + index_name 'dummy' + + def self.find(ids) + ids.map { |id| new(id) } + end + + attr_reader :id + + def initialize(id) + @id = id.to_i + end + end + + module Namespace + class DummyTwo + include Elasticsearch::Model + + index_name 'dummy' + + def self.find(ids) + ids.map { |id| new(id) } + end + + attr_reader :id + + def initialize(id) + @id = id.to_i + end + end + end + + class DummyTwo + include Elasticsearch::Model + + index_name 'other_index' + + def self.find(ids) + ids.map { |id| new(id) } + end + + attr_reader :id + + def initialize(id) + @id = id.to_i + end + end + end + + after(:all) do + [DummyOne, Namespace::DummyTwo, DummyTwo].each do |adapter| + Elasticsearch::Model::Adapter::Adapter.adapters.delete(adapter) + end + Namespace.send(:remove_const, :DummyTwo) if defined?(Namespace::DummyTwo) + remove_classes(DummyOne, DummyTwo, Namespace) + end + + let(:hits) do + [ + { + _index: 'dummy', + _id: '2' + }, + { + _index: 'dummy', + _id: '2' + }, + { + _index: 'other_index', + _id: '1' + }, + { + _index: 'dummy', + _id: '1' + }, + { + _index: 'dummy', + _id: '3' + } + ] + end + + let(:response) do + double('response', response: { 'hits' => { 'hits' => hits } }) + end + + let(:multimodel) do + Elasticsearch::Model::Multimodel.new(DummyOne, DummyTwo, Namespace::DummyTwo) + end + + describe '#records' do + before do + multimodel.class.send :include, Elasticsearch::Model::Adapter::Multiple::Records + expect(multimodel).to receive(:response).at_least(:once).and_return(response) + end + + xit 'instantiates the correct types of instances' do + expect(multimodel.records[0]).to be_a(Namespace::DummyTwo) + expect(multimodel.records[1]).to be_a(DummyOne) + expect(multimodel.records[2]).to be_a(DummyTwo) + expect(multimodel.records[3]).to be_a(Namespace::DummyTwo) + expect(multimodel.records[4]).to be_a(DummyOne) + end + + it 'returns the results in the correct order' do + expect(multimodel.records.map(&:id)).to eq([2, 2, 1, 1, 3]) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/callbacks_spec.rb b/elasticsearch-model/spec/elasticsearch/model/callbacks_spec.rb new file mode 100644 index 000000000..f128d8732 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/callbacks_spec.rb @@ -0,0 +1,50 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Callbacks do + + before(:all) do + class ::DummyCallbacksModel + end + + module DummyCallbacksAdapter + module CallbacksMixin + end + + def callbacks_mixin + CallbacksMixin + end; module_function :callbacks_mixin + end + end + + after(:all) do + remove_classes(DummyCallbacksModel, DummyCallbacksAdapter) + end + + context 'when a model includes the Callbacks module' do + + before do + Elasticsearch::Model::Callbacks.included(DummyCallbacksModel) + end + + it 'includes the callbacks mixin from the model adapter' do + expect(DummyCallbacksModel.ancestors).to include(Elasticsearch::Model::Adapter::Default::Callbacks) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/client_spec.rb b/elasticsearch-model/spec/elasticsearch/model/client_spec.rb new file mode 100644 index 000000000..12814d701 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/client_spec.rb @@ -0,0 +1,83 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Client do + + before(:all) do + class ::DummyClientModel + extend Elasticsearch::Model::Client::ClassMethods + include Elasticsearch::Model::Client::InstanceMethods + end + end + + after(:all) do + remove_classes(DummyClientModel) + end + + context 'when a class includes the client module class methods' do + + it 'defines the client module class methods on the model' do + expect(DummyClientModel.client).to be_a(Elasticsearch::Client) + end + end + + context 'when a class includes the client module instance methods' do + + it 'defines the client module class methods on the model' do + expect(DummyClientModel.new.client).to be_a(Elasticsearch::Client) + end + end + + context 'when the client is set on the class' do + + around do |example| + original_client = DummyClientModel.client + DummyClientModel.client = 'foobar' + example.run + DummyClientModel.client = original_client + end + + it 'sets the client on the class' do + expect(DummyClientModel.client).to eq('foobar') + end + + it 'sets the client on an instance' do + expect(DummyClientModel.new.client).to eq('foobar') + end + end + + context 'when the client is set on an instance' do + + before do + model_instance.client = 'foo' + end + + let(:model_instance) do + DummyClientModel.new + end + + it 'sets the client on an instance' do + expect(model_instance.client).to eq('foo') + end + + it 'does not set the client on the class' do + expect(DummyClientModel.client).to be_a(Elasticsearch::Client) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/hash_wrapper_spec.rb b/elasticsearch-model/spec/elasticsearch/model/hash_wrapper_spec.rb new file mode 100644 index 000000000..ae4c857ac --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/hash_wrapper_spec.rb @@ -0,0 +1,29 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::HashWrapper, if: Hashie::VERSION >= '3.5.3' do + + before do + expect(Hashie.logger).to receive(:warn).never + end + + it 'does not print a warning for re-defined methods' do + Elasticsearch::Model::HashWrapper.new(:foo => 'bar', :sort => true) + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/importing_spec.rb b/elasticsearch-model/spec/elasticsearch/model/importing_spec.rb new file mode 100644 index 000000000..b905fa4eb --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/importing_spec.rb @@ -0,0 +1,214 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Importing do + before(:all) do + class DummyImportingModel + end + + module DummyImportingAdapter + module ImportingMixin + def __find_in_batches(options = {}, &block) + yield if block_given? + end + def __transform + lambda { |a| } + end + end + + def importing_mixin + ImportingMixin + end; module_function :importing_mixin + end + end + + after(:all) do + remove_classes(DummyImportingModel, DummyImportingAdapter) + end + + before do + allow(Elasticsearch::Model::Adapter).to receive(:from_class).with(DummyImportingModel).and_return(DummyImportingAdapter) + DummyImportingModel.__send__ :include, Elasticsearch::Model::Importing + end + + context 'when a model includes the Importing module' do + it 'provides importing methods' do + expect(DummyImportingModel.respond_to?(:import)).to be(true) + expect(DummyImportingModel.respond_to?(:__find_in_batches)).to be(true) + end + end + + describe '#import' do + before do + allow(DummyImportingModel).to receive(:index_name).and_return('foo') + allow(DummyImportingModel).to receive(:index_exists?).and_return(true) + allow(DummyImportingModel).to receive(:__batch_to_bulk) + allow(client).to receive(:bulk).and_return(response) + end + + let(:client) do + double('client') + end + + let(:response) do + { 'items' => [] } + end + + context 'when no options are provided' do + before do + expect(DummyImportingModel).to receive(:client).and_return(client) + allow(DummyImportingModel).to receive(:index_exists?).and_return(true) + end + + it 'uses the client to import documents' do + expect(DummyImportingModel.import).to eq(0) + end + end + + context 'when there is an error' do + before do + expect(DummyImportingModel).to receive(:client).and_return(client) + allow(DummyImportingModel).to receive(:index_exists?).and_return(true) + end + + let(:response) do + { 'items' => [{ 'index' => { } }, { 'index' => { 'error' => 'FAILED' } }] } + end + + it 'returns the number of errors' do + expect(DummyImportingModel.import).to eq(1) + end + + context 'when the method is called with the option to return the errors' do + it 'returns the errors' do + expect(DummyImportingModel.import(return: 'errors')).to eq([{ 'index' => { 'error' => 'FAILED' } }]) + end + end + + context 'when the method is called with a block' do + it 'yields the response to the block' do + DummyImportingModel.import do |response| + expect(response['items'].size).to eq(2) + end + end + end + end + + context 'when the index does not exist' do + before do + allow(DummyImportingModel).to receive(:index_exists?).and_return(false) + end + + it 'raises an exception' do + expect { + DummyImportingModel.import + }.to raise_exception(ArgumentError) + end + end + + context 'when the method is called with the force option' do + before do + expect(DummyImportingModel).to receive(:create_index!).with(force: true, index: 'foo').and_return(true) + expect(DummyImportingModel).to receive(:__find_in_batches).with({ foo: 'bar' }).and_return(true) + end + + it 'deletes and creates the index' do + expect(DummyImportingModel.import(force: true, foo: 'bar')).to eq(0) + end + end + + context 'when the method is called with the refresh option' do + before do + expect(DummyImportingModel).to receive(:refresh_index!).with(index: 'foo').and_return(true) + expect(DummyImportingModel).to receive(:__find_in_batches).with({ foo: 'bar' }).and_return(true) + end + + it 'refreshes the index' do + expect(DummyImportingModel.import(refresh: true, foo: 'bar')).to eq(0) + end + end + + context 'when a different index name is provided' do + before do + expect(DummyImportingModel).to receive(:client).and_return(client) + expect(client).to receive(:bulk).with({ body: nil, index: 'my-new-index' }).and_return(response) + end + + it 'uses the alternate index name' do + expect(DummyImportingModel.import(index: 'my-new-index')).to eq(0) + end + end + + context 'the transform method' do + before do + expect(DummyImportingModel).to receive(:client).and_return(client) + expect(DummyImportingModel).to receive(:__transform).and_return(transform) + expect(DummyImportingModel).to receive(:__batch_to_bulk).with(anything, transform) + end + + let(:transform) do + lambda {|a|} + end + + it 'applies the transform method to the results' do + expect(DummyImportingModel.import).to eq(0) + end + end + + context 'when a transform is provided as an option' do + context 'when the transform option is not a lambda' do + let(:transform) do + 'not_callable' + end + + it 'raises an error' do + expect { + DummyImportingModel.import(transform: transform) + }.to raise_exception(ArgumentError) + end + end + + context 'when the transform option is a lambda' do + before do + expect(DummyImportingModel).to receive(:client).and_return(client) + expect(DummyImportingModel).to receive(:__batch_to_bulk).with(anything, transform) + end + + let(:transform) do + lambda {|a|} + end + + it 'applies the transform lambda to the results' do + expect(DummyImportingModel.import(transform: transform)).to eq(0) + end + end + end + + context 'when a pipeline is provided as an options' do + before do + expect(DummyImportingModel).to receive(:client).and_return(client) + expect(client).to receive(:bulk).with({ body: nil, index: 'foo', pipeline: 'my-pipeline' }).and_return(response) + end + + it 'uses the pipeline option' do + expect(DummyImportingModel.import(pipeline: 'my-pipeline')).to eq(0) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/indexing_spec.rb b/elasticsearch-model/spec/elasticsearch/model/indexing_spec.rb new file mode 100644 index 000000000..b4c0d299d --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/indexing_spec.rb @@ -0,0 +1,864 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Indexing do + before(:all) do + class ::DummyIndexingModel + extend ActiveModel::Naming + extend Elasticsearch::Model::Naming::ClassMethods + extend Elasticsearch::Model::Indexing::ClassMethods + + def self.foo + 'bar' + end + end + + class NotFound < Exception; end + end + + after(:all) do + remove_classes(DummyIndexingModel, NotFound) + end + + describe 'the Settings class' do + it 'should be convertible to a hash' do + expect(Elasticsearch::Model::Indexing::Settings.new(foo: 'bar').to_hash).to eq(foo: 'bar') + end + + it 'should be convertible to json' do + expect(Elasticsearch::Model::Indexing::Settings.new(foo: 'bar').as_json).to eq(foo: 'bar') + end + end + + describe '#settings' do + it 'returns an instance of the Settings class' do + expect(DummyIndexingModel.settings).to be_a(Elasticsearch::Model::Indexing::Settings) + end + + context 'when the settings are updated' do + before do + DummyIndexingModel.settings(foo: 'boo') + DummyIndexingModel.settings(bar: 'bam') + end + + it 'updates the settings on the class' do + expect(DummyIndexingModel.settings.to_hash).to eq(foo: 'boo', bar: 'bam') + end + end + + context 'when the settings are updated with a yml file' do + before do + DummyIndexingModel.settings File.open('spec/support/model.yml') + DummyIndexingModel.settings bar: 'bam' + end + + it 'updates the settings on the class' do + expect(DummyIndexingModel.settings.to_hash).to eq(foo: 'boo', bar: 'bam', 'baz' => 'qux') + end + end + + context 'when the settings are updated with a json file' do + before do + DummyIndexingModel.settings File.open('spec/support/model.json') + DummyIndexingModel.settings bar: 'bam' + end + + it 'updates the settings on the class' do + expect(DummyIndexingModel.settings.to_hash).to eq(foo: 'boo', bar: 'bam', 'baz' => 'qux', 'laz' => 'qux') + end + end + end + + describe '#mappings' do + let(:expected_mapping_hash) do + { foo: 'bar', :properties => {} } + end + + it 'returns an instance of the Mappings class' do + expect(DummyIndexingModel.mappings).to be_a(Elasticsearch::Model::Indexing::Mappings) + end + + it 'should be convertible to a hash' do + expect(Elasticsearch::Model::Indexing::Mappings.new({ foo: 'bar' }).to_hash).to eq(expected_mapping_hash) + end + + it 'should be convertible to json' do + expect(Elasticsearch::Model::Indexing::Mappings.new({ foo: 'bar' }).as_json).to eq(expected_mapping_hash) + end + + context 'basic mappings' do + let(:mappings) do + Elasticsearch::Model::Indexing::Mappings.new + end + + before do + mappings.indexes :foo, { type: 'boolean', include_in_all: false } + mappings.indexes :bar + end + + it 'creates the correct mapping definition' do + expect(mappings.to_hash[:properties][:foo][:type]).to eq('boolean') + end + + it 'uses text as the default type' do + expect(mappings.to_hash[:properties][:bar][:type]).to eq('text') + end + end + + context 'when specific mappings are defined' do + let(:mappings) do + Elasticsearch::Model::Indexing::Mappings.new(include_type_name: true) + end + + before do + mappings.indexes :foo, { type: 'boolean', include_in_all: false } + mappings.indexes :bar + end + + it 'creates the correct mapping definition' do + expect(mappings.to_hash[:properties][:foo][:type]).to eq('boolean') + end + + it 'uses text as the default type' do + expect(mappings.to_hash[:properties][:bar][:type]).to eq('text') + end + + context 'when mappings are defined for multiple fields' do + before do + mappings.indexes :my_field, type: 'text' do + indexes :raw, type: 'keyword' + end + end + + it 'defines the mapping for all the fields' do + expect(mappings.to_hash[:properties][:my_field][:type]).to eq('text') + expect(mappings.to_hash[:properties][:my_field][:fields][:raw][:type]).to eq('keyword') + expect(mappings.to_hash[:properties][:my_field][:fields][:raw][:properties]).to be_nil + end + end + + context 'when embedded properties are defined' do + before do + mappings.indexes :foo do + indexes :bar + end + + mappings.indexes :foo_object, type: 'object' do + indexes :bar + end + + mappings.indexes :foo_nested, type: 'nested' do + indexes :bar + end + + mappings.indexes :foo_nested_as_symbol, type: :nested do + indexes :bar + end + end + + it 'defines mappings for the embedded properties' do + expect(mappings.to_hash[:properties][:foo][:type]).to eq('object') + expect(mappings.to_hash[:properties][:foo][:properties][:bar][:type]).to eq('text') + expect(mappings.to_hash[:properties][:foo][:fields]).to be_nil + + expect(mappings.to_hash[:properties][:foo_object][:type]).to eq('object') + expect(mappings.to_hash[:properties][:foo_object][:properties][:bar][:type]).to eq('text') + expect(mappings.to_hash[:properties][:foo_object][:fields]).to be_nil + + expect(mappings.to_hash[:properties][:foo_nested][:type]).to eq('nested') + expect(mappings.to_hash[:properties][:foo_nested][:properties][:bar][:type]).to eq('text') + expect(mappings.to_hash[:properties][:foo_nested][:fields]).to be_nil + + expect(mappings.to_hash[:properties][:foo_nested_as_symbol][:type]).to eq(:nested) + expect(mappings.to_hash[:properties][:foo_nested_as_symbol][:properties]).not_to be_nil + expect(mappings.to_hash[:properties][:foo_nested_as_symbol][:fields]).to be_nil + end + + it 'defines the settings' do + expect(mappings.to_hash[:include_type_name]).to be(true) + end + end + end + + context 'when the method is called on a class' do + before do + DummyIndexingModel.mappings(foo: 'boo') + DummyIndexingModel.mappings(bar: 'bam') + end + + let(:expected_mappings_hash) do + { foo: "boo", bar: "bam", properties: {} } + end + + it 'sets the mappings' do + expect(DummyIndexingModel.mappings.to_hash).to eq(expected_mappings_hash) + end + + context 'when the method is called with a block' do + before do + DummyIndexingModel.mapping do + indexes :foo, type: 'boolean' + end + end + + it 'sets the mappings' do + expect(DummyIndexingModel.mapping.to_hash[:properties][:foo][:type]).to eq('boolean') + end + end + + context 'when the class has a document_type' do + before do + DummyIndexingModel.instance_variable_set(:@mapping, nil) + DummyIndexingModel.mappings(foo: 'boo') + DummyIndexingModel.mappings(bar: 'bam') + end + + let(:expected_mappings_hash) do + { foo: "boo", bar: "bam", properties: {} } + end + + it 'sets the mappings' do + expect(DummyIndexingModel.mappings.to_hash).to eq(expected_mappings_hash) + end + end + end + end + + describe 'instance methods' do + before(:all) do + class ::DummyIndexingModelWithCallbacks + extend Elasticsearch::Model::Indexing::ClassMethods + include Elasticsearch::Model::Indexing::InstanceMethods + + def self.before_save(&block) + (@callbacks ||= {})[block.hash] = block + end + + def changes_to_save + {:foo => ['One', 'Two']} + end + end + + class ::DummyIndexingModelWithNoChanges + extend Elasticsearch::Model::Indexing::ClassMethods + include Elasticsearch::Model::Indexing::InstanceMethods + + def self.before_save(&block) + (@callbacks ||= {})[block.hash] = block + end + + def changes_to_save + {} + end + end + + class ::DummyIndexingModelWithCallbacksAndCustomAsIndexedJson + extend Elasticsearch::Model::Indexing::ClassMethods + include Elasticsearch::Model::Indexing::InstanceMethods + + def self.before_save(&block) + (@callbacks ||= {})[block.hash] = block + end + + def changes_to_save + {:foo => ['A', 'B'], :bar => ['C', 'D']} + end + + def as_indexed_json(options={}) + { :foo => 'B' } + end + end + + class ::DummyIndexingModelWithOldDirty + extend Elasticsearch::Model::Indexing::ClassMethods + include Elasticsearch::Model::Indexing::InstanceMethods + + def self.before_save(&block) + (@callbacks ||= {})[block.hash] = block + end + + def changes + {:foo => ['One', 'Two']} + end + end + end + + after(:all) do + Object.send(:remove_const, :DummyIndexingModelWithCallbacks) if defined?(DummyIndexingModelWithCallbacks) + Object.send(:remove_const, :DummyIndexingModelWithNoChanges) if defined?(DummyIndexingModelWithNoChanges) + Object.send(:remove_const, :DummyIndexingModelWithCallbacksAndCustomAsIndexedJson) if defined?(DummyIndexingModelWithCallbacksAndCustomAsIndexedJson) + Object.send(:remove_const, :DummyIndexingModelWithOldDirty) if defined?(DummyIndexingModelWithOldDirty) + end + + context 'when the module is included' do + context 'when the model uses the old ActiveModel::Dirty' do + before do + DummyIndexingModelWithOldDirty.__send__ :include, Elasticsearch::Model::Indexing::InstanceMethods + end + + it 'registers callbacks' do + expect(DummyIndexingModelWithOldDirty.instance_variable_get(:@callbacks)).not_to be_empty + end + + let(:instance) do + DummyIndexingModelWithOldDirty.new + end + + it 'sets the @__changed_model_attributes variable before the callback' do + DummyIndexingModelWithOldDirty.instance_variable_get(:@callbacks).each do |n, callback| + instance.instance_eval(&callback) + expect(instance.instance_variable_get(:@__changed_model_attributes)).to eq(foo: 'Two') + end + end + end + + context 'when the model users the current ActiveModel::Dirty' do + before do + DummyIndexingModelWithCallbacks.__send__ :include, Elasticsearch::Model::Indexing::InstanceMethods + end + + it 'registers callbacks' do + expect(DummyIndexingModelWithCallbacks.instance_variable_get(:@callbacks)).not_to be_empty + end + + let(:instance) do + DummyIndexingModelWithCallbacks.new + end + + it 'sets the @__changed_model_attributes variable before the callback' do + DummyIndexingModelWithCallbacks.instance_variable_get(:@callbacks).each do |n, callback| + instance.instance_eval(&callback) + expect(instance.instance_variable_get(:@__changed_model_attributes)).to eq(foo: 'Two') + end + end + end + end + + describe '#index_document' do + before do + expect(instance).to receive(:client).and_return(client) + expect(instance).to receive(:as_indexed_json).and_return('JSON') + expect(instance).to receive(:index_name).and_return('foo') + expect(instance).to receive(:id).and_return('1') + end + + let(:client) do + double('client') + end + + let(:instance) do + DummyIndexingModelWithCallbacks.new + end + + context 'when no options are passed to the method' do + before do + expect(client).to receive(:index).with({ index: 'foo', id: '1', body: 'JSON' }).and_return(true) + end + + it 'provides the method on an instance' do + expect(instance.index_document).to be(true) + end + end + + context 'when extra options are passed to the method' do + before do + expect(client).to receive(:index).with({ index: 'foo', id: '1', body: 'JSON', parent: 'A' }).and_return(true) + end + + it 'passes the extra options to the method call on the client' do + expect(instance.index_document(parent: 'A')).to be(true) + end + end + end + + describe '#delete_document' do + before do + expect(instance).to receive(:client).and_return(client) + expect(instance).to receive(:index_name).and_return('foo') + expect(instance).to receive(:id).and_return('1') + end + + let(:client) do + double('client') + end + + let(:instance) do + DummyIndexingModelWithCallbacks.new + end + + context 'when no options are passed to the method' do + before do + expect(client).to receive(:delete).with({ index: 'foo', id: '1' }).and_return(true) + end + + it 'provides the method on an instance' do + expect(instance.delete_document).to be(true) + end + end + + context 'when extra options are passed to the method' do + before do + expect(client).to receive(:delete).with({ index: 'foo', id: '1', parent: 'A' }).and_return(true) + end + + it 'passes the extra options to the method call on the client' do + expect(instance.delete_document(parent: 'A')).to be(true) + end + end + end + + describe '#update_document' do + let(:client) do + double('client') + end + + let(:instance) do + DummyIndexingModelWithCallbacks.new + end + + context 'when no changes are present' do + before do + expect(instance).to receive(:index_document).and_return(true) + expect(client).to receive(:update).never + instance.instance_variable_set(:@__changed_model_attributes, nil) + end + + it 'updates the document' do + expect(instance.update_document).to be(true) + end + end + + context 'when changes are present' do + before do + allow(instance).to receive(:client).and_return(client) + allow(instance).to receive(:index_name).and_return('foo') + allow(instance).to receive(:id).and_return('1') + end + + context 'when the changes are included in the as_indexed_json representation' do + before do + instance.instance_variable_set(:@__changed_model_attributes, { foo: 'bar' }) + expect(client).to receive(:update).with({ index: 'foo', id: '1', body: { doc: { foo: 'bar' } } }).and_return(true) + end + + it 'updates the document' do + expect(instance.update_document).to be(true) + end + end + + context 'when the changes are not all included in the as_indexed_json representation' do + let(:instance) do + DummyIndexingModelWithCallbacksAndCustomAsIndexedJson.new + end + + before do + instance.instance_variable_set(:@__changed_model_attributes, {'foo' => 'B', 'bar' => 'D' }) + expect(client).to receive(:update).with({ index: 'foo', id: '1', body: { doc: { foo: 'B' } } }).and_return(true) + end + + it 'updates the document' do + expect(instance.update_document).to be(true) + end + end + + context 'when none of the changes are included in the as_indexed_json representation' do + let(:instance) do + DummyIndexingModelWithCallbacksAndCustomAsIndexedJson.new + end + + before do + instance.instance_variable_set(:@__changed_model_attributes, {'bar' => 'D' }) + end + + it 'does not update the document' do + expect(instance.update_document).to_not be(true) + end + end + + context 'when there are partial updates' do + let(:instance) do + DummyIndexingModelWithCallbacksAndCustomAsIndexedJson.new + end + + before do + instance.instance_variable_set(:@__changed_model_attributes, { 'foo' => { 'bar' => 'BAR'} }) + expect(instance).to receive(:as_indexed_json).and_return('foo' => 'BAR') + expect(client).to receive(:update).with({ index: 'foo', id: '1', body: { doc: { 'foo' => 'BAR' } } }).and_return(true) + end + + it 'updates the document' do + expect(instance.update_document).to be(true) + end + end + end + end + + describe '#update_document_attributes' do + let(:client) do + double('client') + end + + let(:instance) do + DummyIndexingModelWithCallbacks.new + end + + context 'when changes are present' do + before do + expect(instance).to receive(:client).and_return(client) + expect(instance).to receive(:index_name).and_return('foo') + expect(instance).to receive(:id).and_return('1') + instance.instance_variable_set(:@__changed_model_attributes, { author: 'john' }) + end + + context 'when no options are specified' do + before do + expect(client).to receive(:update).with({ index: 'foo', id: '1', body: { doc: { title: 'green' } } }).and_return(true) + end + + it 'updates the document' do + expect(instance.update_document_attributes(title: 'green')).to be(true) + end + end + + context 'when extra options are provided' do + before do + expect(client).to receive(:update).with({ index: 'foo', id: '1', body: { doc: { title: 'green' } }, refresh: true }).and_return(true) + end + + it 'updates the document' do + expect(instance.update_document_attributes({ title: 'green' }, refresh: true)).to be(true) + end + end + end + end + end + + describe '#index_exists?' do + before do + expect(DummyIndexingModel).to receive(:client).and_return(client) + end + + context 'when the index exists' do + + let(:client) do + double('client', indices: double('indices', exists: true)) + end + + it 'returns true' do + expect(DummyIndexingModel.index_exists?).to be(true) + end + end + + context 'when the index does not exist' do + let(:client) do + double('client', indices: double('indices', exists: false)) + end + + it 'returns false' do + expect(DummyIndexingModel.index_exists?).to be(false) + end + end + end + + describe '#delete_index!' do + before(:all) do + class ::DummyIndexingModelForRecreate + extend ActiveModel::Naming + extend Elasticsearch::Model::Naming::ClassMethods + extend Elasticsearch::Model::Indexing::ClassMethods + end + end + + after(:all) do + Object.send(:remove_const, :DummyIndexingModelForRecreate) if defined?(DummyIndexingModelForRecreate) + end + + context 'when the index is not found' do + let(:logger) { nil } + let(:client) { Elasticsearch::Client.new(logger: logger, transport_options: { ssl: { verify: false } }) } + + before do + expect(DummyIndexingModelForRecreate).to receive(:client).at_most(3).times.and_return(client) + end + + context 'when the force option is true' do + it 'deletes the index without raising an exception' do + expect(DummyIndexingModelForRecreate.delete_index!(force: true)).to be_nil + end + + context 'when the client has a logger' do + let(:logger) do + Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } + end + + it 'deletes the index without raising an exception' do + expect(DummyIndexingModelForRecreate.delete_index!(force: true)).to be_nil + end + + it 'logs the message that the index is not found' do + expect(logger).to receive(:debug).at_least(:once) + expect(DummyIndexingModelForRecreate.delete_index!(force: true)).to be_nil + end + end + end + + context 'when the force option is not provided' do + it 'raises an exception' do + expect { + DummyIndexingModelForRecreate.delete_index! + }.to raise_exception(Elastic::Transport::Transport::Errors::NotFound) + end + end + + context 'when the exception is not NotFound' do + let(:indices) do + double('indices').tap do |ind| + expect(ind).to receive(:delete).and_raise(Exception) + end + end + + it 'raises an exception' do + expect { + DummyIndexingModelForRecreate.delete_index! + }.to raise_exception(Exception) + end + end + end + + context 'when an index name is provided in the options' do + before do + expect(DummyIndexingModelForRecreate).to receive(:client).and_return(client) + expect(indices).to receive(:delete).with(index: 'custom-foo') + end + + let(:client) do + double('client', indices: indices) + end + + let(:indices) do + double('indices', delete: true) + end + + it 'uses the index name' do + expect(DummyIndexingModelForRecreate.delete_index!(index: 'custom-foo')) + end + end + end + + describe '#create_index' do + before(:all) do + class ::DummyIndexingModelForCreate + extend ActiveModel::Naming + extend Elasticsearch::Model::Naming::ClassMethods + extend Elasticsearch::Model::Indexing::ClassMethods + + index_name 'foo' + + settings index: { number_of_shards: 1 } do + mappings do + indexes :foo, analyzer: 'keyword' + end + end + end + end + + after(:all) do + Object.send(:remove_const, :DummyIndexingModelForCreate) if defined?(DummyIndexingModelForCreate) + end + + let(:client) do + double('client', indices: indices) + end + + let(:indices) do + double('indices') + end + + context 'when the index does not exist' do + before do + expect(DummyIndexingModelForCreate).to receive(:client).and_return(client) + expect(DummyIndexingModelForCreate).to receive(:index_exists?).and_return(false) + end + + context 'when options are not provided' do + let(:expected_body) do + { mappings: { properties: { foo: { analyzer: 'keyword', + type: 'text' } } }, + settings: { index: { number_of_shards: 1 } } } + end + + before do + expect(indices).to receive(:create).with({ index: 'foo', body: expected_body }).and_return(true) + end + + it 'creates the index' do + expect(DummyIndexingModelForCreate.create_index!).to be(true) + end + end + + context 'when options are provided' do + let(:expected_body) do + { mappings: { foobar: { properties: { foo: { analyzer: 'bar' } } } }, + settings: { index: { number_of_shards: 3 } } } + end + + before do + expect(indices).to receive(:create).with({ index: 'foobar', body: expected_body }).and_return(true) + end + + it 'creates the index' do + expect(DummyIndexingModelForCreate.create_index! \ + index: 'foobar', + settings: { index: { number_of_shards: 3 } }, + mappings: { foobar: { properties: { foo: { analyzer: 'bar' } } } } + ).to be(true) + end + end + end + + context 'when the index exists' do + before do + expect(DummyIndexingModelForCreate).to receive(:index_exists?).and_return(true) + expect(indices).to receive(:create).never + end + + it 'does not create the index' do + expect(DummyIndexingModelForCreate.create_index!).to be_nil + end + end + + context 'when creating the index raises an exception' do + before do + expect(DummyIndexingModelForCreate).to receive(:client).and_return(client) + expect(DummyIndexingModelForCreate).to receive(:delete_index!).and_return(true) + expect(DummyIndexingModelForCreate).to receive(:index_exists?).and_return(false) + expect(indices).to receive(:create).and_raise(Exception) + end + + it 'raises the exception' do + expect { + DummyIndexingModelForCreate.create_index!(force: true) + }.to raise_exception(Exception) + end + end + + context 'when an index name is provided in the options' do + before do + expect(DummyIndexingModelForCreate).to receive(:client).and_return(client).twice + expect(indices).to receive(:exists).and_return(false) + expect(indices).to receive(:create).with({ index: 'custom-foo', body: expected_body }) + end + + let(:expected_body) do + { mappings: { properties: { foo: { analyzer: 'keyword', + type: 'text' } } }, + settings: { index: { number_of_shards: 1 } } } + end + + it 'uses the index name' do + expect(DummyIndexingModelForCreate.create_index!(index: 'custom-foo')) + end + end + + context 'when the logging level is debug' + end + + describe '#refresh_index!' do + before(:all) do + class ::DummyIndexingModelForRefresh + extend ActiveModel::Naming + extend Elasticsearch::Model::Naming::ClassMethods + extend Elasticsearch::Model::Indexing::ClassMethods + + index_name 'foo' + + settings index: { number_of_shards: 1 } do + mappings do + indexes :foo, analyzer: 'keyword' + end + end + end + end + + after(:all) do + Object.send(:remove_const, :DummyIndexingModelForRefresh) if defined?(DummyIndexingModelForRefresh) + end + + let(:client) do + Elasticsearch::Client.new(logger: nil) + end + + before do + expect(DummyIndexingModelForRefresh).to receive(:client).at_most(3).times.and_return(client) + end + + context 'when the force option is true' do + context 'when the operation raises a NotFound exception' do + before do + expect(client).to receive_message_chain(:indices, :refresh).and_raise(NotFound) + end + + it 'does not raise an exception' do + expect(DummyIndexingModelForRefresh.refresh_index!(force: true)).to be_nil + end + + context 'when the client has a logger' do + let(:logger) do + Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } + end + + let(:client) do + Elasticsearch::Client.new(logger: logger) + end + + it 'does not raise an exception' do + expect(DummyIndexingModelForRefresh.refresh_index!(force: true)).to be_nil + end + + it 'logs the message that the index is not found' do + expect(logger).to receive(:debug) + expect(DummyIndexingModelForRefresh.refresh_index!(force: true)).to be_nil + end + end + end + + context 'when the operation raises another type of exception' do + before do + expect(client).to receive_message_chain(:indices, :refresh).and_raise(Exception) + end + + it 'does not raise an exception' do + expect { + DummyIndexingModelForRefresh.refresh_index!(force: true) + }.to raise_exception(Exception) + end + end + end + + context 'when an index name is provided in the options' do + before do + expect(client).to receive_message_chain(:indices, :refresh).with(index: 'custom-foo') + end + + it 'uses the index name' do + expect(DummyIndexingModelForRefresh.refresh_index!(index: 'custom-foo')) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/module_spec.rb b/elasticsearch-model/spec/elasticsearch/model/module_spec.rb new file mode 100644 index 000000000..8808475ee --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/module_spec.rb @@ -0,0 +1,93 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model do + + describe '#client' do + + it 'should have a default' do + expect(Elasticsearch::Model.client).to be_a(Elasticsearch::Client) + end + end + + describe '#client=' do + + before do + Elasticsearch::Model.client = 'Foobar' + end + + it 'should allow the client to be set' do + expect(Elasticsearch::Model.client).to eq('Foobar') + end + end + + describe 'mixin' do + + before(:all) do + class ::DummyIncludingModel; end + class ::DummyIncludingModelWithSearchMethodDefined + def self.search(query, options={}) + "SEARCH" + end + end + + DummyIncludingModel.__send__ :include, Elasticsearch::Model + DummyIncludingModelWithSearchMethodDefined.__send__ :include, Elasticsearch::Model + end + + after(:all) do + remove_classes(DummyIncludingModel, DummyIncludingModelWithSearchMethodDefined) + end + + it 'should include and set up the proxy' do + expect(DummyIncludingModel).to respond_to(:__elasticsearch__) + expect(DummyIncludingModel.new).to respond_to(:__elasticsearch__) + end + + it 'should delegate methods to the proxy' do + expect(DummyIncludingModel).to respond_to(:search) + expect(DummyIncludingModel).to respond_to(:mapping) + expect(DummyIncludingModel).to respond_to(:settings) + expect(DummyIncludingModel).to respond_to(:index_name) + expect(DummyIncludingModel).to respond_to(:import) + end + + it 'should not interfere with existing methods' do + expect(DummyIncludingModelWithSearchMethodDefined.search('foo')).to eq('SEARCH') + end + end + + describe '#settings' do + + it 'allows access to the settings' do + expect(Elasticsearch::Model.settings).to eq({}) + end + + context 'when settings are changed' do + + before do + Elasticsearch::Model.settings[:foo] = 'bar' + end + + it 'persists the changes' do + expect(Elasticsearch::Model.settings[:foo]).to eq('bar') + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/multimodel_spec.rb b/elasticsearch-model/spec/elasticsearch/model/multimodel_spec.rb new file mode 100644 index 000000000..31d429074 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/multimodel_spec.rb @@ -0,0 +1,66 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Multimodel do + + let(:multimodel) do + Elasticsearch::Model::Multimodel.new(model_1, model_2) + end + + let(:model_1) do + double('Foo', index_name: 'foo_index', to_ary: nil) + end + + let(:model_2) do + double('Bar', index_name: 'bar_index', to_ary: nil) + end + + it 'has an index name' do + expect(multimodel.index_name).to eq(['foo_index', 'bar_index']) + end + + it 'has a client' do + expect(multimodel.client).to eq(Elasticsearch::Model.client) + end + + describe 'the model registry' do + before(:all) do + class JustAModel + include Elasticsearch::Model + end + + class JustAnotherModel + include Elasticsearch::Model + end + end + + after(:all) do + remove_classes(JustAModel, JustAnotherModel) + end + + let(:multimodel) do + Elasticsearch::Model::Multimodel.new + end + + it 'includes model in the registry' do + expect(multimodel.models).to include(JustAModel) + expect(multimodel.models).to include(JustAnotherModel) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/naming_spec.rb b/elasticsearch-model/spec/elasticsearch/model/naming_spec.rb new file mode 100644 index 000000000..917bc099c --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/naming_spec.rb @@ -0,0 +1,135 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'naming' do + + before(:all) do + class ::DummyNamingModel + extend ActiveModel::Naming + + extend Elasticsearch::Model::Naming::ClassMethods + include Elasticsearch::Model::Naming::InstanceMethods + end + + module ::MyNamespace + class DummyNamingModelInNamespace + extend ActiveModel::Naming + + extend Elasticsearch::Model::Naming::ClassMethods + include Elasticsearch::Model::Naming::InstanceMethods + end + end + end + + after(:all) do + remove_classes(DummyNamingModel, MyNamespace) + end + + it 'returns the default index name' do + expect(DummyNamingModel.index_name).to eq('dummy_naming_models') + expect(DummyNamingModel.new.index_name).to eq('dummy_naming_models') + end + + it 'returns the sanitized defualt index name for namespaced models' do + expect(::MyNamespace::DummyNamingModelInNamespace.index_name).to eq('my_namespace-dummy_naming_model_in_namespaces') + expect(::MyNamespace::DummyNamingModelInNamespace.new.index_name).to eq('my_namespace-dummy_naming_model_in_namespaces') + end + + describe '#index_name' do + context 'when the index name is set on the class' do + + before do + DummyNamingModel.index_name 'foobar' + end + + it 'sets the index_name' do + expect(DummyNamingModel.index_name).to eq('foobar') + end + end + + context 'when the index name is set on an instance' do + + before do + instance.index_name 'foobar_d' + end + + let(:instance) do + DummyNamingModel.new + end + + it 'sets the index name on the instance' do + expect(instance.index_name).to eq('foobar_d') + end + + context 'when the index name is set with a proc' do + + before do + modifier = 'r' + instance.index_name Proc.new{ "foobar_#{modifier}" } + end + + it 'sets the index name on the instance' do + expect(instance.index_name).to eq('foobar_r') + end + end + end + end + + describe '#index_name=' do + + before do + DummyNamingModel.index_name = 'foobar_index_S' + end + + it 'changes the index name' do + expect(DummyNamingModel.index_name).to eq('foobar_index_S') + end + + context 'when the method is called on an instance' do + + let(:instance) do + DummyNamingModel.new + end + + before do + instance.index_name = 'foobar_index_s' + end + + it 'changes the index name' do + expect(instance.index_name).to eq('foobar_index_s') + end + + it 'does not change the index name on the class' do + expect(DummyNamingModel.index_name).to eq('foobar_index_S') + end + end + + context 'when the index name is changed with a proc' do + + before do + modifier2 = 'y' + DummyNamingModel.index_name = Proc.new{ "foobar_index_#{modifier2}" } + end + + it 'changes the index name' do + expect(DummyNamingModel.index_name).to eq('foobar_index_y') + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/proxy_spec.rb b/elasticsearch-model/spec/elasticsearch/model/proxy_spec.rb new file mode 100644 index 000000000..5465c22a9 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/proxy_spec.rb @@ -0,0 +1,130 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Proxy do + before(:all) do + class ::DummyProxyModel + include Elasticsearch::Model::Proxy + + def self.foo + 'classy foo' + end + + def bar + 'insta barr' + end + + def keyword_method(foo: 'default value') + foo + end + + def as_json(options) + {foo: 'bar'} + end + end + + class ::DummyProxyModelWithCallbacks + def self.before_save(&block) + (@callbacks ||= {})[block.hash] = block + end + + def changes_to_save + {:foo => ['One', 'Two']} + end + end + + DummyProxyModelWithCallbacks.__send__ :include, Elasticsearch::Model::Proxy + end + + after(:all) do + remove_classes(DummyProxyModel, DummyProxyModelWithCallbacks) + end + + it 'sets up a proxy method on the class' do + expect(DummyProxyModel).to respond_to(:__elasticsearch__) + end + + it 'sets up a proxy method on instances' do + expect(DummyProxyModel.new).to respond_to(:__elasticsearch__) + end + + it 'sets up hooks for before_save callbacks' do + expect(DummyProxyModelWithCallbacks).to respond_to(:before_save) + end + + it 'delegates methods to the target' do + expect(DummyProxyModel.__elasticsearch__).to respond_to(:foo) + expect(DummyProxyModel.__elasticsearch__.foo).to eq('classy foo') + expect(DummyProxyModel.new.__elasticsearch__).to respond_to(:bar) + expect(DummyProxyModel.new.__elasticsearch__.bar).to eq('insta barr') + + expect { + DummyProxyModel.__elasticsearch__.xoxo + }.to raise_exception(NoMethodError) + + expect { + DummyProxyModel.new.__elasticsearch__.xoxo + }.to raise_exception(NoMethodError) + end + + it 'returns the proxy class from an instance proxy' do + expect(DummyProxyModel.new.__elasticsearch__.class.class).to eq(Elasticsearch::Model::Proxy::ClassMethodsProxy) + end + + it 'returns the origin class from an instance proxy' do + expect(DummyProxyModel.new.__elasticsearch__.klass).to eq(DummyProxyModel) + end + + it 'delegates #as_json from the proxy to the target' do + expect(DummyProxyModel.new.__elasticsearch__.as_json).to eq(foo: 'bar') + end + + it 'includes the proxy in the inspect string' do + expect(DummyProxyModel.__elasticsearch__.inspect).to match(/PROXY/) + expect(DummyProxyModel.new.__elasticsearch__.inspect).to match(/PROXY/) + end + + context 'when instances are cloned' do + let!(:model) do + DummyProxyModel.new + end + + let!(:model_target) do + model.__elasticsearch__.target + end + + let!(:duplicate) do + model.dup + end + + let!(:duplicate_target) do + duplicate.__elasticsearch__.target + end + + it 'resets the proxy target' do + expect(model).not_to eq(duplicate) + expect(model).to eq(model_target) + expect(duplicate).to eq(duplicate_target) + end + + it 'forwards keyword arguments to target methods' do + expect(DummyProxyModel.new.__elasticsearch__.keyword_method(foo: 'bar')).to eq('bar') + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/response/aggregations_spec.rb b/elasticsearch-model/spec/elasticsearch/model/response/aggregations_spec.rb new file mode 100644 index 000000000..4bfd89efe --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/response/aggregations_spec.rb @@ -0,0 +1,83 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Response::Aggregations do + + before(:all) do + class OriginClass + def self.index_name; 'foo'; end + end + end + + after(:all) do + remove_classes(OriginClass) + end + + let(:response_document) do + { + 'aggregations' => { + 'foo' => {'bar' => 10 }, + 'price' => { + 'doc_count' => 123, + 'min' => { 'value' => 1.0}, + 'max' => { 'value' => 99 } + } + } + } + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(OriginClass, '*').tap do |request| + allow(request).to receive(:execute!).and_return(response_document) + end + end + + let(:aggregations) do + Elasticsearch::Model::Response::Response.new(OriginClass, search).aggregations + end + + describe 'method delegation' do + + it 'delegates methods to the response document' do + expect(aggregations.foo).to be_a(Hashie::Mash) + expect(aggregations.foo.bar).to be(10) + end + end + + describe '#doc_count' do + + it 'returns the doc count value from the response document' do + expect(aggregations.price.doc_count).to eq(123) + end + end + + describe '#min' do + + it 'returns the min value from the response document' do + expect(aggregations.price.min.value).to eq(1) + end + end + + describe '#max' do + + it 'returns the max value from the response document' do + expect(aggregations.price.max.value).to eq(99) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/response/base_spec.rb b/elasticsearch-model/spec/elasticsearch/model/response/base_spec.rb new file mode 100644 index 000000000..dd5ff2d26 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/response/base_spec.rb @@ -0,0 +1,106 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Response::Base do + + before(:all) do + class DummyBaseClass + include Elasticsearch::Model::Response::Base + end + + class OriginClass + def self.index_name; 'foo'; end + end + end + + after(:all) do + remove_classes(DummyBaseClass, OriginClass) + end + + let(:response_document) do + { 'hits' => { 'total' => 123, 'max_score' => 456, 'hits' => [] } } + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(OriginClass, '*').tap do |request| + allow(request).to receive(:execute!).and_return(response_document) + end + end + + let(:response) do + Elasticsearch::Model::Response::Response.new(OriginClass, search) + end + + let(:response_base) do + DummyBaseClass.new(OriginClass, response) + end + + describe '#klass' do + + it 'returns the class' do + expect(response.klass).to be(OriginClass) + end + end + + describe '#response' do + + it 'returns the response object' do + expect(response_base.response).to eq(response) + end + end + + describe 'response document' do + + it 'returns the response document' do + expect(response_base.response.response).to eq(response_document) + end + end + + describe '#total' do + + it 'returns the total' do + expect(response_base.total).to eq(123) + end + end + + describe '#max_score' do + + it 'returns the total' do + expect(response_base.max_score).to eq(456) + end + end + + describe '#results' do + + it 'raises a NotImplemented error' do + expect { + response_base.results + }.to raise_exception(Elasticsearch::Model::NotImplemented) + end + end + + describe '#records' do + + it 'raises a NotImplemented error' do + expect { + response_base.records + }.to raise_exception(Elasticsearch::Model::NotImplemented) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/response/pagination/kaminari_spec.rb b/elasticsearch-model/spec/elasticsearch/model/response/pagination/kaminari_spec.rb new file mode 100644 index 000000000..cb01c86cb --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/response/pagination/kaminari_spec.rb @@ -0,0 +1,471 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Response::Response Kaminari' do + + before(:all) do + class ModelClass + include ::Kaminari::ConfigurationMethods + def self.index_name; 'foo'; end + end + end + + after(:all) do + remove_classes(ModelClass) + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(model, '*') + end + + let(:response) do + allow(model).to receive(:client).and_return(client) + Elasticsearch::Model::Response::Response.new(model, search, response_document).tap do |resp| + allow(resp).to receive(:client).and_return(client) + end + end + + let(:client) do + double('client') + end + + shared_examples_for 'a search request that can be paginated' do + + describe '#page' do + + it 'does not set an initial from and size on the search definition' do + expect(response.search.definition[:from]).to be(nil) + expect(response.search.definition[:size]).to be(nil) + end + + context 'when page is called once' do + + let(:search_request) do + { index: index_field, from: 25, size: 25, q: '*' } + end + + before do + expect(client).to receive(:search).with(search_request).and_return(response_document) + response.page(2).to_a + end + + it 'advances the from/size in the search request' do + expect(response.search.definition[:from]).to be(25) + expect(response.search.definition[:size]).to be(25) + end + end + + context 'when page is called more than once' do + + let(:search_request_one) do + { index: index_field, from: 25, size: 25, q: '*' } + end + + let(:search_request_two) do + { index: index_field, from: 75, size: 25, q: '*' } + end + + before do + expect(client).to receive(:search).with(search_request_one).and_return(response_document) + response.page(2).to_a + expect(client).to receive(:search).with(search_request_two).and_return(response_document) + response.page(4).to_a + end + + it 'advances the from/size in the search request' do + expect(response.search.definition[:from]).to be(75) + expect(response.search.definition[:size]).to be(25) + end + end + + context 'when limit is also set' do + + before do + response.records + response.results + end + + context 'when page is called before limit' do + + before do + response.page(3).limit(35) + end + + it 'sets the correct values' do + expect(response.search.definition[:size]).to eq(35) + expect(response.search.definition[:from]).to eq(70) + end + + it 'resets the instance variables' do + expect(response.instance_variable_get(:@response)).to be(nil) + expect(response.instance_variable_get(:@records)).to be(nil) + expect(response.instance_variable_get(:@results)).to be(nil) + end + end + + context 'when limit is called before page' do + + before do + response.limit(35).page(3) + end + + it 'sets the correct values' do + expect(response.search.definition[:size]).to eq(35) + expect(response.search.definition[:from]).to eq(70) + end + + it 'resets the instance variables' do + expect(response.instance_variable_get(:@response)).to be(nil) + expect(response.instance_variable_get(:@records)).to be(nil) + expect(response.instance_variable_get(:@results)).to be(nil) + end + end + end + end + + describe '#limit_value' do + + context 'when there is no default set' do + + it 'uses the limit value from the Kaminari configuration' do + expect(response.limit_value).to eq(Kaminari.config.default_per_page) + end + end + + context 'when there is a limit in the search definition' do + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(model, '*', size: 10) + end + + it 'gets the limit from the search definition' do + expect(response.limit_value).to eq(10) + end + end + + context 'when there is a limit in the search body' do + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(model, { query: { match_all: {} }, size: 999 }) + end + + it 'does not use the limit' do + expect(response.limit_value).to be(Kaminari.config.default_per_page) + end + end + end + + describe '#offset_value' do + + context 'when there is no default set' do + + it 'uses an offset of 0' do + expect(response.offset_value).to eq(0) + end + end + + context 'when there is an offset in the search definition' do + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(model, '*', from: 50) + end + + it 'gets the limit from the search definition' do + expect(response.offset_value).to eq(50) + end + end + + context 'when there is an offset in the search body' do + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(model, { query: { match_all: {} }, from: 333 }) + end + + it 'does not use the offset' do + expect(response.offset_value).to be(0) + end + end + end + + describe '#limit' do + + context 'when a limit is set' do + + before do + response.records + response.results + response.limit(35) + end + + it 'sets the limit on the search defintiion' do + expect(response.search.definition[:size]).to eq(35) + end + + it 'resets the instance variables' do + expect(response.instance_variable_get(:@response)).to be(nil) + expect(response.instance_variable_get(:@records)).to be(nil) + expect(response.instance_variable_get(:@results)).to be(nil) + end + + context 'when the limit is provided as a string' do + + before do + response.limit('35') + end + + it 'coerces the string to an integer' do + expect(response.search.definition[:size]).to eq(35) + end + end + + context 'when the limit is an invalid type' do + + before do + response.limit('asdf') + end + + it 'does not apply the setting' do + expect(response.search.definition[:size]).to eq(35) + end + end + end + end + + describe '#offset' do + + context 'when an offset is set' do + + before do + response.records + response.results + response.offset(15) + end + + it 'sets the limit on the search defintiion' do + expect(response.search.definition[:from]).to eq(15) + end + + it 'resets the instance variables' do + expect(response.instance_variable_get(:@response)).to be(nil) + expect(response.instance_variable_get(:@records)).to be(nil) + expect(response.instance_variable_get(:@results)).to be(nil) + end + + context 'when the offset is provided as a string' do + + before do + response.offset('15') + end + + it 'coerces the string to an integer' do + expect(response.search.definition[:from]).to eq(15) + end + end + + context 'when the offset is an invalid type' do + + before do + response.offset('asdf') + end + + it 'does not apply the setting' do + expect(response.search.definition[:from]).to eq(0) + end + end + end + end + + describe '#total' do + + before do + allow(response.results).to receive(:total).and_return(100) + end + + it 'returns the total number of hits' do + expect(response.total_count).to eq(100) + end + end + + context 'results' do + + before do + allow(search).to receive(:execute!).and_return(response_document) + end + + describe '#current_page' do + + it 'returns the current page' do + expect(response.results.current_page).to eq(1) + end + + context 'when a particular page is accessed' do + + it 'returns the correct current page' do + expect(response.page(5).results.current_page).to eq(5) + end + end + end + + describe '#prev_page' do + + it 'returns the previous page' do + expect(response.page(1).results.prev_page).to be(nil) + expect(response.page(2).results.prev_page).to be(1) + expect(response.page(3).results.prev_page).to be(2) + expect(response.page(4).results.prev_page).to be(3) + end + end + + describe '#next_page' do + + it 'returns the previous page' do + expect(response.page(1).results.next_page).to be(2) + expect(response.page(2).results.next_page).to be(3) + expect(response.page(3).results.next_page).to be(4) + expect(response.page(4).results.next_page).to be(nil) + end + end + end + + context 'records' do + + before do + allow(search).to receive(:execute!).and_return(response_document) + end + + describe '#current_page' do + + it 'returns the current page' do + expect(response.records.current_page).to eq(1) + end + + context 'when a particular page is accessed' do + + it 'returns the correct current page' do + expect(response.page(5).records.current_page).to eq(5) + end + end + end + + describe '#prev_page' do + + it 'returns the previous page' do + expect(response.page(1).records.prev_page).to be(nil) + expect(response.page(2).records.prev_page).to be(1) + expect(response.page(3).records.prev_page).to be(2) + expect(response.page(4).records.prev_page).to be(3) + end + end + + describe '#next_page' do + + it 'returns the previous page' do + expect(response.page(1).records.next_page).to be(2) + expect(response.page(2).records.next_page).to be(3) + expect(response.page(3).records.next_page).to be(4) + expect(response.page(4).records.next_page).to be(nil) + end + end + end + end + + context 'when Elasticsearch version is < 7.0' do + + let(:response_document) do + { 'took' => '5', 'timed_out' => false, '_shards' => {'one' => 'OK'}, + 'hits' => { 'total' => 100, 'hits' => (1..100).to_a.map { |i| { _id: i } } } } + end + + context 'when the model is a single one' do + + let(:model) do + ModelClass + end + + let(:type_field) do + 'bar' + end + + let(:index_field) do + 'foo' + end + + it_behaves_like 'a search request that can be paginated' + end + + context 'when the model is a multimodel' do + + let(:model) do + Elasticsearch::Model::Multimodel.new(ModelClass) + end + + let(:type_field) do + ['bar'] + end + + let(:index_field) do + ['foo'] + end + + it_behaves_like 'a search request that can be paginated' + end + end + + context 'when Elasticsearch version is >= 7.0' do + + let(:response_document) do + { 'took' => '5', 'timed_out' => false, '_shards' => {'one' => 'OK'}, + 'hits' => { 'total' => { 'value' => 100, 'relation' => 'eq' }, 'hits' => (1..100).to_a.map { |i| { _id: i } } } } + end + + context 'when the model is a single one' do + + let(:model) do + ModelClass + end + + let(:type_field) do + 'bar' + end + + let(:index_field) do + 'foo' + end + + it_behaves_like 'a search request that can be paginated' + end + + context 'when the model is a multimodel' do + + let(:model) do + Elasticsearch::Model::Multimodel.new(ModelClass) + end + + let(:type_field) do + ['bar'] + end + + let(:index_field) do + ['foo'] + end + + it_behaves_like 'a search request that can be paginated' + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/response/pagination/will_paginate_spec.rb b/elasticsearch-model/spec/elasticsearch/model/response/pagination/will_paginate_spec.rb new file mode 100644 index 000000000..0f7303adf --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/response/pagination/will_paginate_spec.rb @@ -0,0 +1,278 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'Elasticsearch::Model::Response::Response WillPaginate' do + + before(:all) do + class ModelClass + def self.index_name; 'foo'; end + + def self.per_page + 33 + end + end + + # Subclass Response so we can include WillPaginate module without conflicts with Kaminari. + class WillPaginateResponse < Elasticsearch::Model::Response::Response + include Elasticsearch::Model::Response::Pagination::WillPaginate + end + end + + after(:all) do + remove_classes(ModelClass, WillPaginateResponse) + end + + let(:response_document) do + { 'took' => '5', 'timed_out' => false, '_shards' => {'one' => 'OK'}, + 'hits' => { 'total' => 100, 'hits' => (1..100).to_a.map { |i| { _id: i } } } } + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(model, '*') + end + + let(:response) do + allow(model).to receive(:client).and_return(client) + WillPaginateResponse.new(model, search, response_document).tap do |resp| + allow(resp).to receive(:client).and_return(client) + end + end + + let(:client) do + double('client') + end + + shared_examples_for 'a search request that can be paginated' do + + describe '#offset' do + + context 'when per_page and page are set' do + + before do + response.per_page(3).page(3) + end + + it 'sets the correct offset' do + expect(response.offset).to eq(6) + end + end + end + + describe '#length' do + + context 'when per_page and page are set' do + + before do + response.per_page(3).page(3) + end + + it 'sets the correct offset' do + expect(response.length).to eq(3) + end + end + end + + describe '#paginate' do + + context 'when there are no settings' do + + context 'when page is set to nil' do + + before do + response.paginate(page: nil) + end + + it 'uses the defaults' do + expect(response.search.definition[:size]).to eq(default_per_page) + expect(response.search.definition[:from]).to eq(0) + end + end + + context 'when page is set to a value' do + + before do + response.paginate(page: 2) + end + + it 'uses the defaults' do + expect(response.search.definition[:size]).to eq(default_per_page) + expect(response.search.definition[:from]).to eq(default_per_page) + end + end + + context 'when a custom page and per_page is set' do + + before do + response.paginate(page: 3, per_page: 9) + end + + it 'uses the custom values' do + expect(response.search.definition[:size]).to eq(9) + expect(response.search.definition[:from]).to eq(18) + end + end + + context 'fall back to first page if invalid value is provided' do + + before do + response.paginate(page: -1) + end + + it 'uses the custom values' do + expect(response.search.definition[:size]).to eq(default_per_page) + expect(response.search.definition[:from]).to eq(0) + end + end + end + end + + describe '#page' do + + context 'when a value is provided for page' do + + before do + response.page(5) + end + + it 'calculates the correct :size and :from' do + expect(response.search.definition[:size]).to eq(default_per_page) + expect(response.search.definition[:from]).to eq(default_per_page * 4) + end + end + + context 'when a value is provided for page and per_page' do + + before do + response.page(5).per_page(3) + end + + it 'calculates the correct :size and :from' do + expect(response.search.definition[:size]).to eq(3) + expect(response.search.definition[:from]).to eq(12) + end + end + + context 'when a value is provided for per_page and page' do + + before do + response.per_page(3).page(5) + end + + it 'calculates the correct :size and :from' do + expect(response.search.definition[:size]).to eq(3) + expect(response.search.definition[:from]).to eq(12) + end + end + end + + describe '#current_page' do + + context 'when no values are set' do + + before do + response.paginate({}) + end + + it 'returns the first page' do + expect(response.current_page).to eq(1) + end + end + + context 'when values are provided for per_page and page' do + + before do + response.paginate(page: 3, per_page: 9) + end + + it 'calculates the correct current page' do + expect(response.current_page).to eq(3) + end + end + + context 'when #paginate has not been called on the response' do + + it 'returns nil' do + expect(response.current_page).to be_nil + end + end + end + + describe '#per_page' do + + context 'when a value is set via the #paginate method' do + + before do + response.paginate(per_page: 8) + end + + it 'returns the per_page value' do + expect(response.per_page).to eq(8) + end + end + + context 'when a value is set via the #per_page method' do + + before do + response.per_page(8) + end + + it 'returns the per_page value' do + expect(response.per_page).to eq(8) + end + end + end + + describe '#total_entries' do + + before do + allow(response).to receive(:results).and_return(double('results', total: 100)) + end + + it 'returns the total results' do + expect(response.total_entries).to eq(100) + end + end + end + + context 'when the model is a single one' do + + let(:model) do + ModelClass + end + + let(:default_per_page) do + 33 + end + + it_behaves_like 'a search request that can be paginated' + end + + context 'when the model is a multimodel' do + + let(:model) do + Elasticsearch::Model::Multimodel.new(ModelClass) + end + + let(:default_per_page) do + ::WillPaginate.per_page + end + + it_behaves_like 'a search request that can be paginated' + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/response/records_spec.rb b/elasticsearch-model/spec/elasticsearch/model/response/records_spec.rb new file mode 100644 index 000000000..a4e5e7072 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/response/records_spec.rb @@ -0,0 +1,134 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Response::Records do + + before(:all) do + class DummyCollection + include Enumerable + + def each(&block); ['FOO'].each(&block); end + def size; ['FOO'].size; end + def empty?; ['FOO'].empty?; end + def foo; 'BAR'; end + end + + class DummyModel + def self.index_name; 'foo'; end + + def self.find(*args) + DummyCollection.new + end + end + end + + after(:all) do + remove_classes(DummyCollection, DummyModel) + end + + let(:response_document) do + { 'hits' => { 'total' => 123, 'max_score' => 456, 'hits' => [{'_id' => '1', 'foo' => 'bar'}] } } + end + + let(:results) do + Elasticsearch::Model::Response::Results.new(DummyModel, response_document) + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(DummyModel, '*').tap do |request| + allow(request).to receive(:execute!).and_return(response_document) + end + end + + let(:response) do + Elasticsearch::Model::Response::Response.new(DummyModel, search) + end + + let(:records) do + described_class.new(DummyModel, response) + end + + context 'when the records are accessed' do + + it 'returns the records' do + expect(records.records.size).to eq(1) + expect(records.records.first).to eq('FOO') + end + + it 'delegates methods to records' do + expect(records.foo).to eq('BAR') + end + end + + describe '#each_with_hit' do + + it 'returns each record with its Elasticsearch hit' do + records.each_with_hit do |record, hit| + expect(record).to eq('FOO') + expect(hit.foo).to eq('bar') + end + end + end + + describe '#map_with_hit' do + + let(:value) do + records.map_with_hit { |record, hit| "#{record}---#{hit.foo}" } + end + + it 'returns each record with its Elasticsearch hit' do + expect(value).to eq(['FOO---bar']) + end + end + + describe '#ids' do + + it 'returns the ids' do + expect(records.ids).to eq(['1']) + end + end + + context 'when an adapter is used' do + + before do + module DummyAdapter + module RecordsMixin + def records + ['FOOBAR'] + end + end + + def records_mixin + RecordsMixin + end; module_function :records_mixin + end + + allow(Elasticsearch::Model::Adapter).to receive(:from_class).and_return(DummyAdapter) + end + + after do + Elasticsearch::Model::Adapter::Adapter.adapters.delete(DummyAdapter) + Object.send(:remove_const, :DummyAdapter) if defined?(DummyAdapter) + end + + it 'delegates the records method to the adapter' do + expect(records.records).to eq(['FOOBAR']) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/response/response_spec.rb b/elasticsearch-model/spec/elasticsearch/model/response/response_spec.rb new file mode 100644 index 000000000..7186b4710 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/response/response_spec.rb @@ -0,0 +1,147 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Response::Response do + + before(:all) do + class OriginClass + def self.index_name; 'foo'; end + end + end + + after(:all) do + remove_classes(OriginClass) + end + + let(:response_document) do + { 'took' => '5', 'timed_out' => false, '_shards' => {'one' => 'OK'}, 'hits' => { 'hits' => [] }, + 'aggregations' => {'foo' => {'bar' => 10}}, + 'suggest' => {'my_suggest' => [ { 'text' => 'foo', 'options' => [ { 'text' => 'Foo', 'score' => 2.0 }, + { 'text' => 'Bar', 'score' => 1.0 } ] } ]}} + + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(OriginClass, '*').tap do |request| + allow(request).to receive(:execute!).and_return(response_document) + end + end + + let(:response) do + Elasticsearch::Model::Response::Response.new(OriginClass, search) + end + + it 'performs the Elasticsearch request lazily' do + expect(search).not_to receive(:execute!) + response + end + + describe '#klass' do + + it 'returns the class' do + expect(response.klass).to be(OriginClass) + end + end + + describe '#search' do + + it 'returns the search object' do + expect(response.search).to eq(search) + end + end + + describe '#took' do + + it 'returns the took field' do + expect(response.took).to eq('5') + end + end + + describe '#timed_out' do + + it 'returns the timed_out field' do + expect(response.timed_out).to eq(false) + end + end + + describe '#shards' do + + it 'returns a Hashie::Mash' do + expect(response.shards.one).to eq('OK') + end + end + + describe '#response' do + + it 'returns the response document' do + expect(response.response).to eq(response_document) + end + end + + describe '#results' do + + it 'provides access to the results' do + expect(response.results).to be_a(Elasticsearch::Model::Response::Results) + expect(response.size).to be(0) + end + end + + describe '#records' do + + it 'provides access to the records' do + expect(response.records).to be_a(Elasticsearch::Model::Response::Records) + expect(response.size).to be(0) + end + end + + describe 'enumerable methods' do + + it 'delegates the methods to the results' do + expect(response.empty?).to be(true) + end + end + + describe 'aggregations' do + + it 'provides access to the aggregations' do + expect(response.aggregations).to be_a(Hashie::Mash) + expect(response.aggregations.foo.bar).to eq(10) + end + end + + describe 'suggestions' do + + it 'provides access to the suggestions' do + expect(response.suggestions).to be_a(Hashie::Mash) + expect(response.suggestions.my_suggest.first.options.first.text).to eq('Foo') + expect(response.suggestions.terms).to eq([ 'Foo', 'Bar' ]) + end + + context 'when there are no suggestions' do + + let(:response_document) do + { } + end + + it 'returns an empty list' do + expect(response.suggestions.terms).to eq([ ]) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/response/result_spec.rb b/elasticsearch-model/spec/elasticsearch/model/response/result_spec.rb new file mode 100644 index 000000000..cf5abd38f --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/response/result_spec.rb @@ -0,0 +1,122 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' +require 'active_support/json/encoding' + +describe Elasticsearch::Model::Response::Result do + let(:result) do + described_class.new(foo: 'bar', bar: { bam: 'baz' }) + end + + it 'provides access to the properties' do + expect(result.foo).to eq('bar') + expect(result.bar.bam).to eq('baz') + expect { result.xoxo }.to raise_exception(NoMethodError) + end + + describe '#id' do + + let(:result) do + described_class.new(foo: 'bar', _id: 42, _source: { id: 12 }) + end + + it 'returns the _id field' do + expect(result.id).to eq(42) + end + + it 'provides access to the source id field' do + expect(result._source.id).to eq(12) + end + end + + describe 'method delegation' do + let(:result) do + described_class.new(foo: 'bar', _source: { bar: { bam: 'baz' } }) + end + + it 'provides access to the _source field via a method' do + expect(result._source).to eq('bar' => { 'bam' => 'baz' }) + end + + it 'is recognized by #method' do + expect(result.method :bar).to be_a Method + end + + it 'respond_to? still works' do + expect(result.respond_to? :bar).to be true + end + + context 'when methods map to keys in subdocuments of the response from Elasticsearch' do + + it 'provides access to top level fields via a method' do + expect(result.foo).to eq('bar') + expect(result.fetch(:foo)).to eq('bar') + expect(result.fetch(:does_not_exist, 'moo')).to eq('moo') + end + + it 'responds to hash methods' do + expect(result.keys).to eq(['foo', '_source']) + expect(result.to_hash).to eq('foo' => 'bar', '_source' => { 'bar' => { 'bam' => 'baz' } }) + end + + it 'provides access to fields in the _source subdocument via a method' do + expect(result.bar).to eq('bam' => 'baz') + expect(result.bar.bam).to eq('baz') + expect(result._source.bar).to eq('bam' => 'baz') + expect(result._source.bar.bam).to eq('baz') + end + + context 'when boolean methods are called' do + + it 'provides access to top level fields via a method' do + expect(result.foo?).to eq(true) + expect(result.boo?).to eq(false) + end + + it 'delegates to fields in the _source subdocument via a method' do + expect(result.bar?).to eq(true) + expect(result.bar.bam?).to eq(true) + expect(result.boo?).to eq(false) + expect(result.bar.boo?).to eq(false) + expect(result._source.bar?).to eq(true) + expect(result._source.bar.bam?).to eq(true) + expect(result._source.boo?).to eq(false) + expect(result._source.bar.boo?).to eq(false) + end + end + end + + context 'when methods do not map to keys in subdocuments of the response from Elasticsearch' do + + it 'raises a NoMethodError' do + expect { result.does_not_exist }.to raise_exception(NoMethodError) + end + end + end + + describe '#as_json' do + + let(:result) do + described_class.new(foo: 'bar', _source: { bar: { bam: 'baz' } }) + end + + it 'returns a json string' do + expect(result.as_json(except: 'foo')).to eq({'_source'=>{'bar'=>{'bam'=>'baz'}}}) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/response/results_spec.rb b/elasticsearch-model/spec/elasticsearch/model/response/results_spec.rb new file mode 100644 index 000000000..f944e1877 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/response/results_spec.rb @@ -0,0 +1,84 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Response::Results do + + before(:all) do + class OriginClass + def self.index_name; 'foo'; end + end + end + + after(:all) do + remove_classes(OriginClass) + end + + let(:response_document) do + { 'hits' => { 'total' => 123, 'max_score' => 456, 'hits' => [{'foo' => 'bar'}] } } + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(OriginClass, '*').tap do |request| + allow(request).to receive(:execute!).and_return(response_document) + end + end + + let(:response) do + Elasticsearch::Model::Response::Response.new(OriginClass, search) + end + + let(:results) do + response.results + end + + let(:records) do + response.records + end + + describe '#results' do + + it 'provides access to the results' do + expect(results.results.size).to be(1) + expect(results.results.first.foo).to eq('bar') + end + end + + describe 'Enumerable' do + + it 'deletebates enumerable methods to the results' do + expect(results.empty?).to be(false) + expect(results.first.foo).to eq('bar') + end + end + + describe '#raw_response' do + + it 'returns the raw response document' do + expect(response.raw_response).to eq(response_document) + end + end + + describe '#records' do + + it 'provides access to the records' do + expect(results.records.size).to be(results.results.size) + expect(results.records.first.foo).to eq(results.results.first.foo) + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/searching_search_request_spec.rb b/elasticsearch-model/spec/elasticsearch/model/searching_search_request_spec.rb new file mode 100644 index 000000000..1ab23365a --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/searching_search_request_spec.rb @@ -0,0 +1,128 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Serializing do + + before(:all) do + class ::DummySearchingModel + extend Elasticsearch::Model::Searching::ClassMethods + def self.index_name; 'foo'; end + end + end + + after(:all) do + remove_classes(DummySearchingModel) + end + + before do + allow(DummySearchingModel).to receive(:client).and_return(client) + end + + let(:client) do + double('client') + end + + describe '#initialize' do + + context 'when the search definition is a simple query' do + + before do + expect(client).to receive(:search).with({ index: 'foo', q: 'foo' }).and_return({}) + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(DummySearchingModel, 'foo') + end + + it 'passes the query to the client' do + expect(search.execute!).to eq({}) + end + end + + context 'when the search definition is a hash' do + + before do + expect(client).to receive(:search).with({ index: 'foo', body: { foo: 'bar' } }).and_return({}) + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(DummySearchingModel, foo: 'bar') + end + + it 'passes the hash to the client' do + expect(search.execute!).to eq({}) + end + end + + context 'when the search definition is a json string' do + + before do + expect(client).to receive(:search).with({ index: 'foo', body: '{"foo":"bar"}' }).and_return({}) + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(DummySearchingModel, '{"foo":"bar"}') + end + + it 'passes the json string to the client' do + expect(search.execute!).to eq({}) + end + end + + context 'when the search definition is a custom object' do + + before(:all) do + class MySpecialQueryBuilder + def to_hash; {foo: 'bar'}; end + end + end + + after(:all) do + Object.send(:remove_const, :MySpecialQueryBuilder) if defined?(MySpecialQueryBuilder) + end + + before do + expect(client).to receive(:search).with({ index: 'foo', body: {foo: 'bar'} }).and_return({}) + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(DummySearchingModel, MySpecialQueryBuilder.new) + end + + it 'passes the query builder to the client and calls #to_hash on it' do + expect(search.execute!).to eq({}) + end + end + + context 'when extra options are specified' do + + before do + expect(client).to receive(:search).with({ index: 'foo', q: 'foo', size: 15 }).and_return({}) + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(DummySearchingModel, 'foo', size: 15) + end + + it 'passes the extra options to the client as part of the request' do + expect(search.execute!).to eq({}) + end + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/searching_spec.rb b/elasticsearch-model/spec/elasticsearch/model/searching_spec.rb new file mode 100644 index 000000000..ccede7435 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/searching_spec.rb @@ -0,0 +1,66 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Searching::ClassMethods do + + before(:all) do + class ::DummySearchingModel + extend Elasticsearch::Model::Searching::ClassMethods + + def self.index_name; 'foo'; end + def self.document_type; 'bar'; end + end + end + + after(:all) do + remove_classes(DummySearchingModel) + end + + it 'has the search method' do + expect(DummySearchingModel).to respond_to(:search) + end + + describe '#search' do + + let(:response) do + double('search', execute!: { 'hits' => {'hits' => [ {'_id' => 2 }, {'_id' => 1 } ]} }) + end + + before do + expect(Elasticsearch::Model::Searching::SearchRequest).to receive(:new).with(DummySearchingModel, 'foo', { default_operator: 'AND' }).and_return(response) + end + + it 'creates a search object' do + expect(DummySearchingModel.search('foo', default_operator: 'AND')).to be_a(Elasticsearch::Model::Response::Response) + end + end + + describe 'lazy execution' do + + let(:response) do + double('search').tap do |r| + expect(r).to receive(:execute!).never + end + end + + it 'does not execute the search until the results are accessed' do + DummySearchingModel.search('foo') + end + end +end diff --git a/elasticsearch-model/spec/elasticsearch/model/serializing_spec.rb b/elasticsearch-model/spec/elasticsearch/model/serializing_spec.rb new file mode 100644 index 000000000..c5a26a4e4 --- /dev/null +++ b/elasticsearch-model/spec/elasticsearch/model/serializing_spec.rb @@ -0,0 +1,39 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Model::Serializing do + + before(:all) do + class DummyClass + include Elasticsearch::Model::Serializing::InstanceMethods + + def as_json(options={}) + 'HASH' + end + end + end + + after(:all) do + remove_classes(DummyClass) + end + + it 'delegates to #as_json by default' do + expect(DummyClass.new.as_indexed_json).to eq('HASH') + end +end diff --git a/elasticsearch-model/spec/spec_helper.rb b/elasticsearch-model/spec/spec_helper.rb new file mode 100644 index 000000000..7c4f21a4a --- /dev/null +++ b/elasticsearch-model/spec/spec_helper.rb @@ -0,0 +1,191 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'pry-nav' +require 'kaminari' +require 'kaminari/version' +require 'will_paginate' +require 'will_paginate/collection' +require 'elasticsearch/model' +require 'hashie/version' +require 'active_model' +begin + require 'mongoid' +rescue LoadError + $stderr.puts("'mongoid' gem could not be loaded") +end +require 'yaml' +require 'active_record' + +# Load all of ActiveSupport to be sure of complete compatibility - +# see https://github.com/elastic/elasticsearch-rails/pull/1075 for details +require 'active_support/all' + +unless defined?(ELASTICSEARCH_URL) + ELASTICSEARCH_URL = ENV['ELASTICSEARCH_URL'] || "localhost:#{(ENV['TEST_CLUSTER_PORT'] || 9200)}" +end + +RSpec.configure do |config| + config.formatter = 'documentation' + config.color = true + + config.before(:suite) do + require 'ansi' + tracer = ::Logger.new(STDERR) + tracer.formatter = lambda { |s, d, p, m| "#{m.gsub(/^.*$/) { |n| ' ' + n }.ansi(:faint)}\n" } + Elasticsearch::Model.client = Elasticsearch::Client.new( + host: ELASTICSEARCH_URL, + tracer: (ENV['QUIET'] ? nil : tracer), + transport_options: { :ssl => { verify: false } } + ) + puts "Elasticsearch Version: #{Elasticsearch::Model.client.info['version']}" + + unless ActiveRecord::Base.connected? + ActiveRecord::Base.establish_connection( :adapter => 'sqlite3', :database => ":memory:" ) + end + require 'support/app' + + if ::ActiveRecord::Base.respond_to?(:raise_in_transactional_callbacks) && ::ActiveRecord::VERSION::MAJOR.to_s < '5' + ::ActiveRecord::Base.raise_in_transactional_callbacks = true + end + end + + config.after(:all) do + drop_all_tables! + delete_all_indices! + end +end + +# Delete all documents from the indices of the provided list of models. +# +# @param [ Array ] models The list of models. +# +# @return [ true ] +# +# @since 6.0.1 +def clear_indices(*models) + models.each do |model| + begin + Elasticsearch::Model.client.delete_by_query( + index: model.index_name, + q: '*', + body: {} + ) + rescue + end + end + true +end + +# Delete all documents from the tables of the provided list of models. +# +# @param [ Array ] models The list of models. +# +# @return [ true ] +# +# @since 6.0.1 +def clear_tables(*models) + begin; models.map(&:delete_all); rescue; end and true +end + +# Drop all tables of models registered as subclasses of ActiveRecord::Base. +# +# @return [ true ] +# +# @since 6.0.1 +def drop_all_tables! + ActiveRecord::Base.descendants.each do |model| + begin + ActiveRecord::Schema.define do + drop_table model + end if model.table_exists? + rescue + end + end and true +end + +# Drop all indices of models registered as subclasses of ActiveRecord::Base. +# +# @return [ true ] +# +# @since 6.0.1 +def delete_all_indices! + client = Elasticsearch::Model.client + ActiveRecord::Base.descendants.each do |model| + begin + client.indices.delete(index: model.index_name) if model.__elasticsearch__.index_exists? + rescue + end + end and true +end + +# Remove all classes. +# +# @param [ Array ] classes The list of classes to remove. +# +# @return [ true ] +# +# @since 6.0.1 +def remove_classes(*classes) + classes.each do |_class| + Object.send(:remove_const, _class.name.to_sym) if defined?(_class) + end and true +end + +# Determine whether the tests with Mongoid should be run. +# Depends on whether MongoDB is running on the default host and port, `localhost:27017`. +# +# @return [ true, false ] +# +# @since 6.0.1 +def test_mongoid? + $mongoid_available ||= begin + require 'mongoid' + if defined?(Mongo) # older versions of Mongoid use the driver, Moped + client = Mongo::Client.new(['localhost:27017']) + Timeout.timeout(1) do + client.database.command(ping: 1) && true + end + end and true + rescue LoadError + $stderr.puts("'mongoid' gem could not be loaded") + rescue Timeout::Error, Mongo::Error => e + client.close if client + $stderr.puts("MongoDB not installed or running: #{e}") + end +end + +# Connect Mongoid and set up its Logger if Mongoid tests should be run. +# +# @since 6.0.1 +def connect_mongoid(source) + if test_mongoid? + $stderr.puts "Mongoid #{Mongoid::VERSION}", '-'*80 + + if !ENV['QUIET'] == 'true' + logger = ::Logger.new($stderr) + logger.formatter = lambda { |s, d, p, m| " #{m.ansi(:faint, :cyan)}\n" } + logger.level = ::Logger::DEBUG + Mongoid.logger = logger + Mongo::Logger.logger = logger + else + Mongo::Logger.logger.level = ::Logger::WARN + end + + Mongoid.connect_to(source) + end +end diff --git a/elasticsearch-model/spec/support/app.rb b/elasticsearch-model/spec/support/app.rb new file mode 100644 index 000000000..b5a26ebff --- /dev/null +++ b/elasticsearch-model/spec/support/app.rb @@ -0,0 +1,46 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'active_record' + +require 'support/app/question' +require 'support/app/answer' +require 'support/app/parent_and_child_searchable' +require 'support/app/article_with_custom_serialization' +require 'support/app/import_article' +require 'support/app/namespaced_book' +require 'support/app/article_for_pagination' +require 'support/app/article_with_dynamic_index_name' +require 'support/app/episode' +require 'support/app/series' +require 'support/app/article' +require 'support/app/article_no_type' +require 'support/app/searchable' +require 'support/app/category' +require 'support/app/author' +require 'support/app/authorship' +require 'support/app/comment' +require 'support/app/post' + + +# Mongoid models +begin + require 'support/app/image' + require 'support/app/mongoid_article' +rescue + $stderr.puts("'mongoid' gem is not installed, could not load Mongoid models") +end diff --git a/elasticsearch-model/spec/support/app/answer.rb b/elasticsearch-model/spec/support/app/answer.rb new file mode 100644 index 000000000..ec010e3d2 --- /dev/null +++ b/elasticsearch-model/spec/support/app/answer.rb @@ -0,0 +1,49 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Answer < ActiveRecord::Base + include Elasticsearch::Model + + belongs_to :question + + JOIN_TYPE = 'answer'.freeze + + index_name 'questions_and_answers'.freeze + + before_create :randomize_id + + def randomize_id + begin + self.id = SecureRandom.random_number(1_000_000) + end while Answer.where(id: self.id).exists? + end + + mapping do + indexes :text + indexes :author + end + + def as_indexed_json(options={}) + # This line is necessary for differences between ActiveModel::Serializers::JSON#as_json versions + json = as_json(options)[JOIN_TYPE] || as_json(options) + json.merge(join_field: { name: JOIN_TYPE, parent: question_id }) + end + + after_commit lambda { __elasticsearch__.index_document(routing: (question_id || 1)) }, on: :create + after_commit lambda { __elasticsearch__.update_document(routing: (question_id || 1)) }, on: :update + after_commit lambda {__elasticsearch__.delete_document(routing: (question_id || 1)) }, on: :destroy +end diff --git a/elasticsearch-model/spec/support/app/article.rb b/elasticsearch-model/spec/support/app/article.rb new file mode 100644 index 000000000..659d16cec --- /dev/null +++ b/elasticsearch-model/spec/support/app/article.rb @@ -0,0 +1,37 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class ::Article < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + settings index: {number_of_shards: 1, number_of_replicas: 0} do + mapping do + indexes :title, type: 'text', analyzer: 'snowball' + indexes :body, type: 'text' + indexes :clicks, type: 'integer' + indexes :created_at, type: 'date' + end + end + + def as_indexed_json(options = {}) + attributes + .symbolize_keys + .slice(:title, :body, :clicks, :created_at) + .merge(suggest_title: title) + end +end diff --git a/elasticsearch-model/spec/support/app/article_for_pagination.rb b/elasticsearch-model/spec/support/app/article_for_pagination.rb new file mode 100644 index 000000000..7916e56b9 --- /dev/null +++ b/elasticsearch-model/spec/support/app/article_for_pagination.rb @@ -0,0 +1,29 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class ::ArticleForPagination < ActiveRecord::Base + include Elasticsearch::Model + + scope :published, -> { where(published: true) } + + settings index: { number_of_shards: 1, number_of_replicas: 0 } do + mapping do + indexes :title, type: 'text', analyzer: 'snowball' + indexes :created_at, type: 'date' + end + end +end diff --git a/elasticsearch-model/spec/support/app/article_no_type.rb b/elasticsearch-model/spec/support/app/article_no_type.rb new file mode 100644 index 000000000..9e6b8443f --- /dev/null +++ b/elasticsearch-model/spec/support/app/article_no_type.rb @@ -0,0 +1,37 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class ::ArticleNoType < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + settings index: {number_of_shards: 1, number_of_replicas: 0} do + mapping do + indexes :title, analyzer: 'snowball' + indexes :body, type: 'text' + indexes :clicks, type: 'integer' + indexes :created_at, type: 'date' + end + end + + def as_indexed_json(options = {}) + attributes + .symbolize_keys + .slice(:title, :body, :clicks, :created_at) + .merge(suggest_title: title) + end +end diff --git a/elasticsearch-model/spec/support/app/article_with_custom_serialization.rb b/elasticsearch-model/spec/support/app/article_with_custom_serialization.rb new file mode 100644 index 000000000..527143694 --- /dev/null +++ b/elasticsearch-model/spec/support/app/article_with_custom_serialization.rb @@ -0,0 +1,30 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class ::ArticleWithCustomSerialization < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + mapping do + indexes :title + end + + def as_indexed_json(options={}) + # as_json(options.merge root: false).slice('title') + { title: self.title } + end +end diff --git a/elasticsearch-model/spec/support/app/article_with_dynamic_index_name.rb b/elasticsearch-model/spec/support/app/article_with_dynamic_index_name.rb new file mode 100644 index 000000000..6fa1f1298 --- /dev/null +++ b/elasticsearch-model/spec/support/app/article_with_dynamic_index_name.rb @@ -0,0 +1,32 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class ::ArticleWithDynamicIndexName < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + def self.counter=(value) + @counter = 0 + end + + def self.counter + (@counter ||= 0) && @counter += 1 + end + + mapping { indexes :title } + index_name { "articles-#{counter}" } +end diff --git a/elasticsearch-model/spec/support/app/author.rb b/elasticsearch-model/spec/support/app/author.rb new file mode 100644 index 000000000..47abe43a3 --- /dev/null +++ b/elasticsearch-model/spec/support/app/author.rb @@ -0,0 +1,26 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Author < ActiveRecord::Base + has_many :authorships + + after_update { self.authorships.each(&:touch) } + + def full_name + [first_name, last_name].compact.join(' ') + end +end diff --git a/elasticsearch-model/spec/support/app/authorship.rb b/elasticsearch-model/spec/support/app/authorship.rb new file mode 100644 index 000000000..55258f9f7 --- /dev/null +++ b/elasticsearch-model/spec/support/app/authorship.rb @@ -0,0 +1,21 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Authorship < ActiveRecord::Base + belongs_to :author + belongs_to :post, touch: true +end diff --git a/elasticsearch-model/spec/support/app/category.rb b/elasticsearch-model/spec/support/app/category.rb new file mode 100644 index 000000000..6585ad806 --- /dev/null +++ b/elasticsearch-model/spec/support/app/category.rb @@ -0,0 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Category < ActiveRecord::Base + has_and_belongs_to_many :posts +end diff --git a/elasticsearch-model/spec/support/app/comment.rb b/elasticsearch-model/spec/support/app/comment.rb new file mode 100644 index 000000000..f4ae205d7 --- /dev/null +++ b/elasticsearch-model/spec/support/app/comment.rb @@ -0,0 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Comment < ActiveRecord::Base + belongs_to :post, touch: true +end diff --git a/elasticsearch-model/spec/support/app/episode.rb b/elasticsearch-model/spec/support/app/episode.rb new file mode 100644 index 000000000..34ebfc3eb --- /dev/null +++ b/elasticsearch-model/spec/support/app/episode.rb @@ -0,0 +1,28 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Episode < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + settings index: {number_of_shards: 1, number_of_replicas: 0} do + mapping do + indexes :name, type: 'text', analyzer: 'snowball' + indexes :created_at, type: 'date' + end + end +end diff --git a/elasticsearch-model/spec/support/app/image.rb b/elasticsearch-model/spec/support/app/image.rb new file mode 100644 index 000000000..155e974cd --- /dev/null +++ b/elasticsearch-model/spec/support/app/image.rb @@ -0,0 +1,36 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Image + include Mongoid::Document + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + field :name, type: String + attr_accessible :name if respond_to? :attr_accessible + + settings index: {number_of_shards: 1, number_of_replicas: 0} do + mapping do + indexes :name, type: 'text', analyzer: 'snowball' + indexes :created_at, type: 'date' + end + end + + def as_indexed_json(options={}) + as_json(except: [:_id]) + end +end diff --git a/elasticsearch-model/spec/support/app/import_article.rb b/elasticsearch-model/spec/support/app/import_article.rb new file mode 100644 index 000000000..eb0a4962c --- /dev/null +++ b/elasticsearch-model/spec/support/app/import_article.rb @@ -0,0 +1,29 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class ImportArticle < ActiveRecord::Base + include Elasticsearch::Model + + scope :popular, -> { where('views >= 5') } + + mapping do + indexes :title, type: 'text' + indexes :views, type: 'integer' + indexes :numeric, type: 'integer' + indexes :created_at, type: 'date' + end +end diff --git a/elasticsearch-model/spec/support/app/mongoid_article.rb b/elasticsearch-model/spec/support/app/mongoid_article.rb new file mode 100644 index 000000000..5e7c991ca --- /dev/null +++ b/elasticsearch-model/spec/support/app/mongoid_article.rb @@ -0,0 +1,38 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class ::MongoidArticle + include Mongoid::Document + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + field :id, type: String + field :title, type: String + field :views, type: Integer + attr_accessible :title if respond_to? :attr_accessible + + settings index: { number_of_shards: 1, number_of_replicas: 0 } do + mapping do + indexes :title, type: 'text', analyzer: 'snowball' + indexes :created_at, type: 'date' + end + end + + def as_indexed_json(options={}) + as_json(except: [:id, :_id]) + end +end diff --git a/elasticsearch-model/spec/support/app/namespaced_book.rb b/elasticsearch-model/spec/support/app/namespaced_book.rb new file mode 100644 index 000000000..29949772a --- /dev/null +++ b/elasticsearch-model/spec/support/app/namespaced_book.rb @@ -0,0 +1,25 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module MyNamespace + class Book < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + mapping { indexes :title } + end +end diff --git a/elasticsearch-model/spec/support/app/parent_and_child_searchable.rb b/elasticsearch-model/spec/support/app/parent_and_child_searchable.rb new file mode 100644 index 000000000..210e44076 --- /dev/null +++ b/elasticsearch-model/spec/support/app/parent_and_child_searchable.rb @@ -0,0 +1,43 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module ParentChildSearchable + INDEX_NAME = 'questions_and_answers'.freeze + JOIN = 'join'.freeze + + def create_index!(options={}) + client = Question.__elasticsearch__.client + client.indices.delete index: INDEX_NAME rescue nil if options.delete(:force) + + settings = Question.settings.to_hash.merge Answer.settings.to_hash + mapping_properties = { join_field: { type: JOIN, + relations: { Question::JOIN_TYPE => Answer::JOIN_TYPE } } } + merged_properties = mapping_properties.merge( + Question.mappings.to_hash[:properties] + ).merge( + Answer.mappings.to_hash[:properties] + ) + mappings = { properties: merged_properties } + + client.indices.create({ index: INDEX_NAME, + body: { + settings: settings.to_hash, + mappings: mappings } }.merge(options)) + end + + extend self +end diff --git a/elasticsearch-model/spec/support/app/post.rb b/elasticsearch-model/spec/support/app/post.rb new file mode 100644 index 000000000..9805d9cc3 --- /dev/null +++ b/elasticsearch-model/spec/support/app/post.rb @@ -0,0 +1,31 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Post < ActiveRecord::Base + include Searchable + + has_and_belongs_to_many :categories, after_add: [ lambda { |a,c| a.__elasticsearch__.index_document } ], + after_remove: [ lambda { |a,c| a.__elasticsearch__.index_document } ] + has_many :authorships + has_many :authors, through: :authorships, + after_add: [ lambda { |a,c| a.__elasticsearch__.index_document } ], + after_remove: [ lambda { |a,c| a.__elasticsearch__.index_document } ] + has_many :comments, after_add: [ lambda { |a,c| a.__elasticsearch__.index_document } ], + after_remove: [ lambda { |a,c| a.__elasticsearch__.index_document } ] + + after_touch() { __elasticsearch__.index_document } +end diff --git a/elasticsearch-model/spec/support/app/question.rb b/elasticsearch-model/spec/support/app/question.rb new file mode 100644 index 000000000..fce543139 --- /dev/null +++ b/elasticsearch-model/spec/support/app/question.rb @@ -0,0 +1,43 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Question < ActiveRecord::Base + include Elasticsearch::Model + + has_many :answers, dependent: :destroy + + JOIN_TYPE = 'question'.freeze + JOIN_METADATA = { join_field: JOIN_TYPE}.freeze + + index_name 'questions_and_answers'.freeze + + mapping do + indexes :title + indexes :text + indexes :author + end + + def as_indexed_json(options={}) + # This line is necessary for differences between ActiveModel::Serializers::JSON#as_json versions + json = as_json(options)[JOIN_TYPE] || as_json(options) + json.merge(JOIN_METADATA) + end + + after_commit lambda { __elasticsearch__.index_document }, on: :create + after_commit lambda { __elasticsearch__.update_document }, on: :update + after_commit lambda { __elasticsearch__.delete_document }, on: :destroy +end diff --git a/elasticsearch-model/spec/support/app/searchable.rb b/elasticsearch-model/spec/support/app/searchable.rb new file mode 100644 index 000000000..81ddf78b3 --- /dev/null +++ b/elasticsearch-model/spec/support/app/searchable.rb @@ -0,0 +1,65 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Searchable + extend ActiveSupport::Concern + + included do + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + # Set up the mapping + # + settings index: { number_of_shards: 1, number_of_replicas: 0 } do + mapping do + indexes :title, analyzer: 'snowball' + indexes :created_at, type: 'date' + + indexes :authors do + indexes :first_name + indexes :last_name + indexes :full_name, type: 'text' do + indexes :raw, type: 'keyword' + end + end + + indexes :categories, type: 'keyword' + + indexes :comments, type: 'nested' do + indexes :text + indexes :author + end + end + end + + # Customize the JSON serialization for Elasticsearch + # + def as_indexed_json(options={}) + { + title: title, + text: text, + categories: categories.map(&:title), + authors: authors.as_json(methods: [:full_name], only: [:full_name, :first_name, :last_name]), + comments: comments.as_json(only: [:text, :author]) + } + end + + # Update document in the index after touch + # + after_touch() { __elasticsearch__.index_document } + end +end diff --git a/elasticsearch-model/spec/support/app/series.rb b/elasticsearch-model/spec/support/app/series.rb new file mode 100644 index 000000000..3b4d17989 --- /dev/null +++ b/elasticsearch-model/spec/support/app/series.rb @@ -0,0 +1,28 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +class Series < ActiveRecord::Base + include Elasticsearch::Model + include Elasticsearch::Model::Callbacks + + settings index: {number_of_shards: 1, number_of_replicas: 0} do + mapping do + indexes :name, type: 'text', analyzer: 'snowball' + indexes :created_at, type: 'date' + end + end +end diff --git a/elasticsearch-model/spec/support/model.json b/elasticsearch-model/spec/support/model.json new file mode 100644 index 000000000..0c3e4a388 --- /dev/null +++ b/elasticsearch-model/spec/support/model.json @@ -0,0 +1 @@ +{ "laz": "qux" } diff --git a/elasticsearch-model/spec/support/model.yml b/elasticsearch-model/spec/support/model.yml new file mode 100644 index 000000000..ba8ca60f3 --- /dev/null +++ b/elasticsearch-model/spec/support/model.yml @@ -0,0 +1,2 @@ +baz: + 'qux' diff --git a/elasticsearch-model/test/integration/active_record_associations_parent_child.rb b/elasticsearch-model/test/integration/active_record_associations_parent_child.rb deleted file mode 100644 index 014005a4d..000000000 --- a/elasticsearch-model/test/integration/active_record_associations_parent_child.rb +++ /dev/null @@ -1,138 +0,0 @@ -require 'test_helper' - -class Question < ActiveRecord::Base - include Elasticsearch::Model - - has_many :answers, dependent: :destroy - - index_name 'questions_and_answers' - - mapping do - indexes :title - indexes :text - indexes :author - end - - after_commit lambda { __elasticsearch__.index_document }, on: :create - after_commit lambda { __elasticsearch__.update_document }, on: :update - after_commit lambda { __elasticsearch__.delete_document }, on: :destroy -end - -class Answer < ActiveRecord::Base - include Elasticsearch::Model - - belongs_to :question - - index_name 'questions_and_answers' - - mapping _parent: { type: 'question', required: true } do - indexes :text - indexes :author - end - - after_commit lambda { __elasticsearch__.index_document(parent: question_id) }, on: :create - after_commit lambda { __elasticsearch__.update_document(parent: question_id) }, on: :update - after_commit lambda { __elasticsearch__.delete_document(parent: question_id) }, on: :destroy -end - -module ParentChildSearchable - INDEX_NAME = 'questions_and_answers' - - def create_index!(options={}) - client = Question.__elasticsearch__.client - client.indices.delete index: INDEX_NAME rescue nil if options[:force] - - settings = Question.settings.to_hash.merge Answer.settings.to_hash - mappings = Question.mappings.to_hash.merge Answer.mappings.to_hash - - client.indices.create index: INDEX_NAME, - body: { - settings: settings.to_hash, - mappings: mappings.to_hash } - end - - extend self -end - -module Elasticsearch - module Model - class ActiveRecordAssociationsParentChildIntegrationTest < Elasticsearch::Test::IntegrationTestCase - - context "ActiveRecord associations with parent/child modelling" do - setup do - ActiveRecord::Schema.define(version: 1) do - create_table :questions do |t| - t.string :title - t.text :text - t.string :author - t.timestamps - end - create_table :answers do |t| - t.text :text - t.string :author - t.references :question - t.timestamps - end and add_index(:answers, :question_id) - end - - Question.delete_all - ParentChildSearchable.create_index! force: true - - q_1 = Question.create! title: 'First Question', author: 'John' - q_2 = Question.create! title: 'Second Question', author: 'Jody' - - q_1.answers.create! text: 'Lorem Ipsum', author: 'Adam' - q_1.answers.create! text: 'Dolor Sit', author: 'Ryan' - - q_2.answers.create! text: 'Amet Et', author: 'John' - - Question.__elasticsearch__.refresh_index! - end - - should "find questions by matching answers" do - response = Question.search( - { query: { - has_child: { - type: 'answer', - query: { - match: { - author: 'john' - } - } - } - } - }) - - assert_equal 'Second Question', response.records.first.title - end - - should "find answers for matching questions" do - response = Answer.search( - { query: { - has_parent: { - parent_type: 'question', - query: { - match: { - author: 'john' - } - } - } - } - }) - - assert_same_elements ['Adam', 'Ryan'], response.records.map(&:author) - end - - should "delete answers when the question is deleted" do - Question.where(title: 'First Question').each(&:destroy) - Question.__elasticsearch__.refresh_index! - - response = Answer.search query: { match_all: {} } - - assert_equal 1, response.results.total - end - end - - end - end -end diff --git a/elasticsearch-model/test/integration/active_record_associations_test.rb b/elasticsearch-model/test/integration/active_record_associations_test.rb deleted file mode 100644 index a8ce65599..000000000 --- a/elasticsearch-model/test/integration/active_record_associations_test.rb +++ /dev/null @@ -1,306 +0,0 @@ -require 'test_helper' - -# ----- Models definition ------------------------------------------------------------------------- - -class Category < ActiveRecord::Base - has_and_belongs_to_many :posts -end - -class Author < ActiveRecord::Base - has_many :authorships - - def full_name - [first_name, last_name].compact.join(' ') - end -end - -class Authorship < ActiveRecord::Base - belongs_to :author - belongs_to :post, touch: true -end - -class Comment < ActiveRecord::Base - belongs_to :post, touch: true -end - -class Post < ActiveRecord::Base - has_and_belongs_to_many :categories, after_add: [ lambda { |a,c| a.__elasticsearch__.index_document } ], - after_remove: [ lambda { |a,c| a.__elasticsearch__.index_document } ] - has_many :authorships - has_many :authors, through: :authorships - has_many :comments -end - -# ----- Search integration via Concern module ----------------------------------------------------- - -module Searchable - extend ActiveSupport::Concern - - included do - include Elasticsearch::Model - include Elasticsearch::Model::Callbacks - - # Set up the mapping - # - settings index: { number_of_shards: 1, number_of_replicas: 0 } do - mapping do - indexes :title, analyzer: 'snowball' - indexes :created_at, type: 'date' - - indexes :authors do - indexes :first_name - indexes :last_name - indexes :full_name, type: 'multi_field' do - indexes :full_name - indexes :raw, analyzer: 'keyword' - end - end - - indexes :categories, analyzer: 'keyword' - - indexes :comments, type: 'nested' do - indexes :text - indexes :author - end - end - end - - # Customize the JSON serialization for Elasticsearch - # - def as_indexed_json(options={}) - { - title: title, - text: text, - categories: categories.map(&:title), - authors: authors.as_json(methods: [:full_name], only: [:full_name, :first_name, :last_name]), - comments: comments.as_json(only: [:text, :author]) - } - end - - # Update document in the index after touch - # - after_touch() { __elasticsearch__.index_document } - end -end - -# Include the search integration -# -Post.__send__ :include, Searchable - -module Elasticsearch - module Model - class ActiveRecordAssociationsIntegrationTest < Elasticsearch::Test::IntegrationTestCase - - context "ActiveRecord associations" do - setup do - - # ----- Schema definition --------------------------------------------------------------- - - ActiveRecord::Schema.define(version: 1) do - create_table :categories do |t| - t.string :title - t.timestamps - end - - create_table :categories_posts, id: false do |t| - t.references :post, :category - end - - create_table :authors do |t| - t.string :first_name, :last_name - t.timestamps - end - - create_table :authorships do |t| - t.string :first_name, :last_name - t.references :post - t.references :author - t.timestamps - end - - create_table :comments do |t| - t.string :text - t.string :author - t.references :post - t.timestamps - end and add_index(:comments, :post_id) - - create_table :posts do |t| - t.string :title - t.text :text - t.boolean :published - t.timestamps - end - end - - # ----- Reset the index ----------------------------------------------------------------- - - Post.delete_all - Post.__elasticsearch__.create_index! force: true - end - - should "index and find a document" do - Post.create! title: 'Test' - Post.create! title: 'Testing Coding' - Post.create! title: 'Coding' - Post.__elasticsearch__.refresh_index! - - response = Post.search('title:test') - - assert_equal 2, response.results.size - assert_equal 2, response.records.size - - assert_equal 'Test', response.results.first.title - assert_equal 'Test', response.records.first.title - end - - should "reindex a document after categories are changed" do - # Create categories - category_a = Category.where(title: "One").first_or_create! - category_b = Category.where(title: "Two").first_or_create! - - # Create post - post = Post.create! title: "First Post", text: "This is the first post..." - - # Assign categories - post.categories = [category_a, category_b] - - Post.__elasticsearch__.refresh_index! - - query = { query: { - filtered: { - query: { - multi_match: { - fields: ['title'], - query: 'first' - } - }, - filter: { - terms: { - categories: ['One'] - } - } - } - } - } - - response = Post.search query - - assert_equal 1, response.results.size - assert_equal 1, response.records.size - - # Remove category "One" - post.categories = [category_b] - - Post.__elasticsearch__.refresh_index! - response = Post.search query - - assert_equal 0, response.results.size - assert_equal 0, response.records.size - end - - should "reindex a document after authors are changed" do - # Create authors - author_a = Author.where(first_name: "John", last_name: "Smith").first_or_create! - author_b = Author.where(first_name: "Mary", last_name: "Smith").first_or_create! - author_c = Author.where(first_name: "Kobe", last_name: "Griss").first_or_create! - - # Create posts - post_1 = Post.create! title: "First Post", text: "This is the first post..." - post_2 = Post.create! title: "Second Post", text: "This is the second post..." - post_3 = Post.create! title: "Third Post", text: "This is the third post..." - - # Assign authors - post_1.authors = [author_a, author_b] - post_2.authors = [author_a] - post_3.authors = [author_c] - - Post.__elasticsearch__.refresh_index! - - response = Post.search 'authors.full_name:john' - - assert_equal 2, response.results.size - assert_equal 2, response.records.size - - post_3.authors << author_a - - Post.__elasticsearch__.refresh_index! - - response = Post.search 'authors.full_name:john' - - assert_equal 3, response.results.size - assert_equal 3, response.records.size - end if defined?(::ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 4 - - should "reindex a document after comments are added" do - # Create posts - post_1 = Post.create! title: "First Post", text: "This is the first post..." - post_2 = Post.create! title: "Second Post", text: "This is the second post..." - - # Add comments - post_1.comments.create! author: 'John', text: 'Excellent' - post_1.comments.create! author: 'Abby', text: 'Good' - - post_2.comments.create! author: 'John', text: 'Terrible' - - Post.__elasticsearch__.refresh_index! - - response = Post.search 'comments.author:john AND comments.text:good' - assert_equal 0, response.results.size - - # Add comment - post_1.comments.create! author: 'John', text: 'Or rather just good...' - - Post.__elasticsearch__.refresh_index! - - response = Post.search 'comments.author:john AND comments.text:good' - assert_equal 0, response.results.size - - response = Post.search \ - query: { - nested: { - path: 'comments', - query: { - bool: { - must: [ - { match: { 'comments.author' => 'john' } }, - { match: { 'comments.text' => 'good' } } - ] - } - } - } - } - - assert_equal 1, response.results.size - end if defined?(::ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 4 - - should "reindex a document after Post#touch" do - # Create categories - category_a = Category.where(title: "One").first_or_create! - - # Create post - post = Post.create! title: "First Post", text: "This is the first post..." - - # Assign category - post.categories << category_a - - Post.__elasticsearch__.refresh_index! - - assert_equal 1, Post.search('categories:One').size - - # Update category - category_a.update_attribute :title, "Updated" - - # Trigger touch on posts in category - category_a.posts.each { |p| p.touch } - - Post.__elasticsearch__.refresh_index! - - assert_equal 0, Post.search('categories:One').size - assert_equal 1, Post.search('categories:Updated').size - end - end - - end - end -end diff --git a/elasticsearch-model/test/integration/active_record_basic_test.rb b/elasticsearch-model/test/integration/active_record_basic_test.rb deleted file mode 100644 index f17d9807e..000000000 --- a/elasticsearch-model/test/integration/active_record_basic_test.rb +++ /dev/null @@ -1,139 +0,0 @@ -require 'test_helper' - -puts "ActiveRecord #{ActiveRecord::VERSION::STRING}", '-'*80 - -module Elasticsearch - module Model - class ActiveRecordBasicIntegrationTest < Elasticsearch::Test::IntegrationTestCase - - class ::Article < ActiveRecord::Base - include Elasticsearch::Model - include Elasticsearch::Model::Callbacks - - settings index: { number_of_shards: 1, number_of_replicas: 0 } do - mapping do - indexes :title, type: 'string', analyzer: 'snowball' - indexes :created_at, type: 'date' - end - end - end - - context "ActiveRecord basic integration" do - setup do - ActiveRecord::Schema.define(:version => 1) do - create_table :articles do |t| - t.string :title - t.datetime :created_at, :default => 'NOW()' - end - end - - Article.delete_all - Article.__elasticsearch__.create_index! force: true - - ::Article.create! title: 'Test' - ::Article.create! title: 'Testing Coding' - ::Article.create! title: 'Coding' - - Article.__elasticsearch__.refresh_index! - end - - should "index and find a document" do - response = Article.search('title:test') - - assert response.any?, "Response should not be empty: #{response.to_a.inspect}" - - assert_equal 2, response.results.size - assert_equal 2, response.records.size - - assert_instance_of Elasticsearch::Model::Response::Result, response.results.first - assert_instance_of Article, response.records.first - - assert_equal 'Test', response.results.first.title - assert_equal 'Test', response.records.first.title - end - - should "iterate over results" do - response = Article.search('title:test') - - assert_equal ['1', '2'], response.results.map(&:_id) - assert_equal [1, 2], response.records.map(&:id) - end - - should "access results from records" do - response = Article.search('title:test') - - response.records.each_with_hit do |r, h| - assert_not_nil h._score - assert_not_nil h._source.title - end - end - - should "remove document from index on destroy" do - article = Article.first - - article.destroy - assert_equal 2, Article.count - - Article.__elasticsearch__.refresh_index! - - response = Article.search 'title:test' - - assert_equal 1, response.results.size - assert_equal 1, response.records.size - end - - should "index updates to the document" do - article = Article.first - - article.title = 'Writing' - article.save - - Article.__elasticsearch__.refresh_index! - - response = Article.search 'title:write' - - assert_equal 1, response.results.size - assert_equal 1, response.records.size - end - - should "return results for a DSL search" do - response = Article.search query: { match: { title: { query: 'test' } } } - - assert_equal 2, response.results.size - assert_equal 2, response.records.size - end - - should "return a paged collection" do - response = Article.search query: { match: { title: { query: 'test' } } }, - size: 2, - from: 1 - - assert_equal 1, response.results.size - assert_equal 1, response.records.size - - assert_equal 'Testing Coding', response.results.first.title - assert_equal 'Testing Coding', response.records.first.title - end - - should "allow chaining SQL commands on response.records" do - response = Article.search query: { match: { title: { query: 'test' } } } - - assert_equal 2, response.records.size - assert_equal 1, response.records.where(title: 'Test').size - assert_equal 'Test', response.records.where(title: 'Test').first.title - end - - should "allow ordering response.records in SQL" do - response = Article.search query: { match: { title: { query: 'test' } } } - - if defined?(::ActiveRecord) && ::ActiveRecord::VERSION::MAJOR >= 4 - assert_equal 'Testing Coding', response.records.order(title: :desc).first.title - else - assert_equal 'Testing Coding', response.records.order('title DESC').first.title - end - end - end - - end - end -end diff --git a/elasticsearch-model/test/integration/active_record_import_test.rb b/elasticsearch-model/test/integration/active_record_import_test.rb deleted file mode 100644 index 3c1f7f19a..000000000 --- a/elasticsearch-model/test/integration/active_record_import_test.rb +++ /dev/null @@ -1,74 +0,0 @@ -require 'test_helper' - -module Elasticsearch - module Model - class ActiveRecordImportIntegrationTest < Elasticsearch::Test::IntegrationTestCase - - class ::ImportArticle < ActiveRecord::Base - include Elasticsearch::Model - - mapping do - indexes :title, type: 'string' - indexes :views, type: 'integer' - indexes :created_at, type: 'date' - end - end - - context "ActiveRecord importing" do - setup do - ActiveRecord::Schema.define(:version => 1) do - create_table :import_articles do |t| - t.string :title - t.string :views # For the sake of invalid data sent to Elasticsearch - t.datetime :created_at, :default => 'NOW()' - end - end - - ImportArticle.delete_all - ImportArticle.__elasticsearch__.create_index! force: true - - 100.times { |i| ImportArticle.create! title: "Test #{i}" } - end - - should "import all the documents" do - assert_equal 100, ImportArticle.count - - ImportArticle.__elasticsearch__.refresh_index! - assert_equal 0, ImportArticle.search('*').results.total - - batches = 0 - errors = ImportArticle.import(batch_size: 10) do |response| - batches += 1 - end - - assert_equal 0, errors - assert_equal 10, batches - - ImportArticle.__elasticsearch__.refresh_index! - assert_equal 100, ImportArticle.search('*').results.total - end - - should "report and not store/index invalid documents" do - ImportArticle.create! title: "Test INVALID", views: "INVALID" - - assert_equal 101, ImportArticle.count - - ImportArticle.__elasticsearch__.refresh_index! - assert_equal 0, ImportArticle.search('*').results.total - - batches = 0 - errors = ImportArticle.__elasticsearch__.import(batch_size: 10) do |response| - batches += 1 - end - - assert_equal 1, errors - assert_equal 11, batches - - ImportArticle.__elasticsearch__.refresh_index! - assert_equal 100, ImportArticle.search('*').results.total - end - end - - end - end -end diff --git a/elasticsearch-model/test/integration/active_record_namespaced_model_test.rb b/elasticsearch-model/test/integration/active_record_namespaced_model_test.rb deleted file mode 100644 index bca1ff7a9..000000000 --- a/elasticsearch-model/test/integration/active_record_namespaced_model_test.rb +++ /dev/null @@ -1,49 +0,0 @@ -require 'test_helper' - -module Elasticsearch - module Model - class ActiveRecordNamespacedModelIntegrationTest < Elasticsearch::Test::IntegrationTestCase - - module ::MyNamespace - class Article < ActiveRecord::Base - include Elasticsearch::Model - include Elasticsearch::Model::Callbacks - - mapping { indexes :title } - end - end - - context "Namespaced ActiveRecord model integration" do - setup do - ActiveRecord::Schema.define(:version => 1) do - create_table :articles do |t| - t.string :title - end - end - - MyNamespace::Article.delete_all - MyNamespace::Article.__elasticsearch__.create_index! force: true - - MyNamespace::Article.create! title: 'Test' - - MyNamespace::Article.__elasticsearch__.refresh_index! - end - - should "have proper index name and document type" do - assert_equal "my_namespace-articles", MyNamespace::Article.index_name - assert_equal "article", MyNamespace::Article.document_type - end - - should "save document into index on save and find it" do - response = MyNamespace::Article.search 'title:test' - - assert response.any?, "No results returned: #{response.inspect}" - assert_equal 1, response.size - - assert_equal 'Test', response.results.first.title - end - end - - end - end -end diff --git a/elasticsearch-model/test/integration/active_record_pagination_test.rb b/elasticsearch-model/test/integration/active_record_pagination_test.rb deleted file mode 100644 index 51c79d468..000000000 --- a/elasticsearch-model/test/integration/active_record_pagination_test.rb +++ /dev/null @@ -1,109 +0,0 @@ -require 'test_helper' - -module Elasticsearch - module Model - class ActiveRecordPaginationTest < Elasticsearch::Test::IntegrationTestCase - - class ::Article < ActiveRecord::Base - include Elasticsearch::Model - - settings index: { number_of_shards: 1, number_of_replicas: 0 } do - mapping do - indexes :title, type: 'string', analyzer: 'snowball' - indexes :created_at, type: 'date' - end - end - end - - Kaminari::Hooks.init - - context "ActiveRecord pagination" do - setup do - ActiveRecord::Schema.define(:version => 1) do - create_table :articles do |t| - t.string :title - t.datetime :created_at, :default => 'NOW()' - end - end - - Article.delete_all - Article.__elasticsearch__.create_index! force: true - - 68.times do |i| ::Article.create! title: "Test #{i}" end - - Article.import - Article.__elasticsearch__.refresh_index! - end - - should "be on the first page by default" do - records = Article.search('title:test').page(1).records - - assert_equal 25, records.size - assert_equal 1, records.current_page - assert_equal nil, records.prev_page - assert_equal 2, records.next_page - assert_equal 3, records.total_pages - - assert records.first_page?, "Should be the first page" - assert ! records.last_page?, "Should NOT be the last page" - assert ! records.out_of_range?, "Should NOT be out of range" - end - - should "load next page" do - records = Article.search('title:test').page(2).records - - assert_equal 25, records.size - assert_equal 2, records.current_page - assert_equal 1, records.prev_page - assert_equal 3, records.next_page - assert_equal 3, records.total_pages - - assert ! records.first_page?, "Should NOT be the first page" - assert ! records.last_page?, "Should NOT be the last page" - assert ! records.out_of_range?, "Should NOT be out of range" - end - - should "load last page" do - records = Article.search('title:test').page(3).records - - assert_equal 18, records.size - assert_equal 3, records.current_page - assert_equal 2, records.prev_page - assert_equal nil, records.next_page - assert_equal 3, records.total_pages - - assert ! records.first_page?, "Should NOT be the first page" - assert records.last_page?, "Should be the last page" - assert ! records.out_of_range?, "Should NOT be out of range" - end - - should "not load invalid page" do - records = Article.search('title:test').page(6).records - - assert_equal 0, records.size - assert_equal 6, records.current_page - assert_equal 5, records.prev_page - assert_equal nil, records.next_page - assert_equal 3, records.total_pages - - assert ! records.first_page?, "Should NOT be the first page" - assert records.last_page?, "Should be the last page" - assert records.out_of_range?, "Should be out of range" - end - - context "with specific model settings" do - teardown do - Article.instance_variable_set(:@_default_per_page, nil) - end - end - - should "respect paginates_per" do - Article.paginates_per 50 - - assert_equal 50, Article.search('*').page(1).records.size - end - end - - end - end -end diff --git a/elasticsearch-model/test/integration/mongoid_basic_test.rb b/elasticsearch-model/test/integration/mongoid_basic_test.rb deleted file mode 100644 index b95947e2f..000000000 --- a/elasticsearch-model/test/integration/mongoid_basic_test.rb +++ /dev/null @@ -1,179 +0,0 @@ -require 'test_helper' - -begin - require 'mongoid' - session = Moped::Connection.new("localhost", 27017, 0.5) - session.connect - ENV["MONGODB_AVAILABLE"] = 'yes' -rescue LoadError, Moped::Errors::ConnectionFailure => e - $stderr.puts "MongoDB not installed or running: #{e}" -end - -if ENV["MONGODB_AVAILABLE"] - $stderr.puts "Mongoid #{Mongoid::VERSION}", '-'*80 - - logger = ::Logger.new($stderr) - logger.formatter = lambda { |s, d, p, m| " #{m.ansi(:faint, :cyan)}\n" } - logger.level = ::Logger::DEBUG - - Mongoid.logger = logger unless ENV['QUIET'] - Moped.logger = logger unless ENV['QUIET'] - - Mongoid.connect_to 'mongoid_articles' - - module Elasticsearch - module Model - class MongoidBasicIntegrationTest < Elasticsearch::Test::IntegrationTestCase - - class ::MongoidArticle - include Mongoid::Document - include Elasticsearch::Model - include Elasticsearch::Model::Callbacks - - field :id, type: String - field :title, type: String - attr_accessible :title if respond_to? :attr_accessible - - settings index: { number_of_shards: 1, number_of_replicas: 0 } do - mapping do - indexes :title, type: 'string', analyzer: 'snowball' - indexes :created_at, type: 'date' - end - end - - def as_indexed_json(options={}) - as_json(except: [:id, :_id]) - end - end - - context "Mongoid integration" do - setup do - Elasticsearch::Model::Adapter.register \ - Elasticsearch::Model::Adapter::Mongoid, - lambda { |klass| !!defined?(::Mongoid::Document) && klass.ancestors.include?(::Mongoid::Document) } - - MongoidArticle.__elasticsearch__.create_index! force: true - - MongoidArticle.delete_all - - MongoidArticle.create! title: 'Test' - MongoidArticle.create! title: 'Testing Coding' - MongoidArticle.create! title: 'Coding' - - MongoidArticle.__elasticsearch__.refresh_index! - end - - should "index and find a document" do - response = MongoidArticle.search('title:test') - - assert response.any? - - assert_equal 2, response.results.size - assert_equal 2, response.records.size - - assert_instance_of Elasticsearch::Model::Response::Result, response.results.first - assert_instance_of MongoidArticle, response.records.first - - assert_equal 'Test', response.results.first.title - assert_equal 'Test', response.records.first.title - end - - should "iterate over results" do - response = MongoidArticle.search('title:test') - - assert_equal ['Test', 'Testing Coding'], response.results.map(&:title) - assert_equal ['Test', 'Testing Coding'], response.records.map(&:title) - end - - should "access results from records" do - response = MongoidArticle.search('title:test') - - response.records.each_with_hit do |r, h| - assert_not_nil h._score - assert_not_nil h._source.title - end - end - - should "remove document from index on destroy" do - article = MongoidArticle.first - - article.destroy - assert_equal 2, MongoidArticle.count - - MongoidArticle.__elasticsearch__.refresh_index! - - response = MongoidArticle.search 'title:test' - - assert_equal 1, response.results.size - assert_equal 1, response.records.size - end - - should "index updates to the document" do - article = MongoidArticle.first - - article.title = 'Writing' - article.save - - MongoidArticle.__elasticsearch__.refresh_index! - - response = MongoidArticle.search 'title:write' - - assert_equal 1, response.results.size - assert_equal 1, response.records.size - end - - should "return results for a DSL search" do - response = MongoidArticle.search query: { match: { title: { query: 'test' } } } - - assert_equal 2, response.results.size - assert_equal 2, response.records.size - end - - should "return a paged collection" do - response = MongoidArticle.search query: { match: { title: { query: 'test' } } }, - size: 2, - from: 1 - - assert_equal 1, response.results.size - assert_equal 1, response.records.size - - assert_equal 'Testing Coding', response.results.first.title - assert_equal 'Testing Coding', response.records.first.title - end - - - context "importing" do - setup do - MongoidArticle.delete_all - 97.times { |i| MongoidArticle.create! title: "Test #{i}" } - MongoidArticle.__elasticsearch__.create_index! force: true - end - - should "import all the documents" do - assert_equal 97, MongoidArticle.count - - MongoidArticle.__elasticsearch__.refresh_index! - assert_equal 0, MongoidArticle.search('*').results.total - - batches = 0 - errors = MongoidArticle.import(batch_size: 10) do |response| - batches += 1 - end - - assert_equal 0, errors - assert_equal 10, batches - - MongoidArticle.__elasticsearch__.refresh_index! - assert_equal 97, MongoidArticle.search('*').results.total - - response = MongoidArticle.search('test') - assert response.results.any?, "Search has not returned results: #{response.to_a}" - end - end - end - - end - end - end - -end diff --git a/elasticsearch-model/test/test_helper.rb b/elasticsearch-model/test/test_helper.rb deleted file mode 100644 index dec2efb8b..000000000 --- a/elasticsearch-model/test/test_helper.rb +++ /dev/null @@ -1,57 +0,0 @@ -RUBY_1_8 = defined?(RUBY_VERSION) && RUBY_VERSION < '1.9' - -exit(0) if RUBY_1_8 - -require 'simplecov' and SimpleCov.start { add_filter "/test|test_/" } if ENV["COVERAGE"] - -# Register `at_exit` handler for integration tests shutdown. -# MUST be called before requiring `test/unit`. -at_exit { Elasticsearch::Test::IntegrationTestCase.__run_at_exit_hooks } - -puts '-'*80 - -require 'test/unit' -require 'shoulda-context' -require 'mocha/setup' -require 'turn' unless ENV["TM_FILEPATH"] || ENV["NOTURN"] || RUBY_1_8 - -require 'ansi' -require 'oj' - -require 'active_record' -require 'active_model' - -require 'kaminari' - -require 'elasticsearch/model' - -require 'elasticsearch/extensions/test/cluster' -require 'elasticsearch/extensions/test/startup_shutdown' - -module Elasticsearch - module Test - class IntegrationTestCase < ::Test::Unit::TestCase - extend Elasticsearch::Extensions::Test::StartupShutdown - - startup { Elasticsearch::Extensions::Test::Cluster.start(nodes: 1) if ENV['SERVER'] and not Elasticsearch::Extensions::Test::Cluster.running? } - shutdown { Elasticsearch::Extensions::Test::Cluster.stop if ENV['SERVER'] && started? } - context "IntegrationTest" do; should "noop on Ruby 1.8" do; end; end if RUBY_1_8 - - def setup - ActiveRecord::Base.establish_connection( :adapter => 'sqlite3', :database => ":memory:" ) - logger = ::Logger.new(STDERR) - logger.formatter = lambda { |s, d, p, m| "#{m.ansi(:faint, :cyan)}\n" } - ActiveRecord::Base.logger = logger unless ENV['QUIET'] - - ActiveRecord::LogSubscriber.colorize_logging = false - ActiveRecord::Migration.verbose = false - - tracer = ::Logger.new(STDERR) - tracer.formatter = lambda { |s, d, p, m| "#{m.gsub(/^.*$/) { |n| ' ' + n }.ansi(:faint)}\n" } - - Elasticsearch::Model.client = Elasticsearch::Client.new host: "localhost:#{(ENV['TEST_CLUSTER_PORT'] || 9250)}", - tracer: (ENV['QUIET'] ? nil : tracer) - end - end - end -end diff --git a/elasticsearch-model/test/unit/adapter_active_record_test.rb b/elasticsearch-model/test/unit/adapter_active_record_test.rb deleted file mode 100644 index 80df07d71..000000000 --- a/elasticsearch-model/test/unit/adapter_active_record_test.rb +++ /dev/null @@ -1,94 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::AdapterActiveRecordTest < Test::Unit::TestCase - context "Adapter ActiveRecord module: " do - class ::DummyClassForActiveRecord - RESPONSE = Struct.new('DummyActiveRecordResponse') do - def response - { 'hits' => {'hits' => [ {'_id' => 2}, {'_id' => 1} ]} } - end - end.new - - def response - RESPONSE - end - - def ids - [2, 1] - end - end - - RESPONSE = { 'hits' => { 'total' => 123, 'max_score' => 456, 'hits' => [] } } - - setup do - @records = [ stub(id: 1, inspect: ''), stub(id: 2, inspect: '') ] - @records.stubs(:load).returns(true) - @records.stubs(:exec_queries).returns(true) - end - - should "have the register condition" do - assert_not_nil Elasticsearch::Model::Adapter.adapters[Elasticsearch::Model::Adapter::ActiveRecord] - assert_equal false, Elasticsearch::Model::Adapter.adapters[Elasticsearch::Model::Adapter::ActiveRecord].call(DummyClassForActiveRecord) - end - - context "Records" do - setup do - DummyClassForActiveRecord.__send__ :include, Elasticsearch::Model::Adapter::ActiveRecord::Records - end - - should "have the implementation" do - assert_instance_of Module, Elasticsearch::Model::Adapter::ActiveRecord::Records - - instance = DummyClassForActiveRecord.new - instance.expects(:klass).returns(mock('class', primary_key: :some_key, where: @records)).at_least_once - - assert_equal @records, instance.records - end - - should "load the records" do - instance = DummyClassForActiveRecord.new - instance.expects(:records).returns(@records) - instance.load - end - - should "reorder the records based on hits order" do - @records.instance_variable_set(:@records, @records) - - instance = DummyClassForActiveRecord.new - instance.expects(:klass).returns(mock('class', primary_key: :some_key, where: @records)).at_least_once - - assert_equal [1, 2], @records. to_a.map(&:id) - assert_equal [2, 1], instance.records.to_a.map(&:id) - end - - should "not reorder records when SQL order is present" do - @records.instance_variable_set(:@records, @records) - - instance = DummyClassForActiveRecord.new - instance.expects(:klass).returns(stub('class', primary_key: :some_key, where: @records)).at_least_once - instance.records.expects(:order).returns(@records) - - assert_equal [2, 1], instance.records. to_a.map(&:id) - assert_equal [1, 2], instance.order(:foo).to_a.map(&:id) - end - end - - context "Callbacks" do - should "register hooks for automatically updating the index" do - DummyClassForActiveRecord.expects(:after_commit).times(3) - - Elasticsearch::Model::Adapter::ActiveRecord::Callbacks.included(DummyClassForActiveRecord) - end - end - - context "Importing" do - should "implement the __find_in_batches method" do - DummyClassForActiveRecord.expects(:find_in_batches).returns([]) - - DummyClassForActiveRecord.__send__ :extend, Elasticsearch::Model::Adapter::ActiveRecord::Importing - DummyClassForActiveRecord.__find_in_batches do; end - end - end - - end -end diff --git a/elasticsearch-model/test/unit/adapter_default_test.rb b/elasticsearch-model/test/unit/adapter_default_test.rb deleted file mode 100644 index 161fef9cc..000000000 --- a/elasticsearch-model/test/unit/adapter_default_test.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::AdapterDefaultTest < Test::Unit::TestCase - context "Adapter default module" do - class ::DummyClassForDefaultAdapter; end - - should "have the default Records implementation" do - assert_instance_of Module, Elasticsearch::Model::Adapter::Default::Records - - DummyClassForDefaultAdapter.__send__ :include, Elasticsearch::Model::Adapter::Default::Records - - instance = DummyClassForDefaultAdapter.new - klass = mock('class', find: [1]) - instance.expects(:klass).returns(klass) - instance.records - end - - should "have the default Callbacks implementation" do - assert_instance_of Module, Elasticsearch::Model::Adapter::Default::Callbacks - end - - should "have the default Importing implementation" do - DummyClassForDefaultAdapter.__send__ :include, Elasticsearch::Model::Adapter::Default::Importing - - assert_raise Elasticsearch::Model::NotImplemented do - DummyClassForDefaultAdapter.new.__find_in_batches - end - end - - end -end diff --git a/elasticsearch-model/test/unit/adapter_mongoid_test.rb b/elasticsearch-model/test/unit/adapter_mongoid_test.rb deleted file mode 100644 index 945f4e33f..000000000 --- a/elasticsearch-model/test/unit/adapter_mongoid_test.rb +++ /dev/null @@ -1,87 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::AdapterMongoidTest < Test::Unit::TestCase - context "Adapter Mongoid module: " do - class ::DummyClassForMongoid - RESPONSE = Struct.new('DummyMongoidResponse') do - def response - { 'hits' => {'hits' => [ {'_id' => 2}, {'_id' => 1} ]} } - end - end.new - - def response - RESPONSE - end - - def ids - [2, 1] - end - end - - setup do - @records = [ stub(id: 1, inspect: ''), stub(id: 2, inspect: '') ] - ::Symbol.any_instance.stubs(:in).returns(@records) - end - - should "have the register condition" do - assert_not_nil Elasticsearch::Model::Adapter.adapters[Elasticsearch::Model::Adapter::Mongoid] - assert_equal false, Elasticsearch::Model::Adapter.adapters[Elasticsearch::Model::Adapter::Mongoid].call(DummyClassForMongoid) - end - - context "Records" do - setup do - DummyClassForMongoid.__send__ :include, Elasticsearch::Model::Adapter::Mongoid::Records - end - - should "have the implementation" do - assert_instance_of Module, Elasticsearch::Model::Adapter::Mongoid::Records - - instance = DummyClassForMongoid.new - instance.expects(:klass).returns(mock('class', where: @records)) - - assert_equal @records, instance.records - end - - should "reorder the records based on hits order" do - @records.instance_variable_set(:@records, @records) - - instance = DummyClassForMongoid.new - instance.expects(:klass).returns(mock('class', where: @records)) - - assert_equal [1, 2], @records. to_a.map(&:id) - assert_equal [2, 1], instance.records.to_a.map(&:id) - end - - should "not reorder records when SQL order is present" do - @records.instance_variable_set(:@records, @records) - - instance = DummyClassForMongoid.new - instance.expects(:klass).returns(stub('class', where: @records)).at_least_once - instance.records.expects(:asc).returns(@records) - - assert_equal [2, 1], instance.records.to_a.map(&:id) - assert_equal [1, 2], instance.asc.to_a.map(&:id) - end - end - - context "Callbacks" do - should "register hooks for automatically updating the index" do - DummyClassForMongoid.expects(:after_create) - DummyClassForMongoid.expects(:after_update) - DummyClassForMongoid.expects(:after_destroy) - - Elasticsearch::Model::Adapter::Mongoid::Callbacks.included(DummyClassForMongoid) - end - end - - context "Importing" do - should "implement the __find_in_batches method" do - DummyClassForMongoid.expects(:all).returns([]) - - DummyClassForMongoid.__send__ :extend, Elasticsearch::Model::Adapter::Mongoid::Importing - DummyClassForMongoid.__find_in_batches do; end - end - end - - end -end diff --git a/elasticsearch-model/test/unit/adapter_test.rb b/elasticsearch-model/test/unit/adapter_test.rb deleted file mode 100644 index 71b4e7cea..000000000 --- a/elasticsearch-model/test/unit/adapter_test.rb +++ /dev/null @@ -1,69 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::AdapterTest < Test::Unit::TestCase - context "Adapter module" do - class ::DummyAdapterClass; end - class ::DummyAdapterClassWithAdapter; end - class ::DummyAdapter - Records = Module.new - Callbacks = Module.new - Importing = Module.new - end - - should "return an Adapter instance" do - assert_instance_of Elasticsearch::Model::Adapter::Adapter, - Elasticsearch::Model::Adapter.from_class(DummyAdapterClass) - end - - should "return a list of adapters" do - Elasticsearch::Model::Adapter::Adapter.expects(:adapters) - Elasticsearch::Model::Adapter.adapters - end - - should "register an adapter" do - begin - Elasticsearch::Model::Adapter::Adapter.expects(:register) - Elasticsearch::Model::Adapter.register(:foo, lambda { |c| false }) - ensure - Elasticsearch::Model::Adapter::Adapter.instance_variable_set(:@adapters, {}) - end - end - end - - context "Adapter class" do - should "register an adapter" do - begin - Elasticsearch::Model::Adapter::Adapter.register(:foo, lambda { |c| false }) - assert Elasticsearch::Model::Adapter::Adapter.adapters[:foo] - ensure - Elasticsearch::Model::Adapter::Adapter.instance_variable_set(:@adapters, {}) - end - end - - should "return the default adapter" do - adapter = Elasticsearch::Model::Adapter::Adapter.new(DummyAdapterClass) - assert_equal Elasticsearch::Model::Adapter::Default, adapter.adapter - end - - should "return a specific adapter" do - Elasticsearch::Model::Adapter::Adapter.register(DummyAdapter, - lambda { |c| c == DummyAdapterClassWithAdapter }) - - adapter = Elasticsearch::Model::Adapter::Adapter.new(DummyAdapterClassWithAdapter) - assert_equal DummyAdapter, adapter.adapter - end - - should "return the modules" do - assert_nothing_raised do - Elasticsearch::Model::Adapter::Adapter.register(DummyAdapter, - lambda { |c| c == DummyAdapterClassWithAdapter }) - - adapter = Elasticsearch::Model::Adapter::Adapter.new(DummyAdapterClassWithAdapter) - - assert_instance_of Module, adapter.records_mixin - assert_instance_of Module, adapter.callbacks_mixin - assert_instance_of Module, adapter.importing_mixin - end - end - end -end diff --git a/elasticsearch-model/test/unit/callbacks_test.rb b/elasticsearch-model/test/unit/callbacks_test.rb deleted file mode 100644 index 2aab6b5b7..000000000 --- a/elasticsearch-model/test/unit/callbacks_test.rb +++ /dev/null @@ -1,30 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::CallbacksTest < Test::Unit::TestCase - context "Callbacks module" do - class ::DummyCallbacksModel - end - - module DummyCallbacksAdapter - module CallbacksMixin - end - - def callbacks_mixin - CallbacksMixin - end; module_function :callbacks_mixin - end - - should "include the callbacks mixin from adapter" do - Elasticsearch::Model::Adapter.expects(:from_class) - .with(DummyCallbacksModel) - .returns(DummyCallbacksAdapter) - - ::DummyCallbacksModel.expects(:__send__).with do |method, parameter| - assert_equal :include, method - assert_equal DummyCallbacksAdapter::CallbacksMixin, parameter - end - - Elasticsearch::Model::Callbacks.included(DummyCallbacksModel) - end - end -end diff --git a/elasticsearch-model/test/unit/client_test.rb b/elasticsearch-model/test/unit/client_test.rb deleted file mode 100644 index 315a3ab44..000000000 --- a/elasticsearch-model/test/unit/client_test.rb +++ /dev/null @@ -1,27 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::ClientTest < Test::Unit::TestCase - context "Client module" do - class ::DummyClientModel - extend Elasticsearch::Model::Client::ClassMethods - include Elasticsearch::Model::Client::InstanceMethods - end - - should "have the default client method" do - assert_instance_of Elasticsearch::Transport::Client, DummyClientModel.client - assert_instance_of Elasticsearch::Transport::Client, DummyClientModel.new.client - end - - should "set the client for the model" do - DummyClientModel.client = 'foobar' - assert_equal 'foobar', DummyClientModel.client - assert_equal 'foobar', DummyClientModel.new.client - end - - should "set the client for a model instance" do - instance = DummyClientModel.new - instance.client = 'moobam' - assert_equal 'moobam', instance.client - end - end -end diff --git a/elasticsearch-model/test/unit/importing_test.rb b/elasticsearch-model/test/unit/importing_test.rb deleted file mode 100644 index 4d84c89be..000000000 --- a/elasticsearch-model/test/unit/importing_test.rb +++ /dev/null @@ -1,123 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::ImportingTest < Test::Unit::TestCase - context "Importing module" do - class ::DummyImportingModel - end - - module ::DummyImportingAdapter - module ImportingMixin - def __find_in_batches(options={}, &block) - yield if block_given? - end - end - - def importing_mixin - ImportingMixin - end; module_function :importing_mixin - end - - should "include methods from the module and adapter" do - Elasticsearch::Model::Adapter.expects(:from_class) - .with(DummyImportingModel) - .returns(DummyImportingAdapter) - - DummyImportingModel.__send__ :include, Elasticsearch::Model::Importing - - assert_respond_to DummyImportingModel, :import - assert_respond_to DummyImportingModel, :__find_in_batches - end - - should "call the client when importing" do - Elasticsearch::Model::Adapter.expects(:from_class) - .with(DummyImportingModel) - .returns(DummyImportingAdapter) - - DummyImportingModel.__send__ :include, Elasticsearch::Model::Importing - - client = mock('client') - client.expects(:bulk).returns({'items' => []}) - - DummyImportingModel.expects(:client).returns(client) - DummyImportingModel.expects(:index_name).returns('foo') - DummyImportingModel.expects(:document_type).returns('foo') - - assert_equal 0, DummyImportingModel.import - end - - should "return number of errors" do - Elasticsearch::Model::Adapter.expects(:from_class) - .with(DummyImportingModel) - .returns(DummyImportingAdapter) - - DummyImportingModel.__send__ :include, Elasticsearch::Model::Importing - - client = mock('client') - client.expects(:bulk).returns({'items' => [ {'index' => {}}, {'index' => {'error' => 'FAILED'}} ]}) - - DummyImportingModel.stubs(:client).returns(client) - DummyImportingModel.stubs(:index_name).returns('foo') - DummyImportingModel.stubs(:document_type).returns('foo') - - assert_equal 1, DummyImportingModel.import - end - - should "yield the response" do - Elasticsearch::Model::Adapter.expects(:from_class) - .with(DummyImportingModel) - .returns(DummyImportingAdapter) - - DummyImportingModel.__send__ :include, Elasticsearch::Model::Importing - - client = mock('client') - client.expects(:bulk).returns({'items' => [ {'index' => {}}, {'index' => {'error' => 'FAILED'}} ]}) - - DummyImportingModel.stubs(:client).returns(client) - DummyImportingModel.stubs(:index_name).returns('foo') - DummyImportingModel.stubs(:document_type).returns('foo') - - DummyImportingModel.import do |response| - assert_equal 2, response['items'].size - end - end - - should "delete and create the index with the force option" do - DummyImportingModel.expects(:__find_in_batches).with do |options| - assert_equal 'bar', options[:foo] - assert_nil options[:force] - end - - DummyImportingModel.expects(:create_index!).with do |options| - assert_equal true, options[:force] - end - - DummyImportingModel.expects(:index_name).returns('foo') - DummyImportingModel.expects(:document_type).returns('foo') - - DummyImportingModel.import force: true, foo: 'bar' - end - - should "allow passing a different index / type" do - Elasticsearch::Model::Adapter.expects(:from_class) - .with(DummyImportingModel) - .returns(DummyImportingAdapter) - - DummyImportingModel.__send__ :include, Elasticsearch::Model::Importing - - client = mock('client') - - client - .expects(:bulk) - .with do |options| - assert_equal 'my-new-index', options[:index] - assert_equal 'my-other-type', options[:type] - true - end - .returns({'items' => [ {'index' => {} }]}) - - DummyImportingModel.stubs(:client).returns(client) - - DummyImportingModel.import index: 'my-new-index', type: 'my-other-type' - end - end -end diff --git a/elasticsearch-model/test/unit/indexing_test.rb b/elasticsearch-model/test/unit/indexing_test.rb deleted file mode 100644 index a2758fa6a..000000000 --- a/elasticsearch-model/test/unit/indexing_test.rb +++ /dev/null @@ -1,401 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::IndexingTest < Test::Unit::TestCase - context "Indexing module: " do - class ::DummyIndexingModel - extend ActiveModel::Naming - extend Elasticsearch::Model::Naming::ClassMethods - extend Elasticsearch::Model::Indexing::ClassMethods - - def self.foo - 'bar' - end - end - - context "Settings class" do - should "be convertible to hash" do - hash = { foo: 'bar' } - settings = Elasticsearch::Model::Indexing::Settings.new hash - assert_equal hash, settings.to_hash - assert_equal settings.to_hash, settings.as_json - end - end - - context "Settings method" do - should "initialize the index settings" do - assert_instance_of Elasticsearch::Model::Indexing::Settings, DummyIndexingModel.settings - end - - should "update and return the index settings" do - DummyIndexingModel.settings foo: 'boo' - DummyIndexingModel.settings bar: 'bam' - - assert_equal( {foo: 'boo', bar: 'bam'}, DummyIndexingModel.settings.to_hash) - end - - should "evaluate the block" do - DummyIndexingModel.expects(:foo) - - DummyIndexingModel.settings do - foo - end - end - end - - context "Mappings class" do - should "initialize the index mappings" do - assert_instance_of Elasticsearch::Model::Indexing::Mappings, DummyIndexingModel.mappings - end - - should "be convertible to hash" do - mappings = Elasticsearch::Model::Indexing::Mappings.new :mytype, { foo: 'bar' } - assert_equal( { :mytype => { foo: 'bar', :properties => {} } }, mappings.to_hash ) - assert_equal mappings.to_hash, mappings.as_json - end - - should "define properties" do - mappings = Elasticsearch::Model::Indexing::Mappings.new :mytype - assert_respond_to mappings, :indexes - - mappings.indexes :foo, { type: 'boolean', include_in_all: false } - assert_equal 'boolean', mappings.to_hash[:mytype][:properties][:foo][:type] - end - - should "define type as string by default" do - mappings = Elasticsearch::Model::Indexing::Mappings.new :mytype - - mappings.indexes :bar, {} - assert_equal 'string', mappings.to_hash[:mytype][:properties][:bar][:type] - end - - should "define embedded properties" do - mappings = Elasticsearch::Model::Indexing::Mappings.new :mytype - - mappings.indexes :foo do - indexes :bar - end - - mappings.indexes :multi, type: 'multi_field' do - indexes :multi, analyzer: 'snowball' - indexes :raw, analyzer: 'keyword' - end - - assert_equal 'object', mappings.to_hash[:mytype][:properties][:foo][:type] - assert_equal 'string', mappings.to_hash[:mytype][:properties][:foo][:properties][:bar][:type] - - assert_equal 'multi_field', mappings.to_hash[:mytype][:properties][:multi][:type] - assert_equal 'snowball', mappings.to_hash[:mytype][:properties][:multi][:fields][:multi][:analyzer] - assert_equal 'keyword', mappings.to_hash[:mytype][:properties][:multi][:fields][:raw][:analyzer] - end - - should "define multi_field properties" do - end - end - - context "Mappings method" do - should "initialize the index mappings" do - assert_instance_of Elasticsearch::Model::Indexing::Mappings, DummyIndexingModel.mappings - end - - should "update and return the index mappings" do - DummyIndexingModel.mappings foo: 'boo' do; end - DummyIndexingModel.mappings bar: 'bam' do; end - assert_equal( { dummy_indexing_model: { foo: "boo", bar: "bam", properties: {} } }, - DummyIndexingModel.mappings.to_hash ) - end - - should "evaluate the block" do - DummyIndexingModel.mappings.expects(:indexes).with(:foo).returns(true) - - DummyIndexingModel.mappings do - indexes :foo - end - end - end - - context "Instance methods" do - class ::DummyIndexingModelWithCallbacks - extend Elasticsearch::Model::Indexing::ClassMethods - include Elasticsearch::Model::Indexing::InstanceMethods - - def self.before_save(&block) - (@callbacks ||= {})[block.hash] = block - end - - def changed_attributes; [:foo]; end - - def changes - {:foo => ['One', 'Two']} - end - end - - should "register before_save callback when included" do - ::DummyIndexingModelWithCallbacks.expects(:before_save).returns(true) - ::DummyIndexingModelWithCallbacks.__send__ :include, Elasticsearch::Model::Indexing::InstanceMethods - end - - should "set the @__changed_attributes variable before save" do - instance = ::DummyIndexingModelWithCallbacks.new - instance.expects(:instance_variable_set).with do |name, value| - assert_equal :@__changed_attributes, name - assert_equal({foo: 'Two'}, value) - end - - ::DummyIndexingModelWithCallbacks.__send__ :include, Elasticsearch::Model::Indexing::InstanceMethods - - ::DummyIndexingModelWithCallbacks.instance_variable_get(:@callbacks).each do |n,b| - instance.instance_eval(&b) - end - end - - should "have the index_document method" do - client = mock('client') - instance = ::DummyIndexingModelWithCallbacks.new - - client.expects(:index).with do |payload| - assert_equal 'foo', payload[:index] - assert_equal 'bar', payload[:type] - assert_equal '1', payload[:id] - assert_equal 'JSON', payload[:body] - end - - instance.expects(:client).returns(client) - instance.expects(:as_indexed_json).returns('JSON') - instance.expects(:index_name).returns('foo') - instance.expects(:document_type).returns('bar') - instance.expects(:id).returns('1') - - instance.index_document - end - - should "pass extra options to the index_document method to client.index" do - client = mock('client') - instance = ::DummyIndexingModelWithCallbacks.new - - client.expects(:index).with do |payload| - assert_equal 'A', payload[:parent] - end - - instance.expects(:client).returns(client) - instance.expects(:as_indexed_json).returns('JSON') - instance.expects(:index_name).returns('foo') - instance.expects(:document_type).returns('bar') - instance.expects(:id).returns('1') - - instance.index_document(parent: 'A') - end - - should "have the delete_document method" do - client = mock('client') - instance = ::DummyIndexingModelWithCallbacks.new - - client.expects(:delete).with do |payload| - assert_equal 'foo', payload[:index] - assert_equal 'bar', payload[:type] - assert_equal '1', payload[:id] - end - - instance.expects(:client).returns(client) - instance.expects(:index_name).returns('foo') - instance.expects(:document_type).returns('bar') - instance.expects(:id).returns('1') - - instance.delete_document() - end - - should "pass extra options to the delete_document method to client.delete" do - client = mock('client') - instance = ::DummyIndexingModelWithCallbacks.new - - client.expects(:delete).with do |payload| - assert_equal 'A', payload[:parent] - end - - instance.expects(:client).returns(client) - instance.expects(:id).returns('1') - instance.expects(:index_name).returns('foo') - instance.expects(:document_type).returns('bar') - - instance.delete_document(parent: 'A') - end - - should "update the document by re-indexing when no changes are present" do - client = mock('client') - instance = ::DummyIndexingModelWithCallbacks.new - - # Reset the fake `changes` - instance.instance_variable_set(:@__changed_attributes, nil) - - instance.expects(:index_document) - instance.update_document - end - - should "update the document by partial update when changes are present" do - client = mock('client') - instance = ::DummyIndexingModelWithCallbacks.new - - # Set the fake `changes` hash - instance.instance_variable_set(:@__changed_attributes, {foo: 'bar'}) - - client.expects(:update).with do |payload| - assert_equal 'foo', payload[:index] - assert_equal 'bar', payload[:type] - assert_equal '1', payload[:id] - assert_equal({foo: 'bar'}, payload[:body][:doc]) - end - - instance.expects(:client).returns(client) - instance.expects(:index_name).returns('foo') - instance.expects(:document_type).returns('bar') - instance.expects(:id).returns('1') - - instance.update_document - end - end - - context "Re-creating the index" do - class ::DummyIndexingModelForRecreate - extend ActiveModel::Naming - extend Elasticsearch::Model::Naming::ClassMethods - extend Elasticsearch::Model::Indexing::ClassMethods - - settings index: { number_of_shards: 1 } do - mappings do - indexes :foo, analyzer: 'keyword' - end - end - end - - should "delete the index without raising exception" do - client = stub('client') - indices = stub('indices') - client.stubs(:indices).returns(indices) - - indices.expects(:delete).returns({}).then.raises(Exception).at_least_once - - DummyIndexingModelForRecreate.expects(:client).returns(client).at_least_once - - assert_nothing_raised do - DummyIndexingModelForRecreate.delete_index! - DummyIndexingModelForRecreate.delete_index! - end - end - - should "create the index with correct settings and mappings when it doesn't exist" do - client = stub('client') - indices = stub('indices') - client.stubs(:indices).returns(indices) - - indices.expects(:exists).returns(false) - - indices.expects(:create).with do |payload| - assert_equal 'dummy_indexing_model_for_recreates', payload[:index] - assert_equal 1, payload[:body][:settings][:index][:number_of_shards] - assert_equal 'keyword', payload[:body][:mappings][:dummy_indexing_model_for_recreate][:properties][:foo][:analyzer] - end.returns({}) - - DummyIndexingModelForRecreate.expects(:client).returns(client).at_least_once - - assert_nothing_raised { DummyIndexingModelForRecreate.create_index! } - end - - should "not create the index when it exists" do - client = stub('client') - indices = stub('indices') - client.stubs(:indices).returns(indices) - - indices.expects(:exists).returns(true) - - indices.expects(:create).never - - DummyIndexingModelForRecreate.expects(:client).returns(client).at_least_once - - assert_nothing_raised { DummyIndexingModelForRecreate.create_index! } - end - - should "not raise exception during index creation" do - client = stub('client') - indices = stub('indices') - client.stubs(:indices).returns(indices) - - indices.expects(:exists).returns(false) - indices.expects(:create).raises(Exception).at_least_once - - DummyIndexingModelForRecreate.expects(:client).returns(client).at_least_once - - assert_nothing_raised do - DummyIndexingModelForRecreate.create_index! - end - end - - should "delete the index first with the force option" do - client = stub('client') - indices = stub('indices') - client.stubs(:indices).returns(indices) - - indices.expects(:delete).returns({}) - indices.expects(:exists).returns(false) - indices.expects(:create).returns({}).at_least_once - - DummyIndexingModelForRecreate.expects(:client).returns(client).at_least_once - - assert_nothing_raised do - DummyIndexingModelForRecreate.create_index! force: true - end - end - - should "refresh the index without raising exception" do - client = stub('client') - indices = stub('indices') - client.stubs(:indices).returns(indices) - - indices.expects(:refresh).returns({}).then.raises(Exception).at_least_once - - DummyIndexingModelForRecreate.expects(:client).returns(client).at_least_once - - assert_nothing_raised do - DummyIndexingModelForRecreate.refresh_index! - DummyIndexingModelForRecreate.refresh_index! - end - end - - context "with a custom index name" do - setup do - @client = stub('client') - @indices = stub('indices') - @client.stubs(:indices).returns(@indices) - DummyIndexingModelForRecreate.expects(:client).returns(@client).at_least_once - end - - should "create the custom index" do - @indices.expects(:exists).with do |arguments| - assert_equal 'custom-foo', arguments[:index] - end - - @indices.expects(:create).with do |arguments| - assert_equal 'custom-foo', arguments[:index] - end - - DummyIndexingModelForRecreate.create_index! index: 'custom-foo' - end - - should "delete the custom index" do - @indices.expects(:delete).with do |arguments| - assert_equal 'custom-foo', arguments[:index] - end - - DummyIndexingModelForRecreate.delete_index! index: 'custom-foo' - end - - should "refresh the custom index" do - @indices.expects(:refresh).with do |arguments| - assert_equal 'custom-foo', arguments[:index] - end - - DummyIndexingModelForRecreate.refresh_index! index: 'custom-foo' - end - end - end - - end -end diff --git a/elasticsearch-model/test/unit/module_test.rb b/elasticsearch-model/test/unit/module_test.rb deleted file mode 100644 index 289f6e3b6..000000000 --- a/elasticsearch-model/test/unit/module_test.rb +++ /dev/null @@ -1,46 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::ModuleTest < Test::Unit::TestCase - context "The main module" do - - context "client" do - should "have a default" do - client = Elasticsearch::Model.client - assert_not_nil client - assert_instance_of Elasticsearch::Transport::Client, client - end - - should "be settable" do - begin - Elasticsearch::Model.client = "Foobar" - assert_equal "Foobar", Elasticsearch::Model.client - ensure - Elasticsearch::Model.client = nil - end - end - end - - context "when included in module/class, " do - class ::DummyIncludingModel; end - - should "include and set up the proxy" do - DummyIncludingModel.__send__ :include, Elasticsearch::Model - - assert_respond_to DummyIncludingModel, :__elasticsearch__ - assert_respond_to DummyIncludingModel.new, :__elasticsearch__ - end - - should "delegate important methods to the proxy" do - DummyIncludingModel.__send__ :include, Elasticsearch::Model - - assert_respond_to DummyIncludingModel, :search - assert_respond_to DummyIncludingModel, :mappings - assert_respond_to DummyIncludingModel, :settings - assert_respond_to DummyIncludingModel, :index_name - assert_respond_to DummyIncludingModel, :document_type - assert_respond_to DummyIncludingModel, :import - end - end - - end -end diff --git a/elasticsearch-model/test/unit/naming_test.rb b/elasticsearch-model/test/unit/naming_test.rb deleted file mode 100644 index d574dd129..000000000 --- a/elasticsearch-model/test/unit/naming_test.rb +++ /dev/null @@ -1,76 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::NamingTest < Test::Unit::TestCase - context "Naming module" do - class ::DummyNamingModel - extend ActiveModel::Naming - - extend Elasticsearch::Model::Naming::ClassMethods - include Elasticsearch::Model::Naming::InstanceMethods - end - - module ::MyNamespace - class DummyNamingModelInNamespace - extend ActiveModel::Naming - - extend Elasticsearch::Model::Naming::ClassMethods - include Elasticsearch::Model::Naming::InstanceMethods - end - end - - should "return the default index_name" do - assert_equal 'dummy_naming_models', DummyNamingModel.index_name - assert_equal 'dummy_naming_models', DummyNamingModel.new.index_name - end - - should "return the sanitized default index_name for namespaced model" do - assert_equal 'my_namespace-dummy_naming_model_in_namespaces', ::MyNamespace::DummyNamingModelInNamespace.index_name - assert_equal 'my_namespace-dummy_naming_model_in_namespaces', ::MyNamespace::DummyNamingModelInNamespace.new.index_name - end - - should "return the default document_type" do - assert_equal 'dummy_naming_model', DummyNamingModel.document_type - assert_equal 'dummy_naming_model', DummyNamingModel.new.document_type - end - - should "set and return the index_name" do - DummyNamingModel.index_name 'foobar' - assert_equal 'foobar', DummyNamingModel.index_name - - d = DummyNamingModel.new - d.index_name 'foobar_d' - assert_equal 'foobar_d', d.index_name - end - - should "set the index_name with setter" do - DummyNamingModel.index_name = 'foobar_index_S' - assert_equal 'foobar_index_S', DummyNamingModel.index_name - - d = DummyNamingModel.new - d.index_name = 'foobar_index_s' - assert_equal 'foobar_index_s', d.index_name - - assert_equal 'foobar_index_S', DummyNamingModel.index_name - end - - should "set and return the document_type" do - DummyNamingModel.document_type 'foobar' - assert_equal 'foobar', DummyNamingModel.document_type - - d = DummyNamingModel.new - d.document_type 'foobar_d' - assert_equal 'foobar_d', d.document_type - end - - should "set the document_type with setter" do - DummyNamingModel.document_type = 'foobar_type_S' - assert_equal 'foobar_type_S', DummyNamingModel.document_type - - d = DummyNamingModel.new - d.document_type = 'foobar_type_s' - assert_equal 'foobar_type_s', d.document_type - - assert_equal 'foobar_type_S', DummyNamingModel.document_type - end - end -end diff --git a/elasticsearch-model/test/unit/proxy_test.rb b/elasticsearch-model/test/unit/proxy_test.rb deleted file mode 100644 index 6231bba87..000000000 --- a/elasticsearch-model/test/unit/proxy_test.rb +++ /dev/null @@ -1,88 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::SearchTest < Test::Unit::TestCase - context "Searching module" do - class ::DummyProxyModel - include Elasticsearch::Model::Proxy - - def self.foo - 'classy foo' - end - - def bar - 'insta barr' - end - - def as_json(options) - {foo: 'bar'} - end - end - - class ::DummyProxyModelWithCallbacks - def self.before_save(&block) - (@callbacks ||= {})[block.hash] = block - end - - def changed_attributes; [:foo]; end - - def changes - {:foo => ['One', 'Two']} - end - end - - should "setup the class proxy method" do - assert_respond_to DummyProxyModel, :__elasticsearch__ - end - - should "setup the instance proxy method" do - assert_respond_to DummyProxyModel.new, :__elasticsearch__ - end - - should "register the hook for before_save callback" do - ::DummyProxyModelWithCallbacks.expects(:before_save).returns(true) - DummyProxyModelWithCallbacks.__send__ :include, Elasticsearch::Model::Proxy - end - - should "set the @__changed_attributes variable before save" do - instance = ::DummyProxyModelWithCallbacks.new - instance.__elasticsearch__.expects(:instance_variable_set).with do |name, value| - assert_equal :@__changed_attributes, name - assert_equal({foo: 'Two'}, value) - end - - ::DummyProxyModelWithCallbacks.__send__ :include, Elasticsearch::Model::Proxy - - ::DummyProxyModelWithCallbacks.instance_variable_get(:@callbacks).each do |n,b| - instance.instance_eval(&b) - end - end - - should "delegate methods to the target" do - assert_respond_to DummyProxyModel.__elasticsearch__, :foo - assert_respond_to DummyProxyModel.new.__elasticsearch__, :bar - - assert_raise(NoMethodError) { DummyProxyModel.__elasticsearch__.xoxo } - assert_raise(NoMethodError) { DummyProxyModel.new.__elasticsearch__.xoxo } - - assert_equal 'classy foo', DummyProxyModel.__elasticsearch__.foo - assert_equal 'insta barr', DummyProxyModel.new.__elasticsearch__.bar - end - - should "return the proxy class from instance proxy" do - assert_equal Elasticsearch::Model::Proxy::ClassMethodsProxy, DummyProxyModel.new.__elasticsearch__.class.class - end - - should "return the origin class from instance proxy" do - assert_equal DummyProxyModel, DummyProxyModel.new.__elasticsearch__.klass - end - - should "delegate as_json from the proxy to target" do - assert_equal({foo: 'bar'}, DummyProxyModel.new.__elasticsearch__.as_json) - end - - should "have inspect method indicating the proxy" do - assert_match /PROXY/, DummyProxyModel.__elasticsearch__.inspect - assert_match /PROXY/, DummyProxyModel.new.__elasticsearch__.inspect - end - end -end diff --git a/elasticsearch-model/test/unit/response_base_test.rb b/elasticsearch-model/test/unit/response_base_test.rb deleted file mode 100644 index aa9b4244d..000000000 --- a/elasticsearch-model/test/unit/response_base_test.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::BaseTest < Test::Unit::TestCase - context "Response base module" do - class OriginClass - def self.index_name; 'foo'; end - def self.document_type; 'bar'; end - end - - class DummyBaseClass - include Elasticsearch::Model::Response::Base - end - - RESPONSE = { 'hits' => { 'total' => 123, 'max_score' => 456, 'hits' => [] } } - - setup do - @search = Elasticsearch::Model::Searching::SearchRequest.new OriginClass, '*' - @response = Elasticsearch::Model::Response::Response.new OriginClass, @search - @search.stubs(:execute!).returns(RESPONSE) - end - - should "access klass, response, total and max_score" do - r = DummyBaseClass.new OriginClass, @response - - assert_equal OriginClass, r.klass - assert_equal @response, r.response - assert_equal RESPONSE, r.response.response - assert_equal 123, r.total - assert_equal 456, r.max_score - end - - should "have abstract methods results and records" do - r = DummyBaseClass.new OriginClass, @response - - assert_raise(Elasticsearch::Model::NotImplemented) { |e| r.results } - assert_raise(Elasticsearch::Model::NotImplemented) { |e| r.records } - end - - end -end diff --git a/elasticsearch-model/test/unit/response_pagination_test.rb b/elasticsearch-model/test/unit/response_pagination_test.rb deleted file mode 100644 index 856e2ed3c..000000000 --- a/elasticsearch-model/test/unit/response_pagination_test.rb +++ /dev/null @@ -1,159 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::ResponsePaginationTest < Test::Unit::TestCase - context "Response pagination" do - class ModelClass - include ::Kaminari::ConfigurationMethods - - def self.index_name; 'foo'; end - def self.document_type; 'bar'; end - end - - RESPONSE = { 'took' => '5', 'timed_out' => false, '_shards' => {'one' => 'OK'}, - 'hits' => { 'total' => 100, 'hits' => (1..100).to_a.map { |i| { _id: i } } } } - - setup do - @search = Elasticsearch::Model::Searching::SearchRequest.new ModelClass, '*' - @response = Elasticsearch::Model::Response::Response.new ModelClass, @search, RESPONSE - @response.klass.stubs(:client).returns mock('client') - end - - should "have pagination methods" do - assert_respond_to @response, :page - assert_respond_to @response, :limit_value - assert_respond_to @response, :offset_value - assert_respond_to @response, :limit - assert_respond_to @response, :offset - assert_respond_to @response, :total_count - end - - context "#page method" do - should "advance the from/size" do - @response.klass.client - .expects(:search) - .with do |definition| - assert_equal 25, definition[:from] - assert_equal 25, definition[:size] - end - .returns(RESPONSE) - - assert_nil @response.search.definition[:from] - assert_nil @response.search.definition[:size] - - @response.page(2).to_a - assert_equal 25, @response.search.definition[:from] - assert_equal 25, @response.search.definition[:size] - end - - should "advance the from/size further" do - @response.klass.client - .expects(:search) - .with do |definition| - assert_equal 75, definition[:from] - assert_equal 25, definition[:size] - end - .returns(RESPONSE) - - @response.page(4).to_a - assert_equal 75, @response.search.definition[:from] - assert_equal 25, @response.search.definition[:size] - end - end - - context "limit/offset readers" do - should "return the default" do - assert_equal 0, @response.limit_value - assert_equal 0, @response.offset_value - end - - should "return the value from URL parameters" do - search = Elasticsearch::Model::Searching::SearchRequest.new ModelClass, '*', size: 10, from: 50 - @response = Elasticsearch::Model::Response::Response.new ModelClass, search, RESPONSE - - assert_equal 10, @response.limit_value - assert_equal 50, @response.offset_value - end - - should "return the value from body" do - search = Elasticsearch::Model::Searching::SearchRequest.new ModelClass, { query: { match_all: {} }, from: 10, size: 50 } - @response = Elasticsearch::Model::Response::Response.new ModelClass, search, RESPONSE - - assert_equal 50, @response.limit_value - assert_equal 10, @response.offset_value - end - end - - context "limit setter" do - setup do - @response.records - @response.results - end - - should "set the values" do - @response.limit(35) - assert_equal 35, @response.search.definition[:size] - end - - should "reset the variables" do - @response.limit(35) - - assert_nil @response.instance_variable_get(:@response) - assert_nil @response.instance_variable_get(:@records) - assert_nil @response.instance_variable_get(:@results) - end - end - - context "offset setter" do - setup do - @response.records - @response.results - end - - should "set the values" do - @response.offset(15) - assert_equal 15, @response.search.definition[:from] - end - - should "reset the variables" do - @response.offset(35) - - assert_nil @response.instance_variable_get(:@response) - assert_nil @response.instance_variable_get(:@records) - assert_nil @response.instance_variable_get(:@results) - end - end - - context "total" do - should "return the number of hits" do - @response.expects(:results).returns(mock('results', total: 100)) - assert_equal 100, @response.total_count - end - end - - context "results" do - setup do - @search.stubs(:execute!).returns RESPONSE - end - - should "return current page and total count" do - assert_equal 1, @response.page(1).results.current_page - assert_equal 100, @response.results.total_count - - assert_equal 5, @response.page(5).results.current_page - end - end - - context "records" do - setup do - @search.stubs(:execute!).returns RESPONSE - end - - should "return current page and total count" do - assert_equal 1, @response.page(1).records.current_page - assert_equal 100, @response.records.total_count - - assert_equal 5, @response.page(5).records.current_page - end - end - end -end diff --git a/elasticsearch-model/test/unit/response_records_test.rb b/elasticsearch-model/test/unit/response_records_test.rb deleted file mode 100644 index 8a78255d7..000000000 --- a/elasticsearch-model/test/unit/response_records_test.rb +++ /dev/null @@ -1,91 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::RecordsTest < Test::Unit::TestCase - context "Response records" do - class DummyCollection - include Enumerable - - def each(&block); ['FOO'].each(&block); end - def size; ['FOO'].size; end - def empty?; ['FOO'].empty?; end - def foo; 'BAR'; end - end - - class DummyModel - def self.index_name; 'foo'; end - def self.document_type; 'bar'; end - - def self.find(*args) - DummyCollection.new - end - end - - RESPONSE = { 'hits' => { 'total' => 123, 'max_score' => 456, 'hits' => [{'_id' => '1', 'foo' => 'bar'}] } } - RESULTS = Elasticsearch::Model::Response::Results.new DummyModel, RESPONSE - - setup do - search = Elasticsearch::Model::Searching::SearchRequest.new DummyModel, '*' - search.stubs(:execute!).returns RESPONSE - - response = Elasticsearch::Model::Response::Response.new DummyModel, search - @records = Elasticsearch::Model::Response::Records.new DummyModel, response - end - - should "access the records" do - assert_respond_to @records, :records - assert_equal 1, @records.records.size - assert_equal 'FOO', @records.records.first - end - - should "delegate Enumerable methods to records" do - assert ! @records.empty? - assert_equal 'FOO', @records.first - end - - should "delegate methods to records" do - assert_respond_to @records, :foo - assert_equal 'BAR', @records.foo - end - - should "have each_with_hit method" do - @records.each_with_hit do |record, hit| - assert_equal 'FOO', record - assert_equal 'bar', hit.foo - end - end - - should "have map_with_hit method" do - assert_equal ['FOO---bar'], @records.map_with_hit { |record, hit| "#{record}---#{hit.foo}" } - end - - should "return the IDs" do - assert_equal ['1'], @records.ids - end - - context "with adapter" do - module DummyAdapter - module RecordsMixin - def records - ['FOOBAR'] - end - end - - def records_mixin - RecordsMixin - end; module_function :records_mixin - end - - should "delegate the records method to the adapter" do - Elasticsearch::Model::Adapter.expects(:from_class) - .with(DummyModel) - .returns(DummyAdapter) - - @records = Elasticsearch::Model::Response::Records.new DummyModel, - RESPONSE - - assert_equal ['FOOBAR'], @records.records - end - end - - end -end diff --git a/elasticsearch-model/test/unit/response_result_test.rb b/elasticsearch-model/test/unit/response_result_test.rb deleted file mode 100644 index e82b4758e..000000000 --- a/elasticsearch-model/test/unit/response_result_test.rb +++ /dev/null @@ -1,52 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::ResultTest < Test::Unit::TestCase - context "Response result" do - - should "have method access to properties" do - result = Elasticsearch::Model::Response::Result.new foo: 'bar', bar: { bam: 'baz' } - - assert_respond_to result, :foo - assert_respond_to result, :bar - - assert_equal 'bar', result.foo - assert_equal 'baz', result.bar.bam - - assert_raise(NoMethodError) { result.xoxo } - end - - should "delegate method calls to `_source` when available" do - result = Elasticsearch::Model::Response::Result.new foo: 'bar', _source: { bar: 'baz' } - - assert_respond_to result, :foo - assert_respond_to result, :_source - assert_respond_to result, :bar - - assert_equal 'bar', result.foo - assert_equal 'baz', result._source.bar - assert_equal 'baz', result.bar - end - - should "delegate methods to @result" do - result = Elasticsearch::Model::Response::Result.new foo: 'bar' - - assert_equal 'bar', result.foo - assert_equal 'bar', result.fetch('foo') - assert_equal 'moo', result.fetch('NOT_EXIST', 'moo') - - assert_respond_to result, :to_hash - assert_equal({'foo' => 'bar'}, result.to_hash) - - assert_raise(NoMethodError) { result.does_not_exist } - end - - should "delegate as_json to @result even when ActiveSupport changed half of Ruby" do - require 'active_support/json/encoding' - result = Elasticsearch::Model::Response::Result.new foo: 'bar' - - result.instance_variable_get(:@result).expects(:as_json) - result.as_json(except: 'foo') - end - - end -end diff --git a/elasticsearch-model/test/unit/response_results_test.rb b/elasticsearch-model/test/unit/response_results_test.rb deleted file mode 100644 index e97539ecd..000000000 --- a/elasticsearch-model/test/unit/response_results_test.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::ResultsTest < Test::Unit::TestCase - context "Response results" do - class OriginClass - def self.index_name; 'foo'; end - def self.document_type; 'bar'; end - end - - RESPONSE = { 'hits' => { 'total' => 123, 'max_score' => 456, 'hits' => [{'foo' => 'bar'}] } } - - setup do - @search = Elasticsearch::Model::Searching::SearchRequest.new OriginClass, '*' - @response = Elasticsearch::Model::Response::Response.new OriginClass, @search - @results = Elasticsearch::Model::Response::Results.new OriginClass, @response - @search.stubs(:execute!).returns(RESPONSE) - end - - should "access the results" do - assert_respond_to @results, :results - assert_equal 1, @results.results.size - assert_equal 'bar', @results.results.first.foo - end - - should "delegate Enumerable methods to results" do - assert ! @results.empty? - assert_equal 'bar', @results.first.foo - end - - end -end diff --git a/elasticsearch-model/test/unit/response_test.rb b/elasticsearch-model/test/unit/response_test.rb deleted file mode 100644 index 3f0a36c1a..000000000 --- a/elasticsearch-model/test/unit/response_test.rb +++ /dev/null @@ -1,57 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::ResponseTest < Test::Unit::TestCase - context "Response" do - class OriginClass - def self.index_name; 'foo'; end - def self.document_type; 'bar'; end - end - - RESPONSE = { 'took' => '5', 'timed_out' => false, '_shards' => {'one' => 'OK'}, 'hits' => { 'hits' => [] } } - - setup do - @search = Elasticsearch::Model::Searching::SearchRequest.new OriginClass, '*' - @search.stubs(:execute!).returns(RESPONSE) - end - - should "access klass, response, took, timed_out, shards" do - response = Elasticsearch::Model::Response::Response.new OriginClass, @search - - assert_equal OriginClass, response.klass - assert_equal @search, response.search - assert_equal RESPONSE, response.response - assert_equal '5', response.took - assert_equal false, response.timed_out - assert_equal 'OK', response.shards.one - end - - should "load and access the results" do - @search.expects(:execute!).returns(RESPONSE) - - response = Elasticsearch::Model::Response::Response.new OriginClass, @search - assert_instance_of Elasticsearch::Model::Response::Results, response.results - assert_equal 0, response.size - end - - should "load and access the records" do - @search.expects(:execute!).returns(RESPONSE) - - response = Elasticsearch::Model::Response::Response.new OriginClass, @search - assert_instance_of Elasticsearch::Model::Response::Records, response.records - assert_equal 0, response.size - end - - should "delegate Enumerable methods to results" do - @search.expects(:execute!).returns(RESPONSE) - - response = Elasticsearch::Model::Response::Response.new OriginClass, @search - assert response.empty? - end - - should "be initialized lazily" do - @search.expects(:execute!).never - - Elasticsearch::Model::Response::Response.new OriginClass, @search - end - end -end diff --git a/elasticsearch-model/test/unit/searching_search_request_test.rb b/elasticsearch-model/test/unit/searching_search_request_test.rb deleted file mode 100644 index 652acb89b..000000000 --- a/elasticsearch-model/test/unit/searching_search_request_test.rb +++ /dev/null @@ -1,73 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::SearchRequestTest < Test::Unit::TestCase - context "SearchRequest class" do - class ::DummySearchingModel - extend Elasticsearch::Model::Searching::ClassMethods - - def self.index_name; 'foo'; end - def self.document_type; 'bar'; end - - end - - setup do - @client = mock('client') - DummySearchingModel.stubs(:client).returns(@client) - end - - should "pass the search definition as a simple query" do - @client.expects(:search).with do |params| - assert_equal 'foo', params[:q] - end - .returns({}) - - s = Elasticsearch::Model::Searching::SearchRequest.new ::DummySearchingModel, 'foo' - s.execute! - end - - should "pass the search definition as a Hash" do - @client.expects(:search).with do |params| - assert_equal( {foo: 'bar'}, params[:body] ) - end - .returns({}) - - s = Elasticsearch::Model::Searching::SearchRequest.new ::DummySearchingModel, foo: 'bar' - s.execute! - end - - should "pass the search definition as a JSON string" do - @client.expects(:search).with do |params| - assert_equal( '{"foo":"bar"}', params[:body] ) - end - .returns({}) - - s = Elasticsearch::Model::Searching::SearchRequest.new ::DummySearchingModel, '{"foo":"bar"}' - s.execute! - end - - should "pass the search definition as an object which responds to to_hash" do - class MySpecialQueryBuilder - def to_hash; {foo: 'bar'}; end - end - - @client.expects(:search).with do |params| - assert_equal( {foo: 'bar'}, params[:body] ) - end - .returns({}) - - s = Elasticsearch::Model::Searching::SearchRequest.new ::DummySearchingModel, MySpecialQueryBuilder.new - s.execute! - end - - should "pass the options to the client" do - @client.expects(:search).with do |params| - assert_equal 'foo', params[:q] - assert_equal 15, params[:size] - end - .returns({}) - - s = Elasticsearch::Model::Searching::SearchRequest.new ::DummySearchingModel, 'foo', size: 15 - s.execute! - end - end -end diff --git a/elasticsearch-model/test/unit/searching_test.rb b/elasticsearch-model/test/unit/searching_test.rb deleted file mode 100644 index 8be1c9e32..000000000 --- a/elasticsearch-model/test/unit/searching_test.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::SearchingTest < Test::Unit::TestCase - context "Searching module" do - class ::DummySearchingModel - extend Elasticsearch::Model::Searching::ClassMethods - - def self.index_name; 'foo'; end - def self.document_type; 'bar'; end - end - - setup do - @client = mock('client') - DummySearchingModel.stubs(:client).returns(@client) - end - - should "have the search method" do - assert_respond_to DummySearchingModel, :search - end - - should "initialize the search object" do - Elasticsearch::Model::Searching::SearchRequest - .expects(:new).with do |klass, query, options| - assert_equal DummySearchingModel, klass - assert_equal 'foo', query - assert_equal({default_operator: 'AND'}, options) - end - .returns( stub('search') ) - - DummySearchingModel.search 'foo', default_operator: 'AND' - end - - should "not execute the search" do - Elasticsearch::Model::Searching::SearchRequest - .expects(:new).returns( mock('search').expects(:execute!).never ) - - DummySearchingModel.search 'foo' - end - end -end diff --git a/elasticsearch-model/test/unit/serializing_test.rb b/elasticsearch-model/test/unit/serializing_test.rb deleted file mode 100644 index 201329257..000000000 --- a/elasticsearch-model/test/unit/serializing_test.rb +++ /dev/null @@ -1,17 +0,0 @@ -require 'test_helper' - -class Elasticsearch::Model::SerializingTest < Test::Unit::TestCase - context "Serializing module" do - class DummyClass - include Elasticsearch::Model::Serializing::InstanceMethods - - def as_json(options={}) - 'HASH' - end - end - - should "delegate to as_json by default" do - assert_equal 'HASH', DummyClass.new.as_indexed_json - end - end -end diff --git a/elasticsearch-persistence/.gitignore b/elasticsearch-persistence/.gitignore new file mode 100644 index 000000000..d87d4be66 --- /dev/null +++ b/elasticsearch-persistence/.gitignore @@ -0,0 +1,17 @@ +*.gem +*.rbc +.bundle +.config +.yardoc +Gemfile.lock +InstalledFiles +_yardoc +coverage +doc/ +lib/bundler/man +pkg +rdoc +spec/reports +test/tmp +test/version_tmp +tmp diff --git a/elasticsearch-persistence/.rspec b/elasticsearch-persistence/.rspec new file mode 100644 index 000000000..77d185827 --- /dev/null +++ b/elasticsearch-persistence/.rspec @@ -0,0 +1,2 @@ +--tty +--colour diff --git a/elasticsearch-persistence/CHANGELOG.md b/elasticsearch-persistence/CHANGELOG.md new file mode 100644 index 000000000..7d25720bb --- /dev/null +++ b/elasticsearch-persistence/CHANGELOG.md @@ -0,0 +1,43 @@ +## 0.1.9 + +* Added, that raw `_source` is accessible from a model instance + +## 0.1.8 + +* Added `cluster.health wait_for_status: 'yellow'` to Repository integration test +* Fixed tests for the updates to the `update` method for Persistence::Model +* Fixed timestamp tests +* Fixed typos and broken links in documentation, fixed examples +* Fixed, that `MyModel#save` does in fact persist `updated_at` attribute +* Fixed, that `options` have not been passed to gateway in MyModel#update +* Short-circuit the operation and return `false` when the model is not valid +* Fixed the problem where `document_type` configuration was not propagated to mapping + + +## 0.1.7 + +* Added an integration test for the `MyModel.all` method +* Improved the "music" example application + +## 0.1.6 + +* Improved documentation +* Refactored the Rails' forms date conversions into a module method +* Changed, that search requests are executed through a `SearchRequest` class + +## 0.1.5 + +* Improved documentation +* Added `@mymodel.id=` setter method + +## 0.1.4 + +* Added the Elasticsearch::Persistence::Model feature + +## 0.1.3 + +* Released the "elasticsearch-persistence" Rubygem + +## 0.0.1 + +* Initial infrastructure for the gem diff --git a/elasticsearch-persistence/Gemfile b/elasticsearch-persistence/Gemfile new file mode 100644 index 000000000..4e228558e --- /dev/null +++ b/elasticsearch-persistence/Gemfile @@ -0,0 +1,31 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +source 'https://rubygems.org' + +# Specify your gem's dependencies in elasticsearch-persistence.gemspec +gemspec + +gem 'elasticsearch-model', + path: File.expand_path('../elasticsearch-model', __dir__), + require: false + +group :development, :testing do + gem 'debug' unless defined?(JRUBY_VERSION) + gem 'pry-nav' + gem 'rspec' +end diff --git a/elasticsearch-persistence/LICENSE.txt b/elasticsearch-persistence/LICENSE.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/elasticsearch-persistence/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/elasticsearch-persistence/README.md b/elasticsearch-persistence/README.md new file mode 100644 index 000000000..47747b6ff --- /dev/null +++ b/elasticsearch-persistence/README.md @@ -0,0 +1,563 @@ +# Elasticsearch::Persistence + +Persistence layer for Ruby domain objects in Elasticsearch, using the Repository pattern. + +## Compatibility + +This library is compatible with Ruby 3.1 and higher. + +The version numbers follow the Elasticsearch major versions. Currently the `main` branch is compatible with version `8.x` of the Elasticsearch stack. + +| Rubygem | | Elasticsearch | +|:-------:|:-:|:-------------:| +| 0.1 | → | 1.x | +| 2.x | → | 2.x | +| 5.x | → | 5.x | +| 6.x | → | 6.x | +| 7.x | → | 7.x | +| 8.x | → | 8.x | +| main | → | 8.x | + +## Installation + +Install the package from [Rubygems](https://rubygems.org): + + gem install elasticsearch-persistence + +To use an unreleased version, either add it to your `Gemfile` for [Bundler](http://bundler.io): + + gem 'elasticsearch-persistence', git: 'git://github.com/elastic/elasticsearch-rails.git', branch: '6.x' + +or install it from a source code checkout: + + git clone https://github.com/elastic/elasticsearch-rails.git + cd elasticsearch-rails/elasticsearch-persistence + bundle install + rake install + +## Usage + +The library provides the Repository pattern for adding persistence to your Ruby objects. + +### The Repository Pattern + +The `Elasticsearch::Persistence::Repository` module provides an implementation of the +[repository pattern](http://martinfowler.com/eaaCatalog/repository.html) and allows +you to save, delete, find and search objects stored in Elasticsearch, as well as configure +mappings and settings for the index. It's an unobtrusive and decoupled way of adding +persistence to your Ruby objects. + +Let's have a simple plain old Ruby object (PORO): + +```ruby +class Note + attr_reader :attributes + + def initialize(attributes={}) + @attributes = attributes + end + + def to_hash + @attributes + end +end +``` + +Let's create a default, "dumb" repository, as a first step: + +```ruby +require 'elasticsearch/persistence' +class MyRepository; include Elasticsearch::Persistence::Repository; end +repository = MyRepository.new +``` + +We can save a `Note` instance into the repository... + +```ruby +note = Note.new id: 1, text: 'Test' + +repository.save(note) +# PUT http://localhost:9200/repository/_doc/1 [status:201, request:0.210s, query:n/a] +# > {"id":1,"text":"Test"} +# < {"_index":"repository","_id":"1","_version":1,"created":true} +``` + +...find it... + +```ruby +n = repository.find(1) +# GET http://localhost:9200/repository/_doc/1 [status:200, request:0.003s, query:n/a] +# < {"_index":"repository","_id":"1","_version":2,"found":true, "_source" : {"id":1,"text":"Test"}} +=> 1, "text"=>"Test"}> +``` + +...search for it... + +```ruby +repository.search(query: { match: { text: 'test' } }).first +# GET http://localhost:9200/repository/_search [status:200, request:0.005s, query:0.002s] +# > {"query":{"match":{"text":"test"}}} +# < {"took":2, ... "hits":{"total":1, ... "hits":[{ ... "_source" : {"id":1,"text":"Test"}}]}} +=> 1, "text"=>"Test"}> +``` + +...or delete it: + +```ruby +repository.delete(note) +# DELETE http://localhost:9200/repository/_doc/1 [status:200, request:0.014s, query:n/a] +# < {"found":true,"_index":"repository","_id":"1","_version":3} +=> {"found"=>true, "_index"=>"repository", "_id"=>"1", "_version"=>2} +``` + +The repository module provides a number of features and facilities to configure and customize the behavior: + +* Configuring the Elasticsearch [client](https://github.com/elastic/elasticsearch-ruby#usage) being used +* Setting the index name, and object class for deserialization +* Composing mappings and settings for the index +* Creating, deleting or refreshing the index +* Finding or searching for documents +* Providing access both to domain objects and hits for search results +* Providing access to the Elasticsearch response for search results (aggregations, total, ...) +* Defining the methods for serialization and deserialization + +There are two mixins you can include in your Repository class. The first `Elasticsearch::Persistence::Repository`, +provides the basic methods and settings you'll need. The second, `Elasticsearch::Persistence::Repository::DSL` adds +some additional class methods that allow you to set options that instances of the class will share. + +#### Basic Repository mixin + +For simple cases, you can just include the Elasticsearch::Persistence::Repository mixin to your class: + +```ruby +class MyRepository + include Elasticsearch::Persistence::Repository + + # Customize the serialization logic + def serialize(document) + super.merge(my_special_key: 'my_special_stuff') + end + + # Customize the de-serialization logic + def deserialize(document) + puts "# ***** CUSTOM DESERIALIZE LOGIC... *****" + super + end +end + +client = Elasticsearch::Client.new(url: ENV['ELASTICSEARCH_URL'], log: true) +repository = MyRepository.new(client: client, index_name: :my_notes, klass: Note) +repository.settings number_of_shards: 1 do + mapping do + indexes :text, analyzer: 'snowball' + end +end +``` + +The custom Elasticsearch client will be used now, with a custom index, as well as the custom serialization and de-serialization logic. + +We can create the index with the desired settings and mappings: + +```ruby +repository.create_index! force: true +# PUT http://localhost:9200/my_notes +# > {"settings":{"number_of_shards":1},"mappings":{ ... {"text":{"analyzer":"snowball","type":"string"}}}}} +``` + +Save the document with extra properties added by the `serialize` method: + +```ruby +repository.save(note) +# PUT http://localhost:9200/my_notes/note/1 +# > {"id":1,"text":"Test","my_special_key":"my_special_stuff"} +{"_index"=>"my_notes", "_id"=>"1", "_version"=>4, ... } +``` + +And `deserialize` it: + +```ruby +repository.find(1) +# ***** CUSTOM DESERIALIZE LOGIC... ***** +"my_special_stuff"}> +``` + +#### The DSL mixin + +In some cases, you'll want to set some of the repository configurations at the class level. This makes +most sense when the instances of the repository will use that same configuration: + +```ruby +require 'base64' + +class NoteRepository + include Elasticsearch::Persistence::Repository + include Elasticsearch::Persistence::Repository::DSL + + index_name 'notes' + klass Note + + settings number_of_shards: 1 do + mapping do + indexes :text, analyzer: 'snowball' + # Do not index images + indexes :image, index: false + end + end + + # Base64 encode the "image" field in the document + # + def serialize(document) + hash = document.to_hash.clone + hash['image'] = Base64.encode64(hash['image']) if hash['image'] + hash.to_hash + end + + # Base64 decode the "image" field in the document + # + def deserialize(document) + hash = document['_source'] + hash['image'] = Base64.decode64(hash['image']) if hash['image'] + klass.new hash + end +end +``` + +You can create an instance of this custom class and get each of the configurations. + +```ruby +client = Elasticsearch::Client.new(url: 'http://localhost:9200', log: true) +repository = NoteRepository.new(client: client) +repository.index_name +# => 'notes' + +``` + +You can also override the default configuration with options passed to the initialize method: + +```ruby +client = Elasticsearch::Client.new(url: 'http://localhost:9250', log: true) +client.transport.logger.formatter = proc { |s, d, p, m| "\e[2m# #{m}\n\e[0m" } +repository = NoteRepository.new(client: client, index_name: 'notes_development') + +repository.create_index!(force: true) + +note = Note.new('id' => 1, 'text' => 'Document with image', 'image' => '... BINARY DATA ...') + +repository.save(note) +# PUT http://localhost:9200/notes_development/_doc/1 +# > {"id":1,"text":"Document with image","image":"Li4uIEJJTkFSWSBEQVRBIC4uLg==\n"} +puts repository.find(1).attributes['image'] +# GET http://localhost:9200/notes_development/_doc/1 +# < {... "_source" : { ... "image":"Li4uIEJJTkFSWSBEQVRBIC4uLg==\n"}} +# => ... BINARY DATA ... +``` + +#### Functionality Provided by the Repository mixin + +Each of the following configurations can be set for a repository instance. +If you have included the `Elasticsearch::Persistence::Repository::DSL` mixin, then you can use the class-level DSL +methods to set each value. You can still override the configuration for any instance by passing options to the +`#initialize` method. +Even if you don't use the DSL mixin, you can set the instance configuration with options passed the `#initialize` method. + +##### Client + +The repository uses the standard Elasticsearch [client](https://github.com/elastic/elasticsearch-ruby#usage). + +```ruby +client = Elasticsearch::Client.new(url: 'http://search.server.org') +repository = NoteRepository.new(client: client) +repository.client.transport.logger = Logger.new(STDERR) +repository.client +# => Elasticsearch::Client + +``` + +or with the DSL mixin: + +```ruby +class NoteRepository + include Elasticsearch::Persistence::Repository + include Elasticsearch::Persistence::Repository::DSL + + client Elasticsearch::Client.new url: 'http://search.server.org' +end + +repository = NoteRepository.new +repository.client +# => Elasticsearch::Client + +``` + +##### Naming + +The `index_name` method specifies the Elasticsearch index to use for storage, lookup and search. The default index name +is 'repository'. + +```ruby +repository = NoteRepository.new(index_name: 'notes_development') +repository.index_name +# => 'notes_development' + +``` + +or with the DSL mixin: + +```ruby +class NoteRepository + include Elasticsearch::Persistence::Repository + include Elasticsearch::Persistence::Repository::DSL + + index_name 'notes_development' +end + +repository = NoteRepository.new +repository.index_name +# => 'notes_development' + +``` + +The `klass` method specifies the Ruby class name to use when initializing objects from +documents retrieved from the repository. If this value is not set, a Hash representation of the document will be returned instead. + +```ruby +repository = NoteRepository.new(klass: Note) +repository.klass +# => Note + +``` + +or with the DSL mixin: + +```ruby +class NoteRepository + include Elasticsearch::Persistence::Repository + include Elasticsearch::Persistence::Repository::DSL + + klass Note +end + +repository = NoteRepository.new +repository.klass +# => Note + +``` + +##### Index Configuration + +The `settings` and `mappings` methods, provided by the +[`elasticsearch-model`](http://rubydoc.info/gems/elasticsearch-model/Elasticsearch/Model/Indexing/ClassMethods) +gem, allow you to configure the index properties: + +```ruby +repository.settings number_of_shards: 1 +repository.settings.to_hash +# => {:number_of_shards=>1} + +repository.mappings { indexes :title, analyzer: 'snowball' } +repository.mappings.to_hash +# => { :note => {:properties=> ... }} +``` + +or with the DSL mixin: + +```ruby +class NoteRepository + include Elasticsearch::Persistence::Repository + include Elasticsearch::Persistence::Repository::DSL + + mappings { indexes :title, analyzer: 'snowball' } + settings number_of_shards: 1 +end + +repository = NoteRepository.new + +``` + +##### Create a Repository and set its configuration with a block + +You can also use the `#create` method to instantiate and set the mappings and settings on an instance +with a block in one call: + +```ruby +repository = NoteRepository.create(index_name: 'notes_development') do + settings number_of_shards: 1, number_of_replicas: 0 do + mapping dynamic: 'strict' do + indexes :foo do + indexes :bar + end + indexes :baz + end + end +end +``` + +##### Index Management + +The convenience methods `create_index!`, `delete_index!` and `refresh_index!` allow you to manage the index lifecycle. +These methods can only be called on repository instances and are not implemented at the class level. + +##### Serialization + +The `serialize` and `deserialize` methods allow you to customize the serialization of the document when it +is persisted to Elasticsearch, and define the initialization procedure when loading it from the storage: + +```ruby +class NoteRepository + include Elasticsearch::Persistence::Repository + + def serialize(document) + Hash[document.to_hash.map() { |k,v| v.upcase! if k == :title; [k,v] }] + end + + def deserialize(document) + MyNote.new ActiveSupport::HashWithIndifferentAccess.new(document['_source']).deep_symbolize_keys + end +end +``` + +##### Storage + +The `save` method allows you to store a domain object in the repository: + +```ruby +note = Note.new id: 1, title: 'Quick Brown Fox' +repository.save(note) +# => {"_index"=>"notes_development", "_id"=>"1", "_version"=>1, "created"=>true} +``` + +The `update` method allows you to perform a partial update of a document in the repository. +Use either a partial document: + +```ruby +repository.update id: 1, title: 'UPDATED', tags: [] +# => {"_index"=>"notes_development", "_id"=>"1", "_version"=>2} +``` + +Or a script (optionally with parameters): + +```ruby +repository.update 1, script: 'if (!ctx._source.tags.contains(t)) { ctx._source.tags += t }', params: { t: 'foo' } +# => {"_index"=>"notes_development", "_id"=>"1", "_version"=>3} +``` + + +The `delete` method allows you to remove objects from the repository (pass either the object itself or its ID): + +```ruby +repository.delete(note) +repository.delete(1) +``` + +##### Finding + +The `find` method allows you to find one or many documents in the storage and returns them as deserialized Ruby objects: + +```ruby +repository.save Note.new(id: 2, title: 'Fast White Dog') + +note = repository.find(1) +# => + +notes = repository.find(1, 2) +# => [, ] +``` + +When the document with a specific ID isn't found, a `nil` is returned instead of the deserialized object: + +```ruby +notes = repository.find(1, 3, 2) +# => [, nil, ] +``` + +Handle the missing objects in the application code, or call `compact` on the result. + +##### Search + +The `search` method is used to retrieve objects from the repository by a query string or definition in the Elasticsearch DSL: + +```ruby +repository.search('fox or dog').to_a +# GET http://localhost:9200/notes_development/_doc/_search?q=fox +# => [, ] + +repository.search(query: { match: { title: 'fox dog' } }).to_a +# GET http://localhost:9200/notes_development/_doc/_search +# > {"query":{"match":{"title":"fox dog"}}} +# => [, ] +``` + +The returned object is an instance of the `Elasticsearch::Persistence::Repository::Response::Results` class, +which provides access to the results, the full returned response and hits. + +```ruby +results = repository.search(query: { match: { title: 'fox dog' } }) + +# Iterate over the objects +# +results.each do |note| + puts "* #{note.attributes[:title]}" +end +# * QUICK BROWN FOX +# * FAST WHITE DOG + +# Iterate over the objects and hits +# +results.each_with_hit do |note, hit| + puts "* #{note.attributes[:title]}, score: #{hit._score}" +end +# * QUICK BROWN FOX, score: 0.29930896 +# * FAST WHITE DOG, score: 0.29930896 + +# Get total results +# +results.total +# => 2 + +# Access the raw response as a Hashie::Mash instance. +# Note that a Hashie::Mash will only be created if the 'response' method is called on the results. +results.response._shards.failed +# => 0 + +# Access the raw response +results.raw_response +# => {...} + +``` + +#### Example Application + +An example Sinatra application is available in [`examples/notes/application.rb`](examples/notes/application.rb), +and demonstrates a rich set of features: + +* How to create and configure a custom repository class +* How to work with a plain Ruby class as the domain object +* How to integrate the repository with a Sinatra application +* How to write complex search definitions, including pagination, highlighting and aggregations +* How to use search results in the application view + +### The ActiveRecord Pattern + +The ActiveRecord pattern has been deprecated as of version 6.0.0 of this gem. Please use the +[Repository Pattern](#the-repository-pattern) instead. For more information on migrating 5.x ActiveRecord-based applications to use the Repository Pattern, please see [this blog post](https://www.elastic.co/blog/activerecord-to-repository-changing-persistence-patterns-with-the-elasticsearch-rails-gem). + +## License + +This software is licensed under the Apache 2 license, quoted below. + + Licensed to Elasticsearch B.V. under one or more contributor + license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright + ownership. Elasticsearch B.V. licenses this file to you under + the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. diff --git a/elasticsearch-persistence/Rakefile b/elasticsearch-persistence/Rakefile new file mode 100644 index 000000000..07ba8c2a2 --- /dev/null +++ b/elasticsearch-persistence/Rakefile @@ -0,0 +1,71 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'bundler/gem_tasks' + +desc 'Run unit tests' +task default: 'test:unit' +task test: 'test:unit' + +# ----- Test tasks ------------------------------------------------------------ + +require 'rake/testtask' +require 'rspec/core/rake_task' + +namespace :test do + RSpec::Core::RakeTask.new(:spec) + + Rake::TestTask.new(:all) do |test| + test.verbose = false + test.warning = false + test.deps = [:spec] + end + + task unit: :spec +end + +namespace :bundle do + desc 'Install gem dependencies' + task :install do + puts '-' * 80 + Bundler.with_unbundled_env do + sh 'bundle install' + end + puts '-' * 80 + end +end + +# ----- Documentation tasks --------------------------------------------------- + +require 'yard' +YARD::Rake::YardocTask.new(:doc) do |t| + t.options = %w| --embed-mixins --markup=markdown | +end + +# ----- Code analysis tasks --------------------------------------------------- + +if defined?(RUBY_VERSION) && RUBY_VERSION > '1.9' + begin + require 'cane/rake_task' + Cane::RakeTask.new(:quality) do |cane| + cane.abc_max = 15 + cane.style_measure = 120 + end + rescue LoadError + warn 'cane not available, quality task not provided.' + end +end diff --git a/elasticsearch-persistence/elasticsearch-persistence.gemspec b/elasticsearch-persistence/elasticsearch-persistence.gemspec new file mode 100644 index 000000000..6dbb7386f --- /dev/null +++ b/elasticsearch-persistence/elasticsearch-persistence.gemspec @@ -0,0 +1,64 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# coding: utf-8 + +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'elasticsearch/persistence/version' + +Gem::Specification.new do |s| + s.name = 'elasticsearch-persistence' + s.version = Elasticsearch::Persistence::VERSION + s.authors = ['Elastic Client Library Maintainers'] + s.email = ['client-libs@elastic.co'] + s.description = 'Persistence layer for Ruby models and Elasticsearch.' + s.summary = 'Persistence layer for Ruby models and Elasticsearch.' + s.homepage = 'https://github.com/elasticsearch/elasticsearch-rails/' + s.license = 'Apache 2' + + s.files = `git ls-files -z`.split("\x0") + s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } + s.test_files = s.files.grep(%r{^(test|spec|features)/}) + s.require_paths = ['lib'] + + s.extra_rdoc_files = ['README.md', 'LICENSE.txt'] + s.rdoc_options = ['--charset=UTF-8'] + + s.required_ruby_version = '>= 3' + + s.add_dependency 'activemodel', '> 4' + s.add_dependency 'activesupport', '> 4' + s.add_dependency 'elasticsearch', '~> 8' + s.add_dependency 'elasticsearch-model', '8' + s.add_dependency 'hashie' + + s.add_development_dependency 'bundler' + s.add_development_dependency 'cane' + s.add_development_dependency 'minitest' + s.add_development_dependency 'mocha' + s.add_development_dependency 'oj' unless defined?(JRUBY_VERSION) + s.add_development_dependency 'pry' + s.add_development_dependency 'rails', '> 4' + s.add_development_dependency 'rake', '~> 12' + s.add_development_dependency 'ruby-prof' unless defined?(JRUBY_VERSION) + s.add_development_dependency 'shoulda-context' + s.add_development_dependency 'simplecov' + s.add_development_dependency 'test-unit' + s.add_development_dependency 'turn' + s.add_development_dependency 'yard' +end diff --git a/elasticsearch-persistence/examples/notes/.gitignore b/elasticsearch-persistence/examples/notes/.gitignore new file mode 100644 index 000000000..e9d847d61 --- /dev/null +++ b/elasticsearch-persistence/examples/notes/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +Gemfile.lock +tmp/* +log/* +doc/ +.yardoc +.vagrant diff --git a/elasticsearch-persistence/examples/notes/Gemfile b/elasticsearch-persistence/examples/notes/Gemfile new file mode 100644 index 000000000..ef2c098c4 --- /dev/null +++ b/elasticsearch-persistence/examples/notes/Gemfile @@ -0,0 +1,45 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +source 'https://rubygems.org' + +gem 'rake' +gem 'ansi' + +gem 'multi_json' +gem 'oj' +gem 'hashie' + +gem 'patron' +gem 'elasticsearch' +gem 'elasticsearch-model', git: 'https://github.com/elastic/elasticsearch-rails.git' +gem 'elasticsearch-persistence', git: 'https://github.com/elastic/elasticsearch-rails.git' + +gem 'sinatra', require: false +gem 'thin' + +group :development do + gem 'sinatra-contrib' +end + +group :test do + gem 'elasticsearch-extensions' + gem 'rack-test' + gem 'shoulda-context' + gem 'turn' + gem 'mocha' +end diff --git a/elasticsearch-persistence/examples/notes/README.markdown b/elasticsearch-persistence/examples/notes/README.markdown new file mode 100644 index 000000000..0d9313d72 --- /dev/null +++ b/elasticsearch-persistence/examples/notes/README.markdown @@ -0,0 +1,36 @@ +Demo Aplication for the Repository Pattern +========================================== + +This directory contains a simple demo application for the repository pattern of the `Elasticsearch::Persistence` +module in the [Sinatra](http://www.sinatrarb.com) framework. + +To run the application, first install the required gems and start the application: + +``` +bundle install +bundle exec ruby application.rb +``` + +The application demonstrates: + +* How to use a plain old Ruby object (PORO) as the domain model +* How to set up, configure and use the repository instance +* How to use the repository in tests + +## License + +This software is licensed under the Apache 2 license, quoted below. + + Copyright (c) 2014 Elasticsearch + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/elasticsearch-persistence/examples/notes/application.rb b/elasticsearch-persistence/examples/notes/application.rb new file mode 100644 index 000000000..9b7bc88ae --- /dev/null +++ b/elasticsearch-persistence/examples/notes/application.rb @@ -0,0 +1,252 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +$LOAD_PATH.unshift File.expand_path('../../../lib/', __FILE__) + +require 'sinatra/base' + +require 'multi_json' +require 'oj' +require 'hashie/mash' + +require 'elasticsearch' +require 'elasticsearch/model' +require 'elasticsearch/persistence' + +class Note + attr_reader :attributes + + def initialize(attributes={}) + @attributes = Hashie::Mash.new(attributes) + __add_date + __extract_tags + __truncate_text + end + + def method_missing(method_name, *arguments, &block) + attributes.respond_to?(method_name) ? attributes.__send__(method_name, *arguments, &block) : super + end + + def respond_to?(method_name, include_private=false) + attributes.respond_to?(method_name) || super + end + + def tags; attributes.tags || []; end + + def to_hash + @attributes.to_hash + end + + def __extract_tags + tags = attributes['text'].scan(/(\[\w+\])/).flatten if attributes['text'] + unless tags.nil? || tags.empty? + attributes.update 'tags' => tags.map { |t| t.tr('[]', '') } + attributes['text'].gsub!(/(\[\w+\])/, '').strip! + end + end + + def __add_date + attributes['created_at'] ||= Time.now.utc.iso8601 + end + + def __truncate_text + attributes['text'] = attributes['text'][0...80] + ' (...)' if attributes['text'] && attributes['text'].size > 80 + end +end + +class NoteRepository + include Elasticsearch::Persistence::Repository + include Elasticsearch::Persistence::Repository::DSL + + client Elasticsearch::Client.new url: ENV['ELASTICSEARCH_URL'], log: true + + index_name :notes + + mapping do + indexes :text, analyzer: 'snowball' + indexes :tags, type: 'keyword' + indexes :created_at, type: 'date' + end + + def deserialize(document) + Note.new document['_source'].merge('id' => document['_id']) + end +end unless defined?(NoteRepository) + +class Application < Sinatra::Base + enable :logging + enable :inline_templates + enable :method_override + + configure :development do + enable :dump_errors + disable :show_exceptions + + require 'sinatra/reloader' + register Sinatra::Reloader + end + + set :repository, NoteRepository.new + set :per_page, 25 + + get '/' do + @page = [ params[:p].to_i, 1 ].max + + @notes = settings.repository.search \ + query: ->(q, t) do + query = if q && !q.empty? + { match: { text: q } } + else + { match_all: {} } + end + + filter = if t && !t.empty? + { term: { tags: t } } + end + + if filter + { bool: { must: [ query ], filter: filter } } + else + query + end + end.(params[:q], params[:t]), + + sort: [{created_at: {order: 'desc'}}], + + size: settings.per_page, + from: settings.per_page * (@page-1), + + aggregations: { tags: { terms: { field: 'tags' } } }, + + highlight: { fields: { text: { fragment_size: 0, pre_tags: [''],post_tags: [''] } } } + + erb :index + end + + post '/' do + unless params[:text].empty? + @note = Note.new params + settings.repository.save(@note, refresh: true) + end + + redirect back + end + + delete '/:id' do |id| + settings.repository.delete(id, refresh: true) + redirect back + end +end + +Application.run! if $0 == __FILE__ + +__END__ + +@@ layout + + + + Notes + + + + +<%= yield %> + + + +@@ index + +
+

Notes

+
+ +
+
+ +
+

All notes <%= @notes.size %>

+
    + <% @notes.response.aggregations.tags.buckets.each do |term| %> +
  • <%= term['key'] %> <%= term['doc_count'] %>
  • + <% end %> +
+

Add a note

+
+

+

+
+
+ +
+<% if @notes.empty? %> +

No notes found.

+<% end %> + +<% @notes.each_with_hit do |note, hit| %> +
+

+ <%= hit.highlight && hit.highlight.size > 0 ? hit.highlight.text.first : note.text %> + + <% note.tags.each do |tag| %> <%= tag %><% end %> + <%= Time.parse(note.created_at).strftime('%d/%m/%Y %H:%M') %> + +

+

+
+<% end %> + +<% if @notes.size > 0 && @page.next <= @notes.total / settings.per_page %> +

→ Load next

+<% end %> +
diff --git a/elasticsearch-persistence/examples/notes/config.ru b/elasticsearch-persistence/examples/notes/config.ru new file mode 100644 index 000000000..8dced717b --- /dev/null +++ b/elasticsearch-persistence/examples/notes/config.ru @@ -0,0 +1,24 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +#\ --port 3000 --server thin + +require File.expand_path('../application', __FILE__) + +map '/' do + run Application +end diff --git a/elasticsearch-persistence/examples/notes/test.rb b/elasticsearch-persistence/examples/notes/test.rb new file mode 100644 index 000000000..b57105341 --- /dev/null +++ b/elasticsearch-persistence/examples/notes/test.rb @@ -0,0 +1,135 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +ENV['RACK_ENV'] = 'test' + +at_exit { Elasticsearch::Test::IntegrationTestCase.__run_at_exit_hooks } if ENV['SERVER'] + +require 'test/unit' +require 'shoulda-context' +require 'mocha/setup' +require 'rack/test' +require 'turn' + +require 'elasticsearch/extensions/test/cluster' +require 'elasticsearch/extensions/test/startup_shutdown' + +require_relative 'application' + +NoteRepository.index_name = 'notes_test' + +class Elasticsearch::Persistence::ExampleApplicationTest < Test::Unit::TestCase + include Rack::Test::Methods + alias :response :last_response + + def app + Application.new + end + + context "Note" do + should "be initialized with a Hash" do + note = Note.new 'foo' => 'bar' + assert_equal 'bar', note.attributes['foo'] + end + + should "add created_at when it's not passed" do + note = Note.new + assert_not_nil note.created_at + assert_match /#{Time.now.year}/, note.created_at + end + + should "not add created_at when it's passed" do + note = Note.new 'created_at' => 'FOO' + assert_equal 'FOO', note.created_at + end + + should "trim long text" do + assert_equal 'Hello World', Note.new('text' => 'Hello World').text + assert_equal 'FOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFOOFO (...)', + Note.new('text' => 'FOO'*200).text + end + + should "delegate methods to attributes" do + note = Note.new 'foo' => 'bar' + assert_equal 'bar', note.foo + end + + should "have tags" do + assert_not_nil Note.new.tags + end + + should "provide a `to_hash` method" do + note = Note.new 'foo' => 'bar' + assert_instance_of Hash, note.to_hash + assert_equal ['created_at', 'foo'], note.to_hash.keys.sort + end + + should "extract tags from the text" do + note = Note.new 'text' => 'Hello [foo] [bar]' + assert_equal 'Hello', note.text + assert_equal ['foo', 'bar'], note.tags + end + end + + context "Application" do + setup do + app.settings.repository.client = Elasticsearch::Client.new \ + hosts: [{ host: 'localhost', port: ENV.fetch('TEST_CLUSTER_PORT', 9250)}], + log: true + app.settings.repository.client.transport.logger.formatter = proc { |s, d, p, m| "\e[2m#{m}\n\e[0m" } + app.settings.repository.create_index! force: true + app.settings.repository.client.cluster.health wait_for_status: 'yellow' + end + + should "have the correct index name" do + assert_equal 'notes_test', app.settings.repository.index + end + + should "display empty page when there are no notes" do + get '/' + assert response.ok?, response.status.to_s + assert_match /No notes found/, response.body.to_s + end + + should "display the notes" do + app.settings.repository.save Note.new('text' => 'Hello') + app.settings.repository.refresh_index! + + get '/' + assert response.ok?, response.status.to_s + assert_match /

\s*Hello/, response.body.to_s + end + + should "create a note" do + post '/', { 'text' => 'Hello World' } + follow_redirect! + + assert response.ok?, response.status.to_s + assert_match /Hello World/, response.body.to_s + end + + should "delete a note" do + app.settings.repository.save Note.new('id' => 'foobar', 'text' => 'Perish...') + delete "/foobar" + follow_redirect! + + assert response.ok?, response.status.to_s + assert_no_match /Perish/, response.body.to_s + end + end + +end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence.rb b/elasticsearch-persistence/lib/elasticsearch/persistence.rb new file mode 100644 index 000000000..ac845ce77 --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence.rb @@ -0,0 +1,25 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'hashie/mash' + +require 'elasticsearch' +require 'elasticsearch/model' + +require 'elasticsearch/persistence/version' +require 'elasticsearch/persistence/repository' +require 'elasticsearch/persistence/repository/response/results' diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository.rb new file mode 100644 index 000000000..e5fb23c13 --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository.rb @@ -0,0 +1,226 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'elasticsearch/persistence/repository/dsl' +require 'elasticsearch/persistence/repository/find' +require 'elasticsearch/persistence/repository/store' +require 'elasticsearch/persistence/repository/serialize' +require 'elasticsearch/persistence/repository/search' + +module Elasticsearch + module Persistence + + # The base Repository mixin. This module should be included in classes that + # represent an Elasticsearch repository. + # + # @since 6.0.0 + module Repository + include Store + include Serialize + include Find + include Search + include Elasticsearch::Model::Indexing::ClassMethods + + def self.included(base) + base.send(:extend, ClassMethods) + end + + module ClassMethods + + # Initialize a repository instance. Optionally provide a block to define index mappings or + # settings on the repository instance. + # + # @example Create a new repository. + # MyRepository.create(index_name: 'notes', klass: Note) + # + # @example Create a new repository and evaluate a block on it. + # MyRepository.create(index_name: 'notes', klass: Note) do + # mapping dynamic: 'strict' do + # indexes :title + # end + # end + # + # @param [ Hash ] options The options to use. + # @param [ Proc ] block A block to evaluate on the new repository instance. + # + # @option options [ Symbol, String ] :index_name The name of the index. + # @option options [ Symbol, String ] :client The client used to handle requests to and from Elasticsearch. + # @option options [ Symbol, String ] :klass The class used to instantiate an object when documents are + # deserialized. The default is nil, in which case the raw document will be returned as a Hash. + # @option options [ Elasticsearch::Model::Indexing::Mappings, Hash ] :mapping The mapping for this index. + # @option options [ Elasticsearch::Model::Indexing::Settings, Hash ] :settings The settings for this index. + # + # @since 6.0.0 + def create(options = {}, &block) + new(options).tap do |obj| + obj.instance_eval(&block) if block_given? + end + end + end + + # The default index name. + # + # @return [ String ] The default index name. + # + # @since 6.0.0 + DEFAULT_INDEX_NAME = 'repository'.freeze + + # The repository options. + # + # @return [ Hash ] + # + # @since 6.0.0 + attr_reader :options + + # Initialize a repository instance. + # + # @example Initialize the repository. + # MyRepository.new(index_name: 'notes', klass: Note) + # + # @param [ Hash ] options The options to use. + # + # @option options [ Symbol, String ] :index_name The name of the index. + # @option options [ Symbol, String ] :client The client used to handle requests to and from Elasticsearch. + # @option options [ Symbol, String ] :klass The class used to instantiate an object when documents are + # deserialized. The default is nil, in which case the raw document will be returned as a Hash. + # @option options [ Elasticsearch::Model::Indexing::Mappings, Hash ] :mapping The mapping for this index. + # @option options [ Elasticsearch::Model::Indexing::Settings, Hash ] :settings The settings for this index. + # + # @since 6.0.0 + def initialize(options = {}) + @options = options + end + + # Get the client used by the repository. + # + # @example + # repository.client + # + # @return [ Elasticsearch::Client ] The repository's client. + # + # @since 6.0.0 + def client + @client ||= @options[:client] || + __get_class_value(:client) || + Elasticsearch::Client.new + end + + # Get the index name used by the repository. + # + # @example + # repository.index_name + # + # @return [ String, Symbol ] The repository's index name. + # + # @since 6.0.0 + def index_name + @index_name ||= @options[:index_name] || + __get_class_value(:index_name) || + DEFAULT_INDEX_NAME + end + + # Get the class used by the repository when deserializing. + # + # @example + # repository.klass + # + # @return [ Class ] The repository's klass for deserializing. + # + # @since 6.0.0 + def klass + @klass ||= @options[:klass] || __get_class_value(:klass) + end + + # Get the index mapping. Optionally pass a block to define the mappings. + # + # @example + # repository.mapping + # + # @example Set the mappings with a block. + # repository.mapping dynamic: 'strict' do + # indexes :foo + # end + # end + # + # @note If mappings were set when the repository was created, a block passed to this + # method will not be evaluated. + # + # @return [ Elasticsearch::Model::Indexing::Mappings ] The index mappings. + # + # @since 6.0.0 + def mapping(*args) + @memoized_mapping ||= @options[:mapping] || (begin + if _mapping = __get_class_value(:mapping) + _mapping + end + end) || (super && @mapping) + end + alias :mappings :mapping + + # Get the index settings. + # + # @example + # repository.settings + # + # @example Set the settings with a block. + # repository.settings number_of_shards: 1, number_of_replicas: 0 do + # mapping dynamic: 'strict' do + # indexes :foo do + # indexes :bar + # end + # end + # end + # + # @return [ Elasticsearch::Model::Indexing::Settings ] The index settings. + # + # @since 6.0.0 + def settings(*args) + @memoized_settings ||= @options[:settings] || __get_class_value(:settings) || (super && @settings) + end + + # Determine whether the index with this repository's index name exists. + # + # @example + # repository.index_exists? + # + # @return [ true, false ] Whether the index exists. + # + # @since 6.0.0 + def index_exists?(*args) + super + end + + # Get the nicer formatted string for use in inspection. + # + # @example Inspect the repository. + # repository.inspect + # + # @return [ String ] The repository inspection. + # + # @since 6.0.0 + def inspect + "#<#{self.class}:0x#{object_id} index_name=#{index_name} klass=#{klass}>" + end + + private + + def __get_class_value(_method_) + self.class.send(_method_) if self.class.respond_to?(_method_) + end + end + end +end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/dsl.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/dsl.rb new file mode 100644 index 000000000..cab567d1d --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/dsl.rb @@ -0,0 +1,97 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Persistence + module Repository + + # Include this module to get class-level methods for repository configuration. + # + # @since 6.0.0 + module DSL + def self.included(base) + base.send(:extend, Elasticsearch::Model::Indexing::ClassMethods) + base.send(:extend, ClassMethods) + end + + # These methods are necessary to define at the class-level so that the methods available + # via Elasticsearch::Model::Indexing::ClassMethods have the references they depend on. + # + # @since 6.0.0 + module ClassMethods + # Get or set the class-level index name setting. + # + # @example + # MyRepository.index_name + # + # @return [ String, Symbol ] _name The repository's index name. + # + # @since 6.0.0 + def index_name(_name = nil) + @index_name ||= (_name || DEFAULT_INDEX_NAME) + end + + # Get or set the class-level setting for the class used by the repository when deserializing. + # + # @example + # MyRepository.klass + # + # @return [ Class ] _class The repository's klass for deserializing. + # + # @since 6.0.0 + def klass(_class = nil) + instance_variables.include?(:@klass) ? @klass : @klass = _class + end + + # Get or set the class-level setting for the client used by the repository. + # + # @example + # MyRepository.client + # + # @return [ Class ] _client The repository's client. + # + # @since 6.0.0 + def client(_client = nil) + @client ||= (_client || Elasticsearch::Client.new) + end + + def create_index!(*args) + __raise_not_implemented_error(__method__) + end + + def delete_index!(*args) + __raise_not_implemented_error(__method__) + end + + def refresh_index!(*args) + __raise_not_implemented_error(__method__) + end + + def index_exists?(*args) + __raise_not_implemented_error(__method__) + end + + private + + def __raise_not_implemented_error(_method_) + raise NotImplementedError, "The '#{_method_}' method is not implemented on the Repository class." + end + end + end + end + end +end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/find.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/find.rb new file mode 100644 index 000000000..281486a4a --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/find.rb @@ -0,0 +1,104 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Persistence + module Repository + class DocumentNotFound < StandardError; end + + # Retrieves one or more domain objects from the repository + # + module Find + + # Retrieve a single object or multiple objects from Elasticsearch by ID or IDs + # + # @example Retrieve a single object by ID + # + # repository.find(1) + # # => + # + # @example Retrieve multiple objects by IDs + # + # repository.find(1, 2) + # # => [, + # + # @return [Object,Array] + # + def find(*args) + options = args.last.is_a?(Hash) ? args.pop : {} + ids = args + + if args.size == 1 + id = args.pop + id.is_a?(Array) ? __find_many(id, options) : __find_one(id, options) + else + __find_many args, options + end + end + + # Return if object exists in the repository + # + # @example + # + # repository.exists?(1) + # => true + # + # @param [ String, Integer ] id The id to search. + # @param [ Hash ] options The options. + # + # @return [true, false] + # + def exists?(id, options={}) + request = { index: index_name, id: id } + client.exists(request.merge(options)) + end + + private + + # The key for accessing the document found and returned from an + # Elasticsearch _mget query. + # + DOCS = 'docs'.freeze + + # The key for the boolean value indicating whether a particular id + # has been successfully found in an Elasticsearch _mget query. + # + FOUND = 'found'.freeze + + # @api private + # + def __find_one(id, options={}) + request = { index: index_name, id: id } + document = client.get(request.merge(options)) + deserialize(document) + rescue Elastic::Transport::Transport::Errors::NotFound => e + raise DocumentNotFound, e.message, caller + end + + # @api private + # + def __find_many(ids, options={}) + request = { index: index_name, body: { ids: ids } } + documents = client.mget(request.merge(options)) + documents[DOCS].map do |document| + deserialize(document) if document[FOUND] + end + end + end + end + end +end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/response/results.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/response/results.rb new file mode 100644 index 000000000..1852f192a --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/response/results.rb @@ -0,0 +1,128 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Persistence + module Repository + module Response # :nodoc: + + # Encapsulates the domain objects and documents returned from Elasticsearch when searching + # + # Implements `Enumerable` and forwards its methods to the {#results} object. + # + class Results + include Enumerable + + attr_reader :repository + attr_reader :raw_response + + # The key for accessing the results in an Elasticsearch query response. + # + HITS = 'hits'.freeze + + # The key for accessing the total number of hits in an Elasticsearch query response. + # + TOTAL = 'total'.freeze + + # The key for accessing the value field in an Elasticsearch query response when 'total' is an object. + # + VALUE = 'value'.freeze + + # The key for accessing the maximum score in an Elasticsearch query response. + # + MAX_SCORE = 'max_score'.freeze + + # @param repository [Elasticsearch::Persistence::Repository::Class] The repository instance + # @param response [Hash] The full response returned from the Elasticsearch client + # @param options [Hash] Optional parameters + # + def initialize(repository, response, options={}) + @repository = repository + @raw_response = response + @options = options + end + + def method_missing(method_name, *arguments, &block) + results.respond_to?(method_name) ? results.__send__(method_name, *arguments, &block) : super + end + + def respond_to?(method_name, include_private = false) + results.respond_to?(method_name) || super + end + + # The number of total hits for a query + # + def total + if raw_response[HITS][TOTAL].respond_to?(:keys) + raw_response[HITS][TOTAL][VALUE] + else + raw_response[HITS][TOTAL] + end + end + + # The maximum score for a query + # + def max_score + raw_response[HITS][MAX_SCORE] + end + + # Yields [object, hit] pairs to the block + # + def each_with_hit(&block) + results.zip(raw_response[HITS][HITS]).each(&block) + end + + # Yields [object, hit] pairs and returns the result + # + def map_with_hit(&block) + results.zip(raw_response[HITS][HITS]).map(&block) + end + + # Return the collection of domain objects + # + # @example Iterate over the results + # + # results.map { |r| r.attributes[:title] } + # => ["Fox", "Dog"] + # + # @return [Array] + # + def results + @results ||= raw_response[HITS][HITS].map do |document| + repository.deserialize(document.to_hash) + end + end + + # Access the response returned from Elasticsearch by the client + # + # @example Access the aggregations in the response + # + # results = repository.search query: { match: { title: 'fox dog' } }, + # aggregations: { titles: { terms: { field: 'title' } } } + # results.response.aggregations.titles.buckets.map { |term| "#{term['key']}: #{term['doc_count']}" } + # # => ["brown: 1", "dog: 1", ...] + # + # @return [Elasticsearch::Model::HashWrapper] + # + def response + @response ||= Elasticsearch::Model::HashWrapper.new(raw_response) + end + end + end + end + end +end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/search.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/search.rb new file mode 100644 index 000000000..db7788682 --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/search.rb @@ -0,0 +1,122 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Persistence + module Repository + + # Returns a collection of domain objects by an Elasticsearch query + # + module Search + + # Returns a collection of domain objects by an Elasticsearch query + # + # Pass the query either as a string or a Hash-like object + # + # @example Return objects matching a simple query + # + # repository.search('fox or dog') + # + # @example Return objects matching a query in the Elasticsearch DSL + # + # repository.search(query: { match: { title: 'fox dog' } }) + # + # @example Define additional search parameters, such as highlighted excerpts + # + # results = repository.search(query: { match: { title: 'fox dog' } }, highlight: { fields: { title: {} } }) + # results.map_with_hit { |d,h| h.highlight.title.join } + # # => ["quick brown fox", "fast white dog"] + # + # @example Perform aggregations as part of the request + # + # results = repository.search query: { match: { title: 'fox dog' } }, + # aggregations: { titles: { terms: { field: 'title' } } } + # results.response.aggregations.titles.buckets.map { |term| "#{term['key']}: #{term['doc_count']}" } + # # => ["brown: 1", "dog: 1", ... ] + # + # @example Pass additional options to the search request, such as `size` + # + # repository.search query: { match: { title: 'fox dog' } }, size: 25 + # # GET http://localhost:9200/notes/note/_search + # # > {"query":{"match":{"title":"fox dog"}},"size":25} + # + # @param [ Hash, String ] query_or_definition The query or search definition. + # @param [ Hash ] options The search options. + # + # @return [Elasticsearch::Persistence::Repository::Response::Results] + # + def search(query_or_definition, options={}) + request = { index: index_name } + if query_or_definition.respond_to?(:to_hash) + request[:body] = query_or_definition.to_hash + elsif query_or_definition.is_a?(String) + request[:q] = query_or_definition + else + raise ArgumentError, "[!] Pass the search definition as a Hash-like object or pass the query as a String" + + " -- #{query_or_definition.class} given." + end + + Response::Results.new(self, client.search(request.merge(options))) + end + + # Return the number of domain object in the index + # + # @example Return the number of all domain objects + # + # repository.count + # # => 2 + # + # @example Return the count of domain object matching a simple query + # + # repository.count('fox or dog') + # # => 1 + # + # @example Return the count of domain object matching a query in the Elasticsearch DSL + # + # repository.count(query: { match: { title: 'fox dog' } }) + # # => 1 + # + # @param [ Hash, String ] query_or_definition The query or search definition. + # @param [ Hash ] options The search options. + # + # @return [Integer] + # + def count(query_or_definition=nil, options={}) + query_or_definition ||= { query: { match_all: {} } } + request = { index: index_name } + + if query_or_definition.respond_to?(:to_hash) + request[:body] = query_or_definition.to_hash + elsif query_or_definition.is_a?(String) + request[:q] = query_or_definition + else + raise ArgumentError, "[!] Pass the search definition as a Hash-like object or pass the query as a String" + + " -- #{query_or_definition.class} given." + end + + client.count(request.merge(options))[COUNT] + end + + private + + # The key for accessing the count in a Elasticsearch query response. + # + COUNT = 'count'.freeze + end + end + end +end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/serialize.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/serialize.rb new file mode 100644 index 000000000..fe68a7fbb --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/serialize.rb @@ -0,0 +1,106 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Persistence + module Repository + + # Provide serialization and deserialization between Ruby objects and Elasticsearch documents. + # + # Override these methods in your repository class to customize the logic. + # + module Serialize + + # Serialize the object for storing it in Elasticsearch. + # + # In the default implementation, call the `to_hash` method on the passed object. + # + # @param [ Object ] document The Ruby object to serialize. + # + # @return [ Hash ] The serialized document. + # + def serialize(document) + document.to_hash + end + + # Deserialize the document retrieved from Elasticsearch into a Ruby object. + # If no klass is set for the Repository then the raw document '_source' field will be returned. + # + # def deserialize(document) + # Note.new document[SOURCE] + # end + # + # @param [ Hash ] document The raw document. + # + # @return [ Object ] The deserialized object. + # + def deserialize(document) + klass ? klass.new(document[SOURCE]) : document[SOURCE] + end + + private + + # The key for document fields in an Elasticsearch query response. + # + SOURCE = '_source'.freeze + + # The key for the document type in an Elasticsearch query response. + # Note that it will be removed eventually, as multiple types in a single + # index are deprecated as of Elasticsearch 6.0. + # + TYPE = '_type'.freeze + + IDS = [:id, 'id', :_id, '_id'].freeze + + # Get a document ID from the document (assuming Hash or Hash-like object) + # + # @example + # repository.__get_id_from_document title: 'Test', id: 'abc123' + # => "abc123" + # + # @api private + # + def __get_id_from_document(document) + document[IDS.find { |id| document[id] }] + end + + # Extract a document ID from the document (assuming Hash or Hash-like object) + # + # @note Calling this method will *remove* the `id` or `_id` key from the passed object. + # + # @example + # options = { title: 'Test', id: 'abc123' } + # repository.__extract_id_from_document options + # # => "abc123" + # options + # # => { title: 'Test' } + # + # @api private + # + def __extract_id_from_document(document) + IDS.inject(nil) do |deleted, id| + if document[id] + document.delete(id) + else + deleted + end + end + end + end + end + end +end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/repository/store.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/store.rb new file mode 100644 index 000000000..0986d5540 --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/repository/store.rb @@ -0,0 +1,103 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Persistence + module Repository + + # Save and delete documents in Elasticsearch + # + module Store + + # Store the serialized object in Elasticsearch + # + # @example + # repository.save(myobject) + # => {"_index"=>"...", "_id"=>"...", "_version"=>1, "created"=>true} + # + # @param [ Object ] document The document to save into Elasticsearch. + # @param [ Hash ] options The save request options. + # + # @return [ Hash ] The response from Elasticsearch + # + def save(document, options={}) + serialized = serialize(document) + id = __get_id_from_document(serialized) + request = { index: index_name, + id: id, + body: serialized } + client.index(request.merge(options)) + end + + # Update the serialized object in Elasticsearch with partial data or script + # + # @example Update the document with partial data + # + # repository.update id: 1, title: 'UPDATED', tags: [] + # # => {"_index"=>"...", "_id"=>"1", "_version"=>2} + # + # @example Update the document with a script + # + # repository.update 1, script: 'ctx._source.views += 1' + # # => {"_index"=>"...", "_id"=>"1", "_version"=>3} + # + # @param [ Object ] document_or_id The document to update or the id of the document to update. + # @param [ Hash ] options The update request options. + # + # @return [ Hash ] The response from Elasticsearch + # + def update(document_or_id, options = {}) + if document_or_id.is_a?(String) || document_or_id.is_a?(Integer) + id = document_or_id + body = options + else + document = serialize(document_or_id) + id = __extract_id_from_document(document) + if options[:script] + body = options + else + body = { doc: document }.merge(options) + end + end + client.update(index: index_name, id: id, body: body) + end + + # Remove the serialized object or document with specified ID from Elasticsearch + # + # @example Remove the document with ID 1 + # + # repository.delete(1) + # # => {"_index"=>"...", "_id"=>"1", "_version"=>4} + # + # @param [ Object ] document_or_id The document to delete or the id of the document to delete. + # @param [ Hash ] options The delete request options. + # + # @return [ Hash ] The response from Elasticsearch + # + def delete(document_or_id, options = {}) + if document_or_id.is_a?(String) || document_or_id.is_a?(Integer) + id = document_or_id + else + serialized = serialize(document_or_id) + id = __get_id_from_document(serialized) + end + client.delete({ index: index_name, id: id }.merge(options)) + end + end + end + end +end diff --git a/elasticsearch-persistence/lib/elasticsearch/persistence/version.rb b/elasticsearch-persistence/lib/elasticsearch/persistence/version.rb new file mode 100644 index 000000000..7fa2ce112 --- /dev/null +++ b/elasticsearch-persistence/lib/elasticsearch/persistence/version.rb @@ -0,0 +1,22 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Elasticsearch + module Persistence + VERSION = '8.0.0'.freeze + end +end diff --git a/elasticsearch-persistence/spec/repository/find_spec.rb b/elasticsearch-persistence/spec/repository/find_spec.rb new file mode 100644 index 000000000..22cc5af5e --- /dev/null +++ b/elasticsearch-persistence/spec/repository/find_spec.rb @@ -0,0 +1,119 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Find do + after do + begin; repository.delete_index!; rescue; end + end + + let(:repository) do + DEFAULT_REPOSITORY + end + + describe '#exists?' do + context 'when the document exists' do + let(:id) do + repository.save(a: 1)['_id'] + end + + it 'returns true' do + expect(repository.exists?(id)).to be(true) + end + end + + context 'when the document does not exist' do + it 'returns false' do + expect(repository.exists?('1')).to be(false) + end + end + end + + describe '#find' do + context 'when options are not provided' do + context 'when a single id is provided' do + let!(:id) do + repository.save(a: 1)['_id'] + end + + it 'retrieves the document' do + expect(repository.find(id)).to eq('a' => 1) + end + end + + context 'when an array of ids is provided' do + let!(:ids) do + 3.times.collect do |i| + repository.save(a: i)['_id'] + end + end + + it 'retrieves the documents' do + expect(repository.find(ids)).to eq([ + { 'a' =>0 }, + { 'a' => 1 }, + { 'a' => 2 } + ]) + end + + context 'when some documents are found and some are not' do + before do + ids[1] = 22 + ids + end + + it 'returns nil in the result list for the documents not found' do + expect(repository.find(ids)).to eq([ + { 'a' =>0 }, + nil, + { 'a' => 2 } + ]) + end + end + end + + context 'when multiple ids are provided' do + let!(:ids) do + 3.times.collect do |i| + repository.save(a: i)['_id'] + end + end + + it 'retrieves the documents' do + expect(repository.find(*ids)).to eq([ + { 'a' =>0 }, + { 'a' => 1 }, + { 'a' => 2 } + ]) + end + end + + context 'when the document cannot be found' do + before do + begin; repository.create_index!; rescue; end + end + + it 'raises a DocumentNotFound exception' do + expect { + repository.find(1) + }.to raise_exception(Elasticsearch::Persistence::Repository::DocumentNotFound) + end + end + end + end +end diff --git a/elasticsearch-persistence/spec/repository/response/results_spec.rb b/elasticsearch-persistence/spec/repository/response/results_spec.rb new file mode 100644 index 000000000..c4ca44683 --- /dev/null +++ b/elasticsearch-persistence/spec/repository/response/results_spec.rb @@ -0,0 +1,146 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Response::Results do + + before(:all) do + class MyRepository + include Elasticsearch::Persistence::Repository + + def deserialize(document) + 'Object' + end + end + end + + let(:repository) do + MyRepository.new + end + + after(:all) do + if defined?(MyRepository) + Object.send(:remove_const, MyRepository.name) + end + end + + let(:response) do + { "took" => 2, + "timed_out" => false, + "_shards" => {"total" => 5, "successful" => 5, "failed" => 0}, + "hits" => { + "total" => 2, + "max_score" => 0.19, + "hits" => [ + { + "_index" => "my_index", + "_id" => "1", + "_score" => 0.19, + "_source" => {"id" => 1, "title" => "Test 1"} + }, + { + "_index" => "my_index", + "_id" => "2", + "_score" => 0.19, + "_source" => {"id" => 2, "title" => "Test 2"} + } + ] + } + } + end + + let(:results) do + described_class.new(repository, response) + end + + describe '#repository' do + + it 'should return the repository' do + expect(results.repository).to be(repository) + end + end + + describe '#response' do + + it 'returns the response' do + expect(results.response).to eq(response) + end + + it 'wraps the response in a HashWrapper' do + expect(results.response._shards.total).to eq(5) + end + + context 'when the response method is not called' do + + it 'does not create an instance of HashWrapper' do + expect(Elasticsearch::Model::HashWrapper).not_to receive(:new) + results + end + end + + context 'when the response method is called' do + + it 'does create an instance of HashWrapper' do + expect(Elasticsearch::Model::HashWrapper).to receive(:new) + results.response + end + end + end + + describe '#total' do + + it 'returns the total' do + expect(results.total).to eq(2) + end + end + + describe '#max_score' do + + it 'returns the max score' do + expect(results.max_score).to eq(0.19) + end + end + + describe '#each' do + + it 'delegates the method to the results' do + expect(results.size).to eq(2) + end + end + + describe '#each_with_hit' do + + it 'returns each deserialized object with the raw document' do + expect(results.each_with_hit { |pair| pair[0] = 'Obj'}).to eq(['Obj', 'Obj'].zip(response['hits']['hits'])) + end + end + + describe '#map_with_hit' do + + it 'returns the result of the block called on a pair of each raw document and the deserialized object' do + expect(results.map_with_hit { |pair| pair[0] }).to eq(['Object', 'Object']) + end + end + + describe '#raw_response' do + + it 'returns the raw response from Elasticsearch' do + expect(results.raw_response).to eq(response) + end + end +end diff --git a/elasticsearch-persistence/spec/repository/search_spec.rb b/elasticsearch-persistence/spec/repository/search_spec.rb new file mode 100644 index 000000000..d2e323791 --- /dev/null +++ b/elasticsearch-persistence/spec/repository/search_spec.rb @@ -0,0 +1,72 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Search do + after do + begin; repository.delete_index!; rescue; end + end + + let(:repository) do + DEFAULT_REPOSITORY + end + + describe '#search' do + before do + repository.save({ name: 'user' }, refresh: true) + end + + context 'when a query definition is provided as a hash' do + it 'searches' do + expect(repository.search({ query: { match: { name: 'user' } } }).first).to eq('name' => 'user') + end + end + + context 'when a query definition is provided as a string' do + it 'searches' do + expect(repository.search('user').first).to eq('name' => 'user') + end + end + + context 'when the query definition is neither a String nor a Hash' do + it 'raises an ArgumentError' do + expect { + repository.search(1) + }.to raise_exception(ArgumentError) + end + end + end + + describe '#count' do + before do + repository.save({ name: 'usuario' }, refresh: true) + end + + context 'when a query definition is provided as a hash' do + it 'uses the default document type' do + expect(repository.count({ query: { match: { name: 'usuario' } } })).to eq(1) + end + end + + context 'when a query definition is provided as a string' do + it 'uses the default document type' do + expect(repository.count('usuario')).to eq(1) + end + end + end +end diff --git a/elasticsearch-persistence/spec/repository/serialize_spec.rb b/elasticsearch-persistence/spec/repository/serialize_spec.rb new file mode 100644 index 000000000..17e358686 --- /dev/null +++ b/elasticsearch-persistence/spec/repository/serialize_spec.rb @@ -0,0 +1,70 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Serialize do + + let(:repository) do + DEFAULT_REPOSITORY + end + + describe '#serialize' do + + before do + class MyDocument + def to_hash + { a: 1 } + end + end + end + + it 'calls #to_hash on the object' do + expect(repository.serialize(MyDocument.new)).to eq(a: 1) + end + end + + describe '#deserialize' do + + context 'when klass is defined on the Repository' do + + let(:repository) do + require 'set' + MyTestRepository.new(klass: Set) + end + + it 'instantiates an object of the klass' do + expect(repository.deserialize('_source' => { a: 1 })).to be_a(Set) + end + + it 'uses the source field to instantiate the object' do + expect(repository.deserialize('_source' => { a: 1 })).to eq(Set.new({ a: 1})) + end + end + + context 'when klass is not defined on the Repository' do + + it 'returns the raw Hash' do + expect(repository.deserialize('_source' => { a: 1 })).to be_a(Hash) + end + + it 'uses the source field to instantiate the object' do + expect(repository.deserialize('_source' => { a: 1 })).to eq(a: 1) + end + end + end +end diff --git a/elasticsearch-persistence/spec/repository/store_spec.rb b/elasticsearch-persistence/spec/repository/store_spec.rb new file mode 100644 index 000000000..5be0b5df8 --- /dev/null +++ b/elasticsearch-persistence/spec/repository/store_spec.rb @@ -0,0 +1,304 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository::Store do + let(:repository) do + DEFAULT_REPOSITORY + end + + after do + begin; repository.delete_index!; rescue; end + end + + describe '#save' do + let(:document) do + { a: 1 } + end + + let(:response) do + repository.save(document) + end + + it 'saves the document' do + expect(repository.find(response['_id'])).to eq('a' => 1) + end + + context 'when the repository defines a custom serialize method' do + before do + class OtherNoteRepository + include Elasticsearch::Persistence::Repository + def serialize(document) + { b: 1 } + end + end + end + + after do + if defined?(OtherNoteRepository) + Object.send(:remove_const, OtherNoteRepository.name) + end + end + + let(:repository) do + OtherNoteRepository.new(client: DEFAULT_CLIENT) + end + + let(:response) do + repository.save(document) + end + + it 'saves the document' do + expect(repository.find(response['_id'])).to eq('b' => 1) + end + end + end + + describe '#update' do + before(:all) do + class Note + def to_hash + { text: 'testing', views: 0 } + end + end + end + + after(:all) do + if defined?(Note) + Object.send(:remove_const, :Note) + end + end + + context 'when the document exists' do + let!(:id) do + repository.save(Note.new)['_id'] + end + + context 'when an id is provided' do + context 'when a doc is specified in the options' do + before do + repository.update(id, doc: { text: 'testing_2' }) + end + + it 'updates using the doc parameter' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + + context 'when a script is specified in the options' do + before do + repository.update(id, script: { inline: 'ctx._source.views += 1' }) + end + + it 'updates using the script parameter' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 1) + end + end + + context 'when params are specified in the options' do + before do + repository.update(id, script: { inline: 'ctx._source.views += params.count', + params: { count: 2 } }) + end + + it 'updates using the script parameter' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 2) + end + end + + context 'when upsert is specified in the options' do + before do + repository.update(id, script: { inline: 'ctx._source.views += 1' }, + upsert: { text: 'testing_2' }) + end + + it 'executes the script' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 1) + end + end + + context 'when doc_as_upsert is specified in the options' do + before do + repository.update(id, doc: { text: 'testing_2' }, + doc_as_upsert: true) + end + + it 'performs an upsert' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + end + + context 'when a document is provided as the query criteria' do + context 'when no options are provided' do + before do + repository.update(id: id, text: 'testing_2') + end + + it 'updates using the id and the document as the doc parameter' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + + context 'when options are provided' do + context 'when a doc is specified in the options' do + before do + repository.update({ id: id, text: 'testing' }, doc: { text: 'testing_2' }) + end + + it 'updates using the id and the doc in the options' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + + context 'when a script is specified in the options' do + before do + repository.update( + { id: id, text: 'testing' }, + script: { inline: 'ctx._source.views += 1' } + ) + end + + it 'updates using the id and script from the options' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 1) + end + end + + context 'when params are specified in the options' do + before do + repository.update({ id: id, text: 'testing' }, + script: { inline: 'ctx._source.views += params.count', + params: { count: 2 } }) + end + + it 'updates using the id and script and params from the options' do + expect(repository.find(id)).to eq('text' => 'testing', 'views' => 2) + end + end + + context 'when upsert is specified in the options' do + before do + repository.update({ id: id, text: 'testing_2' }, + doc_as_upsert: true) + end + + it 'updates using the id and script and params from the options' do + expect(repository.find(id)).to eq('text' => 'testing_2', 'views' => 0) + end + end + end + end + end + + context 'when the document does not exist' do + context 'when an id is provided 'do + it 'raises an exception' do + expect { + repository.update(1, doc: { text: 'testing_2' }) + }.to raise_exception(Elastic::Transport::Transport::Errors::NotFound) + end + + context 'when upsert is provided' do + before do + repository.update(1, doc: { text: 'testing' }, doc_as_upsert: true) + end + + it 'upserts the document' do + expect(repository.find(1)).to eq('text' => 'testing') + end + end + end + + context 'when a document is provided' do + it 'raises an exception' do + expect { + repository.update(id: 1, text: 'testing_2') + }.to raise_exception(Elastic::Transport::Transport::Errors::NotFound) + end + + context 'when upsert is provided' do + before do + repository.update({ id: 1, text: 'testing' }, doc_as_upsert: true) + end + + it 'upserts the document' do + expect(repository.find(1)).to eq('text' => 'testing') + end + end + end + end + end + + describe '#delete' do + before(:all) do + class Note + def to_hash + { text: 'testing', views: 0 } + end + end + end + + after(:all) do + if defined?(Note) + Object.send(:remove_const, :Note) + end + end + + context 'when the document exists' do + let!(:id) do + repository.save(Note.new)['_id'] + end + + context 'an id is provided' do + before do + repository.delete(id) + end + + it 'deletes the document using the id' do + expect { + repository.find(id) + }.to raise_exception(Elasticsearch::Persistence::Repository::DocumentNotFound) + end + end + + context 'when a document is provided' do + before do + repository.delete(id: id, text: 'testing') + end + + it 'deletes the document using the document' do + expect { + repository.find(id) + }.to raise_exception(Elasticsearch::Persistence::Repository::DocumentNotFound) + end + end + end + + context 'when the document does not exist' do + before do + repository.create_index! + end + + it 'raises an exception' do + expect { + repository.delete(1) + }.to raise_exception(Elastic::Transport::Transport::Errors::NotFound) + end + end + end +end diff --git a/elasticsearch-persistence/spec/repository_spec.rb b/elasticsearch-persistence/spec/repository_spec.rb new file mode 100644 index 000000000..a605a5250 --- /dev/null +++ b/elasticsearch-persistence/spec/repository_spec.rb @@ -0,0 +1,665 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe Elasticsearch::Persistence::Repository do + describe '#create' do + before(:all) do + class RepositoryWithoutDSL + include Elasticsearch::Persistence::Repository + end + end + + after(:all) do + if defined?(RepositoryWithoutDSL) + Object.send(:remove_const, RepositoryWithoutDSL.name) + end + end + + it 'creates a repository object' do + expect(RepositoryWithoutDSL.create).to be_a(RepositoryWithoutDSL) + end + + context 'when a block is passed' do + let(:repository) do + RepositoryWithoutDSL.create do + mapping dynamic: 'strict' do + indexes :foo + end + end + end + + it 'executes the block on the instance' do + expect(repository.mapping.to_hash).to eq({ dynamic: 'strict', properties: { foo: { type: 'text' } } }) + end + + context 'when options are provided in the args and set in the block' do + let(:repository) do + RepositoryWithoutDSL.create(mapping: double('mapping', to_hash: {})) do + mapping dynamic: 'strict' do + indexes :foo + end + end + end + + it 'uses the options from the args' do + expect(repository.mapping.to_hash).to eq({}) + end + end + end + end + + describe '#initialize' do + before(:all) do + class RepositoryWithoutDSL + include Elasticsearch::Persistence::Repository + end + end + + after(:all) do + if defined?(RepositoryWithoutDSL) + Object.send(:remove_const, RepositoryWithoutDSL.name) + end + end + + after do + begin; repository.delete_index!; rescue; end + end + + context 'when options are not provided' do + let(:repository) do + RepositoryWithoutDSL.new + end + + it 'sets a default client' do + expect(repository.client).to be_a(Elasticsearch::Client) + end + + + it 'sets a default index name' do + expect(repository.index_name).to eq('repository') + end + + it 'does not set a klass' do + expect(repository.klass).to be_nil + end + end + + context 'when options are provided' do + let(:client) do + Elasticsearch::Client.new + end + + let(:repository) do + RepositoryWithoutDSL.new(client: client, index_name: 'users', klass: Array) + end + + it 'sets the client' do + expect(repository.client).to be(client) + end + + it 'sets index name' do + expect(repository.index_name).to eq('users') + end + + it 'sets the klass' do + expect(repository.klass).to eq(Array) + end + end + end + + context 'when the DSL module is included' do + before(:all) do + class RepositoryWithDSL + include Elasticsearch::Persistence::Repository + include Elasticsearch::Persistence::Repository::DSL + + index_name 'notes_repo' + klass Hash + client DEFAULT_CLIENT + + settings number_of_shards: 1, number_of_replicas: 0 do + mapping dynamic: 'strict' do + indexes :foo do + indexes :bar + end + indexes :baz + end + end + end + end + + after(:all) do + if defined?(RepositoryWithDSL) + Object.send(:remove_const, RepositoryWithDSL.name) + end + end + + after do + begin; repository.delete_index; rescue; end + end + + context '#client' do + it 'allows the value to be set only once on the class' do + RepositoryWithDSL.client(double('client', class: 'other_client')) + expect(RepositoryWithDSL.client).to be(DEFAULT_CLIENT) + end + + it 'sets the value at the class level' do + expect(RepositoryWithDSL.client).to be(DEFAULT_CLIENT) + end + + it 'sets the value as the default at the instance level' do + expect(RepositoryWithDSL.new.client).to be(DEFAULT_CLIENT) + end + + it 'allows the value to be overridden with options on the instance' do + expect(RepositoryWithDSL.new(client: double('client', instance: 'other')).client.instance).to eq('other') + end + end + + context '#klass' do + it 'allows the value to be set only once on the class' do + RepositoryWithDSL.klass(Array) + expect(RepositoryWithDSL.klass).to eq(Hash) + end + + it 'sets the value at the class level' do + expect(RepositoryWithDSL.klass).to eq(Hash) + end + + it 'sets the value as the default at the instance level' do + expect(RepositoryWithDSL.new.klass).to eq(Hash) + end + + it 'allows the value to be overridden with options on the instance' do + expect(RepositoryWithDSL.new(klass: Array).klass).to eq(Array) + end + + context 'when nil is passed to the method' do + + before do + RepositoryWithDSL.klass(nil) + end + + it 'allows the value to be set only once' do + expect(RepositoryWithDSL.klass).to eq(Hash) + end + end + end + + context '#index_name' do + it 'allows the value to be set only once on the class' do + RepositoryWithDSL.index_name('other_name') + expect(RepositoryWithDSL.index_name).to eq('notes_repo') + end + + it 'sets the value at the class level' do + expect(RepositoryWithDSL.index_name).to eq('notes_repo') + end + + it 'sets the value as the default at the instance level' do + expect(RepositoryWithDSL.new.index_name).to eq('notes_repo') + end + + it 'allows the value to be overridden with options on the instance' do + expect(RepositoryWithDSL.new(index_name: 'other_notes_repo').index_name).to eq('other_notes_repo') + end + end + + describe '#create_index!' do + context 'when the method is called on an instance' do + let(:repository) do + RepositoryWithDSL.new + end + + before do + begin; repository.delete_index!; rescue; end + repository.create_index! + end + + it 'creates the index' do + expect(repository.index_exists?).to be(true) + end + end + + context 'when the method is called on the class' do + it 'raises a NotImplementedError' do + expect { + RepositoryWithDSL.create_index! + }.to raise_exception(NotImplementedError) + end + end + end + + describe '#delete_index!' do + context 'when the method is called on an instance' do + let(:repository) do + RepositoryWithDSL.new + end + + before do + repository.create_index! + begin; repository.delete_index!; rescue; end + end + + it 'deletes the index' do + expect(repository.index_exists?).to be(false) + end + end + + context 'when the method is called on the class' do + it 'raises a NotImplementedError' do + expect { + RepositoryWithDSL.delete_index! + }.to raise_exception(NotImplementedError) + end + end + end + + describe '#refresh_index!' do + context 'when the method is called on an instance' do + let(:repository) do + RepositoryWithDSL.new + end + + before do + repository.create_index! + end + + it 'refreshes the index' do + expect(repository.refresh_index!['_shards']).to be_a(Hash) + end + end + + context 'when the method is called on the class' do + it 'raises a NotImplementedError' do + expect { + RepositoryWithDSL.refresh_index! + }.to raise_exception(NotImplementedError) + end + end + end + + describe '#index_exists?' do + context 'when the method is called on an instance' do + let(:repository) do + RepositoryWithDSL.new + end + + before do + repository.create_index! + end + + it 'determines if the index exists' do + expect(repository.index_exists?).to be(true) + end + + context 'when arguments are passed in' do + + it 'passes the arguments to the request' do + expect(repository.index_exists?(index: 'other')).to be(false) + end + end + end + + context 'when the method is called on the class' do + it 'raises a NotImplementedError' do + expect { + RepositoryWithDSL.index_exists? + }.to raise_exception(NotImplementedError) + end + end + end + + describe '#mapping' do + let(:expected_mapping) do + { + dynamic: 'strict', + properties: { + foo: { + type: 'object', + properties: { bar: { type: 'text' } } }, + baz: { type: 'text' } + } + } + end + + it 'sets the value at the class level' do + expect(RepositoryWithDSL.mapping.to_hash).to eq(expected_mapping) + end + + it 'sets the value as the default at the instance level' do + expect(RepositoryWithDSL.new.mapping.to_hash).to eq(expected_mapping) + end + + it 'allows the value to be overridden with options on the instance' do + expect(RepositoryWithDSL.new(mapping: double('mapping', to_hash: { note: {} })).mapping.to_hash).to eq(note: {}) + end + + context 'when the instance has a different document type' do + let(:expected_mapping) do + { + other_note: + { + dynamic: 'strict', + properties: { + foo: { + type: 'object', + properties: { bar: { type: 'text' } } + }, + baz: { type: 'text' } + } + } + } + end + end + end + + describe '#settings' do + it 'sets the value at the class level' do + expect(RepositoryWithDSL.settings.to_hash).to eq(number_of_shards: 1, number_of_replicas: 0) + end + + it 'sets the value as the default at the instance level' do + expect(RepositoryWithDSL.new.settings.to_hash).to eq(number_of_shards: 1, number_of_replicas: 0) + end + + it 'allows the value to be overridden with options on the instance' do + expect(RepositoryWithDSL.new(settings: { number_of_shards: 3 }).settings.to_hash).to eq({number_of_shards: 3}) + end + end + end + + context 'when the DSL module is not included' do + before(:all) do + class RepositoryWithoutDSL + include Elasticsearch::Persistence::Repository + end + end + + after(:all) do + if defined?(RepositoryWithoutDSL) + Object.send(:remove_const, RepositoryWithoutDSL.name) + end + end + + context '#client' do + + it 'does not define the method at the class level' do + expect { + RepositoryWithoutDSL.client + }.to raise_exception(NoMethodError) + end + + it 'sets a default on the instance' do + expect(RepositoryWithoutDSL.new.client).to be_a(Elasticsearch::Client) + end + + it 'allows the value to be overridden with options on the instance' do + expect(RepositoryWithoutDSL.new(client: double('client', object_id: 123)).client.object_id).to eq(123) + end + end + + context '#klass' do + + it 'does not define the method at the class level' do + expect { + RepositoryWithoutDSL.klass + }.to raise_exception(NoMethodError) + end + + it 'does not set a default on an instance' do + expect(RepositoryWithoutDSL.new.klass).to be_nil + end + + it 'allows the value to be overridden with options on the instance' do + expect(RepositoryWithoutDSL.new(klass: Array).klass).to eq(Array) + end + end + + + context '#index_name' do + it 'does not define the method at the class level' do + expect { + RepositoryWithoutDSL.index_name + }.to raise_exception(NoMethodError) + end + + it 'sets a default on the instance' do + expect(RepositoryWithoutDSL.new.index_name).to eq('repository') + end + + it 'allows the value to be overridden with options on the instance' do + expect(RepositoryWithoutDSL.new(index_name: 'notes_repository').index_name).to eq('notes_repository') + end + end + + describe '#create_index!' do + let(:repository) do + RepositoryWithoutDSL.new(client: DEFAULT_CLIENT) + end + + after do + begin; repository.delete_index!; rescue; end + end + + it 'does not define the method at the class level' do + expect { + RepositoryWithoutDSL.create_index! + }.to raise_exception(NoMethodError) + end + + it 'creates an index' do + repository.create_index! + expect(repository.index_exists?).to eq(true) + end + end + + describe '#delete_index!' do + let(:repository) do + RepositoryWithoutDSL.new(client: DEFAULT_CLIENT) + end + + it 'does not define the method at the class level' do + expect { + RepositoryWithoutDSL.delete_index! + }.to raise_exception(NoMethodError) + end + + it 'deletes an index' do + repository.create_index! + repository.delete_index! + expect(repository.index_exists?).to eq(false) + end + end + + describe '#refresh_index!' do + let(:repository) do + RepositoryWithoutDSL.new(client: DEFAULT_CLIENT) + end + + after do + begin; repository.delete_index!; rescue; end + end + + it 'does not define the method at the class level' do + expect { + RepositoryWithoutDSL.refresh_index! + }.to raise_exception(NoMethodError) + end + + it 'refreshes an index' do + repository.create_index! + expect(repository.refresh_index!['_shards']).to be_a(Hash) + end + end + + describe '#index_exists?' do + + let(:repository) do + RepositoryWithoutDSL.new(client: DEFAULT_CLIENT) + end + + after do + begin; repository.delete_index!; rescue; end + end + + it 'does not define the method at the class level' do + expect { + RepositoryWithoutDSL.index_exists? + }.to raise_exception(NoMethodError) + end + + it 'returns whether the index exists' do + repository.create_index! + expect(repository.index_exists?).to be(true) + end + end + + describe '#mapping' do + + it 'does not define the method at the class level' do + expect { + RepositoryWithoutDSL.mapping + }.to raise_exception(NoMethodError) + end + + it 'sets a default on an instance' do + expect(RepositoryWithoutDSL.new.mapping.to_hash).to eq(properties: {}) + end + + it 'allows the mapping to be set as an option' do + expect(RepositoryWithoutDSL.new(mapping: double('mapping', to_hash: { note: {} })).mapping.to_hash).to eq(note: {}) + end + + context 'when a block is passed to the create method' do + + let(:expected_mapping) do + { + dynamic: 'strict', + properties: { + foo: { + type: 'object', + properties: { bar: { type: 'text' } } + }, + baz: { type: 'text' } + } + } + end + + let(:repository) do + RepositoryWithoutDSL.create do + mapping dynamic: 'strict' do + indexes :foo do + indexes :bar + end + indexes :baz + end + end + end + + it 'allows the mapping to be set in the block' do + expect(repository.mapping.to_hash).to eq(expected_mapping) + end + + context 'when the mapping is set in the options' do + + let(:repository) do + RepositoryWithoutDSL.create(mapping: double('mapping', to_hash: { note: {} })) do + mapping dynamic: 'strict' do + indexes :foo do + indexes :bar + end + indexes :baz + end + end + end + + it 'uses the mapping from the options' do + expect(repository.mapping.to_hash).to eq(note: {}) + end + end + end + end + + describe '#settings' do + it 'does not define the method at the class level' do + expect { + RepositoryWithoutDSL.settings + }.to raise_exception(NoMethodError) + end + + it 'sets a default on an instance' do + expect(RepositoryWithoutDSL.new.settings.to_hash).to eq({}) + end + + it 'allows the settings to be set as an option' do + expect(RepositoryWithoutDSL.new(settings: double('settings', to_hash: {})).settings.to_hash).to eq({}) + end + + context 'when a block is passed to the #create method' do + let(:repository) do + RepositoryWithoutDSL.create do + settings number_of_shards: 1, number_of_replicas: 0 + end + end + + it 'allows the settings to be set with a block' do + expect(repository.settings.to_hash).to eq(number_of_shards: 1, number_of_replicas: 0) + end + + context 'when a mapping is set in the block as well' do + let(:expected_mapping) do + { + dynamic: 'strict', + properties: { + foo: { + type: 'object', + properties: { bar: { type: 'text' } } + }, + baz: { type: 'text' } + } + } + end + + let(:repository) do + RepositoryWithoutDSL.create do + settings number_of_shards: 1, number_of_replicas: 0 do + mapping dynamic: 'strict' do + indexes :foo do + indexes :bar + end + indexes :baz + end + end + end + end + + it 'allows the settings to be set with a block' do + expect(repository.settings.to_hash).to eq(number_of_shards: 1, number_of_replicas: 0) + end + + it 'allows the mapping to be set with a block' do + expect(repository.mappings.to_hash).to eq(expected_mapping) + end + end + end + end + end +end diff --git a/elasticsearch-persistence/spec/spec_helper.rb b/elasticsearch-persistence/spec/spec_helper.rb new file mode 100644 index 000000000..bf9ac3e07 --- /dev/null +++ b/elasticsearch-persistence/spec/spec_helper.rb @@ -0,0 +1,60 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'pry-nav' +require 'elasticsearch/persistence' + +unless defined?(ELASTICSEARCH_URL) + ELASTICSEARCH_URL = ENV['ELASTICSEARCH_URL'] || "localhost:#{(ENV['TEST_CLUSTER_PORT'] || 9200)}" +end + +RSpec.configure do |config| + config.formatter = 'documentation' + config.color = true + + config.before(:suite) { puts "Elasticsearch Version: #{DEFAULT_CLIENT.info['version']}" } + config.after(:suite) do + DEFAULT_CLIENT.indices.delete(index: '_all') + end +end + +# The default client to be used by the repositories. +# +# @since 6.0.0 +DEFAULT_CLIENT = Elasticsearch::Client.new(host: ELASTICSEARCH_URL, + tracer: (ENV['QUIET'] ? nil : ::Logger.new(STDERR)), + transport_options: { :ssl => { verify: false } }) + +class MyTestRepository + include Elasticsearch::Persistence::Repository + include Elasticsearch::Persistence::Repository::DSL + client DEFAULT_CLIENT +end + +# The default repository to be used by tests. +# +# @since 6.0.0 +DEFAULT_REPOSITORY = MyTestRepository.new(index_name: 'my_test_repository') + +# Get the Elasticsearch server version. +# +# @return [ String ] The version of Elasticsearch. +# +# @since 7.0.0 +def server_version(client = nil) + (client || DEFAULT_CLIENT).info['version']['number'] +end diff --git a/elasticsearch-rails/CHANGELOG.md b/elasticsearch-rails/CHANGELOG.md new file mode 100644 index 000000000..5bb4e13bd --- /dev/null +++ b/elasticsearch-rails/CHANGELOG.md @@ -0,0 +1,44 @@ +## 0.1.9 + +* Added checks for proper launch order and other updates to the example application templates +* Updated the example application to work with Elasticsearch 2.x +* Used the `suggest` method instead of `response['suggest']` in the application template + +## 0.1.8 + +* Added an example application template that loads settings from a file +* Added missing require in the seeds.rb file for the expert template +* Fixed double include of the aliased method (execute_without_instrumentation) +* Fixed the error when getting the search_controller_test.rb asset in `03-expert.rb` template +* Updated URLs for getting raw assets from Github in the `03-expert.rb` template + +## 0.1.7 + +* Updated dependencies for the gem and example applications +* Fixed various small errors in the `01-basic.rb` template +* Fixed error when inserting the Kaminari gem into Gemfile in the 02-pretty.rb template +* Fixed incorrect regex for adding Rails instrumentation into the application.rb in the `02-pretty.rb` template +* Fixed other small errors in the `02-pretty.rb` template +* Improved and added tests for the generated application from the `02-pretty.rb` template +* Added the `04-dsl.rb` template which uses the `elasticsearch-dsl` gem to build the search definition + +## 0.1.6 + +* Fixed errors in templates for the Rails example applications +* Fixed errors in the importing Rake task +* Refactored and updated the instrumentation support to allow integration with `Persistence::Model` + +## 0.1.5 + +* Fixed an exception when no suggestions were returned in the `03-expert` example application template + +## 0.1.2 + +* Allow passing an ActiveRecord scope to the importing Rake task + +## 0.1.1 + +* Improved the Rake tasks +* Improved the example application templates + +## 0.1.0 (Initial Version) diff --git a/elasticsearch-rails/Gemfile b/elasticsearch-rails/Gemfile index 204d24d2d..4be2dd014 100644 --- a/elasticsearch-rails/Gemfile +++ b/elasticsearch-rails/Gemfile @@ -1,8 +1,38 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + source 'https://rubygems.org' # Specify your gem's dependencies in elasticsearch-rails.gemspec gemspec -if File.exists? File.expand_path("../../elasticsearch-model", __FILE__) - gem 'elasticsearch-model', :path => File.expand_path("../../elasticsearch-model", __FILE__), :require => true +gem 'elasticsearch-model', + path: File.expand_path('../elasticsearch-model', __dir__), + require: false + +gem 'elasticsearch-persistence', + path: File.expand_path('../elasticsearch-persistence', __dir__), + require: false + +group :development, :testing do + gem 'pry-nav' + gem 'rspec' + unless defined?(JRUBY_VERSION) + gem 'sqlite3', '~> 1.4' + gem 'debug' + end end diff --git a/elasticsearch-rails/LICENSE.txt b/elasticsearch-rails/LICENSE.txt index 7dc94b3e5..d64569567 100644 --- a/elasticsearch-rails/LICENSE.txt +++ b/elasticsearch-rails/LICENSE.txt @@ -1,13 +1,202 @@ -Copyright (c) 2014 Elasticsearch -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ - http://www.apache.org/licenses/LICENSE-2.0 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/elasticsearch-rails/README.md b/elasticsearch-rails/README.md index bebdff4de..4ec8ac2f8 100644 --- a/elasticsearch-rails/README.md +++ b/elasticsearch-rails/README.md @@ -1,10 +1,24 @@ # Elasticsearch::Rails The `elasticsearch-rails` library is a companion for the -the [`elasticsearch-model`](https://github.com/elasticsearch/elasticsearch-rails/tree/master/elasticsearch-model) +the [`elasticsearch-model`](https://github.com/elastic/elasticsearch-rails/tree/main/elasticsearch-model) library, providing features suitable for Ruby on Rails applications. -The library is compatible with Ruby 1.9.3 and higher. +## Compatibility + +This library is compatible with Ruby 3.1 and higher. + +The version numbers follow the Elasticsearch major versions. Currently the `main` branch is compatible with version `8.x` of the Elasticsearch stack. + +| Rubygem | | Elasticsearch | +|:-------:|:-:|:-------------:| +| 0.1 | → | 1.x | +| 2.x | → | 2.x | +| 5.x | → | 5.x | +| 6.x | → | 6.x | +| 7.x | → | 7.x | +| 8.x | → | 8.x | +| main | → | 8.x | ## Installation @@ -14,11 +28,11 @@ Install the package from [Rubygems](https://rubygems.org): To use an unreleased version, either add it to your `Gemfile` for [Bundler](http://bundler.io): - gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git' + gem 'elasticsearch-rails', git: 'git://github.com/elastic/elasticsearch-rails.git', branch: '5.x' or install it from a source code checkout: - git clone https://github.com/elasticsearch/elasticsearch-rails.git + git clone https://github.com/elastic/elasticsearch-rails.git cd elasticsearch-rails/elasticsearch-rails bundle install rake install @@ -40,6 +54,14 @@ To import the records from your `Article` model, run: $ bundle exec rake environment elasticsearch:import:model CLASS='Article' ``` +To limit the imported records to a certain +ActiveRecord [scope](http://guides.rubyonrails.org/active_record_querying.html#scopes), +pass it to the task: + +```bash +$ bundle exec rake environment elasticsearch:import:model CLASS='Article' SCOPE='published' +``` + Run this command to display usage instructions: ```bash @@ -77,46 +99,51 @@ You should see the duration of the request to Elasticsearch as part of each log ### Rails Application Templates You can generate a fully working example Ruby on Rails application, with an `Article` model and a search form, -to play with (it even downloads _Elasticsearch_ itself, generates the application skeleton and leaves you with -a _Git_ repository to explore the steps and the code): +to play with (it generates the application skeleton and leaves you with a _Git_ repository to explore the +steps and the code) with the +[`01-basic.rb`](https://github.com/elastic/elasticsearch-rails/blob/main/elasticsearch-rails/lib/rails/templates/01-basic.rb) template: ```bash -rails new searchapp --skip --skip-bundle --template https://raw.github.com/elasticsearch/elasticsearch-rails/master/elasticsearch-rails/lib/rails/templates/01-basic.rb +rails new searchapp --skip --skip-bundle --template https://raw.github.com/elastic/elasticsearch-rails/main/elasticsearch-rails/lib/rails/templates/01-basic.rb ``` -Run the same command again, in the same folder, with the `02-pretty` template to add features such as -a custom `Article.search` method, result highlighting and [_Bootstrap_](http://getbootstrap.com) integration: +Run the same command again, in the same folder, with the +[`02-pretty`](https://github.com/elastic/elasticsearch-rails/blob/main/elasticsearch-rails/lib/rails/templates/02-pretty.rb) +template to add features such as a custom `Article.search` method, result highlighting and +[_Bootstrap_](http://getbootstrap.com) integration: ```bash -rails new searchapp --skip --skip-bundle --template https://raw.github.com/elasticsearch/elasticsearch-rails/master/elasticsearch-rails/lib/rails/templates/02-pretty.rb +rails new searchapp --skip --skip-bundle --template https://raw.github.com/elastic/elasticsearch-rails/main/elasticsearch-rails/lib/rails/templates/02-pretty.rb ``` -NOTE: A third, much more complex template, demonstrating other features such as faceted navigation or - query suggestions is being worked on. - -## TODO - -This is an initial release of the `elasticsearch-rails` library. Many more features are planned and/or -being worked on, such as: +Run the same command with the [`03-expert.rb`](https://github.com/elastic/elasticsearch-rails/blob/main/elasticsearch-rails/lib/rails/templates/03-expert.rb) +template to refactor the application into a more complex use case, +with couple of hundreds of The New York Times articles as the example content. +The template will extract the Elasticsearch integration into a `Searchable` "concern" module, +define complex mapping, custom serialization, implement faceted navigation and suggestions as a part of +a complex query, and add a _Sidekiq_-based worker for updating the index in the background. -* Rake tasks for convenient (re)indexing your models from the command line -* Hooking into Rails' notification system to display Elasticsearch related statistics in the application log -* Instrumentation support for NewRelic integration +```bash +rails new searchapp --skip --skip-bundle --template https://raw.github.com/elastic/elasticsearch-rails/main/elasticsearch-rails/lib/rails/templates/03-expert.rb +``` ## License This software is licensed under the Apache 2 license, quoted below. - Copyright (c) 2014 Elasticsearch - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. + Licensed to Elasticsearch B.V. under one or more contributor + license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright + ownership. Elasticsearch B.V. licenses this file to you under + the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. diff --git a/elasticsearch-rails/Rakefile b/elasticsearch-rails/Rakefile index 3cf581a91..bbd245469 100644 --- a/elasticsearch-rails/Rakefile +++ b/elasticsearch-rails/Rakefile @@ -1,37 +1,51 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + require "bundler/gem_tasks" desc "Run unit tests" -task :default => 'test:unit' -task :test => 'test:unit' +task default: 'test:unit' +task test: 'test:unit' # ----- Test tasks ------------------------------------------------------------ require 'rake/testtask' +require 'rspec/core/rake_task' + namespace :test do - task :ci_reporter do - ENV['CI_REPORTS'] ||= 'tmp/reports' - require 'ci/reporter/rake/minitest' - Rake::Task['ci:setup:minitest'].invoke - end + RSpec::Core::RakeTask.new(:spec) - Rake::TestTask.new(:unit) do |test| - Rake::Task['test:ci_reporter'].invoke if ENV['CI'] - test.libs << 'lib' << 'test' - test.test_files = FileList["test/unit/**/*_test.rb"] - # test.verbose = true - # test.warning = true + Rake::TestTask.new(:all) do |test| + test.verbose = false + test.warning = false + test.deps = [:spec] unless defined?(JRUBY_VERSION) end - Rake::TestTask.new(:integration) do |test| - Rake::Task['test:ci_reporter'].invoke if ENV['CI'] - test.libs << 'lib' << 'test' - test.test_files = FileList["test/integration/**/*_test.rb"] - end + task unit: :spec +end - Rake::TestTask.new(:all) do |test| - Rake::Task['test:ci_reporter'].invoke if ENV['CI'] - test.libs << 'lib' << 'test' - test.test_files = FileList["test/unit/**/*_test.rb", "test/integration/**/*_test.rb"] +namespace :bundle do + desc 'Install gem dependencies' + task :install do + puts '-' * 80 + Bundler.with_unbundled_env do + sh 'bundle install' + end + puts '-' * 80 end end diff --git a/elasticsearch-rails/elasticsearch-rails.gemspec b/elasticsearch-rails/elasticsearch-rails.gemspec index 43c2e5ae8..f7f2f5077 100644 --- a/elasticsearch-rails/elasticsearch-rails.gemspec +++ b/elasticsearch-rails/elasticsearch-rails.gemspec @@ -1,47 +1,67 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # coding: utf-8 lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'elasticsearch/rails/version' Gem::Specification.new do |s| - s.name = "elasticsearch-rails" + s.name = 'elasticsearch-rails' s.version = Elasticsearch::Rails::VERSION - s.authors = ["Karel Minarik"] - s.email = ["karel.minarik@elasticsearch.org"] - s.description = "Ruby on Rails integrations for Elasticsearch." - s.summary = "Ruby on Rails integrations for Elasticsearch." - s.homepage = "https://github.com/elasticsearch/elasticsearch-rails/" - s.license = "Apache 2" + s.authors = ['Elastic Client Library Maintainers'] + s.email = ['client-libs@elastic.co'] + s.description = 'Ruby on Rails integrations for Elasticsearch.' + s.summary = 'Ruby on Rails integrations for Elasticsearch.' + s.homepage = 'https://github.com/elasticsearch/elasticsearch-rails/' + s.license = 'Apache 2' + s.metadata = { + 'homepage_uri' => 'https://www.elastic.co/guide/en/elasticsearch/client/ruby-api/current/ruby_on_rails.html', + 'changelog_uri' => 'https://github.com/elastic/elasticsearch-rails/blob/main/CHANGELOG.md', + 'source_code_uri' => 'https://github.com/elastic/elasticsearch-rails/', + 'bug_tracker_uri' => 'https://github.com/elastic/elasticsearch-rails/issues' + } s.files = `git ls-files`.split($/) s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } s.test_files = s.files.grep(%r{^(test|spec|features)/}) - s.require_paths = ["lib"] - - s.extra_rdoc_files = [ "README.md", "LICENSE.txt" ] - s.rdoc_options = [ "--charset=UTF-8" ] - - s.add_development_dependency "bundler", "~> 1.3" - s.add_development_dependency "rake" - - s.add_development_dependency "elasticsearch-extensions" - - s.add_development_dependency "oj" - s.add_development_dependency "rails", "> 3.0" + s.require_paths = ['lib'] - s.add_development_dependency "lograge" + s.extra_rdoc_files = ['README.md', 'LICENSE.txt'] + s.rdoc_options = ['--charset=UTF-8'] - s.add_development_dependency "shoulda-context" - s.add_development_dependency "mocha" - s.add_development_dependency "turn" - s.add_development_dependency "yard" - s.add_development_dependency "ruby-prof" - s.add_development_dependency "pry" - s.add_development_dependency "ci_reporter" + s.required_ruby_version = '>= 3' - if defined?(RUBY_VERSION) && RUBY_VERSION > '1.9' - s.add_development_dependency "simplecov" - s.add_development_dependency "cane" - s.add_development_dependency "require-prof" + s.add_development_dependency 'bundler' + s.add_development_dependency 'cane' + s.add_development_dependency 'lograge' + s.add_development_dependency 'minitest' + s.add_development_dependency 'mocha' + s.add_development_dependency 'pry' + s.add_development_dependency 'rails', '> 3.1' + s.add_development_dependency 'rake', '~> 12' + s.add_development_dependency 'require-prof' + s.add_development_dependency 'shoulda-context' + s.add_development_dependency 'simplecov' + s.add_development_dependency 'test-unit' + s.add_development_dependency 'turn' + s.add_development_dependency 'yard' + unless defined?(JRUBY_VERSION) + s.add_development_dependency 'oj' + s.add_development_dependency 'ruby-prof' end end diff --git a/elasticsearch-rails/lib/elasticsearch/rails.rb b/elasticsearch-rails/lib/elasticsearch/rails.rb index f425f7276..1f6bac36f 100644 --- a/elasticsearch-rails/lib/elasticsearch/rails.rb +++ b/elasticsearch-rails/lib/elasticsearch/rails.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + require "elasticsearch/rails/version" module Elasticsearch diff --git a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation.rb b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation.rb index 081791ab5..ca210066a 100644 --- a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation.rb +++ b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + require 'elasticsearch/rails/instrumentation/railtie' require 'elasticsearch/rails/instrumentation/publishers' diff --git a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/controller_runtime.rb b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/controller_runtime.rb index 461387c80..a26765c54 100644 --- a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/controller_runtime.rb +++ b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/controller_runtime.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + require 'active_support/core_ext/module/attr_internal' module Elasticsearch diff --git a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/log_subscriber.rb b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/log_subscriber.rb index c02bc0704..a9f112f5d 100644 --- a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/log_subscriber.rb +++ b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/log_subscriber.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Rails module Instrumentation @@ -29,8 +46,19 @@ def search(event) payload = event.payload name = "#{payload[:klass]} #{payload[:name]} (#{event.duration.round(1)}ms)" search = payload[:search].inspect.gsub(/:(\w+)=>/, '\1: ') + debug %Q| #{color(name, GREEN, color_option(true))} #{colorize_logging ? "\e[2m#{search}\e[0m" : search}| + end + + private + + def color_option(bold_value) + new_color_syntax? ? { bold: bold_value } : bold_value + end + + def new_color_syntax? + return @new_color_syntax if defined?(@new_color_syntax) - debug %Q| #{color(name, GREEN, true)} #{colorize_logging ? "\e[2m#{search}\e[0m" : search}| + @new_color_syntax = ::Rails.respond_to?(:gem_version) && ::Rails.gem_version >= '7.1' end end diff --git a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/publishers.rb b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/publishers.rb index 4de42fb71..2c36d91bd 100644 --- a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/publishers.rb +++ b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/publishers.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Rails module Instrumentation @@ -9,10 +26,14 @@ module Publishers # @see http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html # module SearchRequest - extend ActiveSupport::Concern - included do - alias_method_chain :execute!, :instrumentation + def self.included(base) + base.class_eval do + unless method_defined?(:execute_without_instrumentation!) + alias_method :execute_without_instrumentation!, :execute! + alias_method :execute!, :execute_with_instrumentation! + end + end end # Wrap `Search#execute!` and perform instrumentation diff --git a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/railtie.rb b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/railtie.rb index 03e9d9797..38fdbc6f1 100644 --- a/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/railtie.rb +++ b/elasticsearch-rails/lib/elasticsearch/rails/instrumentation/railtie.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Rails module Instrumentation @@ -14,7 +31,7 @@ class Railtie < ::Rails::Railtie Elasticsearch::Model::Searching::SearchRequest.class_eval do include Elasticsearch::Rails::Instrumentation::Publishers::SearchRequest - end + end if defined?(Elasticsearch::Model::Searching::SearchRequest) ActiveSupport.on_load(:action_controller) do include Elasticsearch::Rails::Instrumentation::ControllerRuntime diff --git a/elasticsearch-rails/lib/elasticsearch/rails/lograge.rb b/elasticsearch-rails/lib/elasticsearch/rails/lograge.rb index ee68b46f9..d2bd06467 100644 --- a/elasticsearch-rails/lib/elasticsearch/rails/lograge.rb +++ b/elasticsearch-rails/lib/elasticsearch/rails/lograge.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Rails module Lograge @@ -21,17 +38,17 @@ class Railtie < ::Rails::Railtie require 'elasticsearch/rails/instrumentation/log_subscriber' require 'elasticsearch/rails/instrumentation/controller_runtime' - config.lograge.custom_options = lambda do |event| - { es: event.payload[:elasticsearch_runtime].to_f.round(2) } - end - Elasticsearch::Model::Searching::SearchRequest.class_eval do include Elasticsearch::Rails::Instrumentation::Publishers::SearchRequest - end + end if defined?(Elasticsearch::Model::Searching::SearchRequest) ActiveSupport.on_load(:action_controller) do include Elasticsearch::Rails::Instrumentation::ControllerRuntime end + + config.lograge.custom_options = lambda do |event| + { es: event.payload[:elasticsearch_runtime].to_f.round(2) } + end end end diff --git a/elasticsearch-rails/lib/elasticsearch/rails/tasks/import.rb b/elasticsearch-rails/lib/elasticsearch/rails/tasks/import.rb index 709164f51..dcdc82d89 100644 --- a/elasticsearch-rails/lib/elasticsearch/rails/tasks/import.rb +++ b/elasticsearch-rails/lib/elasticsearch/rails/tasks/import.rb @@ -1,4 +1,21 @@ -# A collection of Rake tasks to facilitate importing data from yout models into Elasticsearch. +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# A collection of Rake tasks to facilitate importing data from your models into Elasticsearch. # # Add this e.g. into the `lib/tasks/elasticsearch.rake` file in your Rails application: # @@ -22,7 +39,7 @@ task :import => 'import:model' namespace :import do - desc <<-DESC.gsub(/ /, '') + import_model_desc = <<-DESC.gsub(/ /, '') Import data from your model (pass name as CLASS environment variable). $ rake environment elasticsearch:import:model CLASS='MyModel' @@ -35,8 +52,12 @@ Set target index name: $ rake environment elasticsearch:import:model CLASS='Article' INDEX='articles-new' + + Pass an ActiveRecord scope to limit the imported records: + $ rake environment elasticsearch:import:model CLASS='Article' SCOPE='published' DESC - task :model do + desc import_model_desc + task model: :environment do if ENV['CLASS'].to_s == '' puts '='*90, 'USAGE', '='*90, import_model_desc, "" exit(1) @@ -56,16 +77,17 @@ rescue NoMethodError; end end - klass.import force: ENV.fetch('FORCE', false), - batch_size: ENV.fetch('BATCH', 1000).to_i, - index: ENV.fetch('INDEX', nil), - type: ENV.fetch('TYPE', nil) do |response| + total_errors = klass.__elasticsearch__.import force: ENV.fetch('FORCE', false), + batch_size: ENV.fetch('BATCH', 1000).to_i, + index: ENV.fetch('INDEX', nil), + scope: ENV.fetch('SCOPE', nil) do |response| pbar.inc response['items'].size if pbar STDERR.flush STDOUT.flush end pbar.finish if pbar + puts "[IMPORT] #{total_errors} errors occurred" unless total_errors.zero? puts '[IMPORT] Done' end @@ -74,7 +96,7 @@ $ rake environment elasticsearch:import:all DIR=app/models DESC - task :all do + task all: :environment do dir = ENV['DIR'].to_s != '' ? ENV['DIR'] : Rails.root.join("app/models") puts "[IMPORT] Loading models from: #{dir}" diff --git a/elasticsearch-rails/lib/elasticsearch/rails/version.rb b/elasticsearch-rails/lib/elasticsearch/rails/version.rb index 1cbcedc0e..6506ad906 100644 --- a/elasticsearch-rails/lib/elasticsearch/rails/version.rb +++ b/elasticsearch-rails/lib/elasticsearch/rails/version.rb @@ -1,5 +1,22 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Elasticsearch module Rails - VERSION = "0.1.0" + VERSION = '8.0.0'.freeze end end diff --git a/elasticsearch-rails/lib/rails/templates/01-basic.rb b/elasticsearch-rails/lib/rails/templates/01-basic.rb index 4940b86ff..faf67489a 100644 --- a/elasticsearch-rails/lib/rails/templates/01-basic.rb +++ b/elasticsearch-rails/lib/rails/templates/01-basic.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # ===================================================================================================== # Template for generating a no-frills Rails application with support for Elasticsearch full-text search # ===================================================================================================== @@ -10,86 +27,94 @@ # # * Git # * Ruby >= 1.9.3 -# * Rails >= 4 -# * Java >= 7 (for Elasticsearch) +# * Rails >= 5 # # Usage: # ------ # -# $ rails new searchapp --skip --skip-bundle --template https://raw.github.com/elasticsearch/elasticsearch-rails/master/elasticsearch-rails/lib/rails/templates/01-basic.rb +# $ rails new searchapp --skip --skip-bundle --template https://raw.github.com/elasticsearch/elasticsearch-rails/main/elasticsearch-rails/lib/rails/templates/01-basic.rb # # ===================================================================================================== require 'uri' require 'net/http' +require 'json' -at_exit do - pid = File.read("#{destination_root}/tmp/pids/elasticsearch.pid") rescue nil - if pid - say_status "Stop", "Elasticsearch", :yellow - run "kill #{pid}" - end +$elasticsearch_url = ENV.fetch('ELASTICSEARCH_URL', 'http://localhost:9200') + +# ----- Check for Elasticsearch ------------------------------------------------------------------- + +required_elasticsearch_version = '7' + +docker_command =<<-CMD.gsub(/\s{1,}/, ' ').strip + docker run \ + --name elasticsearch-rails-searchapp \ + --publish 9200:9200 \ + --env "discovery.type=single-node" \ + --env "cluster.name=elasticsearch-rails" \ + --env "cluster.routing.allocation.disk.threshold_enabled=false" \ + --rm \ + docker.elastic.co/elasticsearch/elasticsearch-oss:7.6.0 +CMD + +begin + cluster_info = Net::HTTP.get(URI.parse($elasticsearch_url)) +rescue Errno::ECONNREFUSED => e + say_status "ERROR", "Cannot connect to Elasticsearch on <#{$elasticsearch_url}>\n\n", :red + say_status "", "The application requires an Elasticsearch cluster running, " + + "but no cluster has been found on <#{$elasticsearch_url}>." + say_status "", "The easiest way of launching Elasticsearch is by running it with Docker (https://www.docker.com/get-docker):\n\n" + say_status "", docker_command + "\n" + exit(1) +rescue StandardError => e + say_status "ERROR", "#{e.class}: #{e.message}", :red + exit(1) +end + +cluster_info = JSON.parse(cluster_info) + +unless cluster_info['version'] + say_status "ERROR", "Cannot determine Elasticsearch version from <#{$elasticsearch_url}>", :red + say_status "", JSON.dump(cluster_info), :red + exit(1) end +if cluster_info['version']['number'] < required_elasticsearch_version + say_status "ERROR", + "The application requires Elasticsearch version #{required_elasticsearch_version} or higher, found version #{cluster_info['version']['number']}.\n\n", :red + say_status "", "The easiest way of launching Elasticsearch is by running it with Docker (https://www.docker.com/get-docker):\n\n" + say_status "", docker_command + "\n" + exit(1) +end + +# ----- Application skeleton ---------------------------------------------------------------------- + run "touch tmp/.gitignore" -append_to_file ".gitignore", "vendor/elasticsearch-1.0.1/\n" +append_to_file ".gitignore", "vendor/elasticsearch-5.2.1/\n" git :init git add: "." git commit: "-m 'Initial commit: Clean application'" -# ----- Download Elasticsearch -------------------------------------------------------------------- - -unless (Net::HTTP.get(URI.parse('http://localhost:9200')) rescue false) - COMMAND = <<-COMMAND.gsub(/^ /, '') - curl -# -O "http://download.elasticsearch.org/elasticsearch/elasticsearch/elasticsearch-1.0.1.tar.gz" - tar -zxf elasticsearch-1.0.1.tar.gz - rm -f elasticsearch-1.0.1.tar.gz - ./elasticsearch-1.0.1/bin/elasticsearch -d -p #{destination_root}/tmp/pids/elasticsearch.pid - COMMAND - - puts "\n" - say_status "ERROR", "Elasticsearch not running!\n", :red - puts '-'*80 - say_status '', "It appears that Elasticsearch is not running on this machine." - say_status '', "Is it installed? Do you want me to install it for you with this command?\n\n" - COMMAND.each_line { |l| say_status '', "$ #{l}" } - puts - say_status '', "(To uninstall, just remove the generated application directory.)" - puts '-'*80, '' - - if yes?("Install Elasticsearch?", :bold) - puts - say_status "Install", "Elasticsearch", :yellow - - commands = COMMAND.split("\n") - exec = commands.pop - inside("vendor") do - commands.each { |command| run command } - run "(#{exec})" # Launch Elasticsearch in subshell - end - end -end unless ENV['RAILS_NO_ES_INSTALL'] - # ----- Add README -------------------------------------------------------------------------------- puts say_status "README", "Adding Readme...\n", :yellow puts '-'*80, ''; sleep 0.25 -remove_file 'README.rdoc' +remove_file 'README.md' -create_file 'README.rdoc', <<-README -= Ruby on Rails and Elasticsearch: Example application +create_file 'README.md', <<-README +# Ruby on Rails and Elasticsearch: Example application -This application is an example of integrating the {Elasticsearch}[http://www.elasticsearch.org] +This application is an example of integrating the {Elasticsearch}[https://www.elastic.co] search engine with the {Ruby On Rails}[http://rubyonrails.org] web framework. It has been generated by application templates available at -https://github.com/elasticsearch/elasticsearch-rails/tree/master/elasticsearch-rails/lib/rails/templates. +https://github.com/elasticsearch/elasticsearch-rails/tree/main/elasticsearch-rails/lib/rails/templates. -== [1] Basic +## [1] Basic The `basic` version provides a simple integration for a simple Rails model, `Article`, showing how to include the search engine support in your model, automatically index changes to records, @@ -115,8 +140,8 @@ # ----- Auxiliary gems ---------------------------------------------------------------------------- -gem 'turn', group: 'test' -gem 'mocha', group: 'test', require: 'mocha/setup' +gem 'mocha', group: 'test' +gem 'rails-controller-testing', group: 'test' # ----- Remove CoffeeScript, Sass and "all that jazz" --------------------------------------------- @@ -131,9 +156,9 @@ say_status "Rubygems", "Adding Elasticsearch libraries into Gemfile...\n", :yellow puts '-'*80, ''; sleep 0.75 -gem 'elasticsearch', git: 'git://github.com/elasticsearch/elasticsearch-ruby.git' -gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git' -gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git' +gem 'elasticsearch' +gem 'elasticsearch-model', git: 'https://github.com/elasticsearch/elasticsearch-rails.git', branch: 'main' +gem 'elasticsearch-rails', git: 'https://github.com/elasticsearch/elasticsearch-rails.git', branch: 'main' git add: "Gemfile*" @@ -146,9 +171,8 @@ puts '-'*80, ''; sleep 0.25 environment 'config.assets.logger = false', env: 'development' -gem 'quiet_assets', group: "development" +environment 'config.assets.quiet = true', env: 'development' -git add: "Gemfile*" git add: "config/" git commit: "-m 'Disabled asset logging in development'" @@ -184,6 +208,7 @@ class Article < ActiveRecord::Base include Elasticsearch::Model include Elasticsearch::Model::Callbacks + #{'attr_accessible :title, :content, :published_on' if Rails::VERSION::STRING < '4'} end CODE @@ -208,9 +233,10 @@ def search CODE end -inject_into_file 'app/views/articles/index.html.erb', after: %r{

Listing articles

} do +inject_into_file 'app/views/articles/index.html.erb', after: %r{

.*Articles

}i do <<-CODE +
<%= form_tag search_articles_path, method: 'get' do %> @@ -220,7 +246,6 @@ def search <% end %>
- CODE end @@ -236,21 +261,21 @@ def search end CODE -gsub_file 'test/controllers/articles_controller_test.rb', %r{setup do.*?end}m, <<-CODE +gsub_file "test/controllers/articles_controller_test.rb", %r{setup do.*?end}m, <<-CODE setup do @article = articles(:one) - Article.__elasticsearch__.import + Article.__elasticsearch__.import force: true Article.__elasticsearch__.refresh_index! end CODE -inject_into_file 'test/controllers/articles_controller_test.rb', after: %r{test "should get index" do.*?end}m do +inject_into_file "test/controllers/articles_controller_test.rb", after: %r{test "should get index" do.*?end}m do <<-CODE test "should get search results" do - get :search, q: 'mystring' + #{ Rails::VERSION::STRING > '5' ? 'get search_articles_url(q: "mystring")' : 'get :search, q: "mystring"' } assert_response :success assert_not_nil assigns(:articles) assert_equal 2, assigns(:articles).size @@ -313,6 +338,10 @@ def search git tag: "basic" git log: "--reverse --oneline" +# ----- Install Webpacker ------------------------------------------------------------------------- + +run 'rails webpacker:install' + # ----- Start the application --------------------------------------------------------------------- unless ENV['RAILS_NO_SERVER_START'] diff --git a/elasticsearch-rails/lib/rails/templates/02-pretty.rb b/elasticsearch-rails/lib/rails/templates/02-pretty.rb index af3f1ec4c..fb88e0c47 100644 --- a/elasticsearch-rails/lib/rails/templates/02-pretty.rb +++ b/elasticsearch-rails/lib/rails/templates/02-pretty.rb @@ -1,14 +1,34 @@ -# $ rails new searchapp --skip --skip-bundle --template https://raw.github.com/elasticsearch/elasticsearch-rails/master/elasticsearch-rails/lib/rails/templates/02-pretty.rb - -# (See: 01-basic.rb) +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# $ rails new searchapp --skip --skip-bundle --template https://raw.github.com/elasticsearch/elasticsearch-rails/main/elasticsearch-rails/lib/rails/templates/02-pretty.rb + +unless File.read('README.md').include? '## [1] Basic' + say_status "ERROR", "You have to run the 01-basic.rb template first.", :red + exit(1) +end puts say_status "README", "Updating Readme...\n", :yellow puts '-'*80, ''; sleep 0.25 -append_to_file 'README.rdoc', <<-README +append_to_file 'README.md', <<-README -== [2] Pretty +## [2] Pretty The `pretty` template builds on the `basic` version and brings couple of improvements: @@ -19,7 +39,7 @@ README -git add: "README.rdoc" +git add: "README.md" git commit: "-m '[02] Updated the application README'" # ----- Update application.rb --------------------------------------------------------------------- @@ -29,8 +49,8 @@ puts '-'*80, ''; sleep 0.25 insert_into_file 'config/application.rb', - "\n\nrequire 'elasticsearch/rails/instrumentation'\n", - after: 'Bundler.require(:default, Rails.env)' + "\n\nrequire 'elasticsearch/rails/instrumentation'", + after: /Bundler\.require.+$/ git add: "config/application.rb" git commit: "-m 'Added the Rails logger integration to application.rb'" @@ -43,7 +63,7 @@ # NOTE: Kaminari has to be loaded before Elasticsearch::Model so the callbacks are executed # -insert_into_file 'Gemfile', <<-CODE, before: 'gem "elasticsearch"' +insert_into_file 'Gemfile', <<-CODE, before: /gem ["']elasticsearch["'].+$/ # NOTE: Kaminari has to be loaded before Elasticsearch::Model so the callbacks are executed gem 'kaminari' @@ -86,19 +106,33 @@ def self.search(query) end CODE -gsub_file "test/models/article_test.rb", %r{# test "the truth" do.*?# end}m, <<-CODE +insert_into_file "#{Rails::VERSION::STRING > '4' ? 'test/models' : 'test/unit' }/article_test.rb", <<-CODE, after: /class ArticleTest < ActiveSupport::TestCase$/ + + teardown do + Article.__elasticsearch__.unstub(:search) + end + +CODE + +gsub_file "#{Rails::VERSION::STRING > '4' ? 'test/models' : 'test/unit' }/article_test.rb", %r{# test "the truth" do.*?# end}m, <<-CODE test "has a search method delegating to __elasticsearch__" do Article.__elasticsearch__.expects(:search).with do |definition| assert_equal 'foo', definition[:query][:multi_match][:query] + true end Article.search 'foo' end CODE +insert_into_file "test/test_helper.rb", + "require 'mocha/minitest'\n\n", + before: "class ActiveSupport::TestCase\n" + git add: "app/models/article.rb" -git add: "test/models/article_test.rb" +git add: "test/**/article_test.rb" +git add: "test/test_helper.rb" git commit: "-m 'Added an `Article.search` method'" # ----- Add loading Bootstrap assets -------------------------------------------------------------- @@ -114,8 +148,9 @@ def self.search(query) CODE insert_into_file 'app/views/layouts/application.html.erb', <<-CODE, before: '' - - + + + CODE git commit: "-a -m 'Added loading Bootstrap assets in the application layout'" @@ -130,20 +165,14 @@ def self.search(query) <<-CODE
<%= text_field_tag :q, params[:q], class: 'form-control', placeholder: 'Search...' %> - - - -
CODE end # ----- Customize the header ----------------------------------------------------------------- -gsub_file 'app/views/articles/index.html.erb', %r{

Listing articles

} do |match| - "

<%= controller.action_name == 'search' ? 'Searching articles' : 'Listing articles' %>

" +gsub_file 'app/views/articles/index.html.erb', %r{

.*Articles

} do |match| + "

<%= controller.action_name == 'search' ? 'Search results' : 'Articles' %>

" end # ----- Customize the results listing ------------------------------------------------------------- @@ -154,7 +183,7 @@ def self.search(query) gsub_file 'app/views/articles/index.html.erb', %r{<%= link_to [^%]+} do |match| match.gsub!('', '') - match.include?("btn") ? match : (match + ", class: 'btn btn-default btn-xs'") + match.include?("btn") ? match : (match + ", class: 'btn btn-outline-primary btn-sm'") end gsub_file 'app/views/articles/index.html.erb', %r{
\s*(<\%= link_to 'New Article'.*)}m do |content| @@ -178,6 +207,12 @@ def self.search(query) "\n " + match + ", class: 'btn btn-primary btn-xs', style: 'color: #fff'" end +# ----- Customize the form ----------------------------------------------------------------- + +gsub_file 'app/views/articles/_form.html.erb', %r{
} do |match| + %Q|
| +end + git add: "app/views" git commit: "-m 'Refactored the articles listing to use Bootstrap components'" @@ -217,9 +252,9 @@ def self.search(query) CODE end -generate "kaminari:views", "bootstrap", "--force" +generate "kaminari:views", "bootstrap3", "--force" -gsub_file 'app/views/kaminari/_paginator.html.erb', %r{
    }, '
      ' +gsub_file 'app/views/kaminari/_paginator.html.erb', %r{
@@ -47,13 +65,16 @@
-<% if @articles.size < 1 && (suggestions = @articles.response.response['suggest']) && suggestions.present? %> +<% if @articles.size < 1 && @articles.response.suggestions.present? %>

- No documents have been found. Maybe you mean - <%= suggestions.map { |k,v| v.first['options'] }.flatten.map {|v| v['text']}.uniq.map do |term| - link_to term, search_path(params.merge q: term) - end.to_sentence(last_word_connector: ' or ').html_safe %>? + No documents have been found. + <% if @articles.response.suggestions['suggest_title'].present? || @articles.response.suggestions['suggest_body'].present? %> + Maybe you mean + <%= @articles.response.suggestions.map { |k,v| v.first['options'] }.flatten.map {|v| v['text']}.uniq.map do |term| + link_to term, search_path(params.permit(:q, :a, :c, :s, :w, :comments).merge q: term) + end.to_sentence(last_word_connector: ' or ').html_safe %>? + <% end %>

<% end %> @@ -62,14 +83,14 @@ <% unless @articles.size < 1 %>
-

<%= link_to 'All Sections →'.html_safe, search_path(params.merge(c: nil))%>

+

<%= link_to 'All Sections →'.html_safe, search_path(params.permit(:q, :a, :c, :s, :w, :comments).merge(c: nil))%>

- <% @articles.response.response['facets']['categories']['terms'].each do |c| %> + <% @articles.response.response['aggregations']['categories']['categories']['buckets'].each do |c| %> <%= - link_to search_path(params.merge(c: c['term'])), - class: "list-group-item#{' active' if params[:c] == c['term']}" do - c['term'].titleize.html_safe + content_tag(:small, c['count'], class: 'badge').html_safe + link_to search_path(params.permit(:q, :a, :c, :s, :w, :comments).merge(c: c['key'])), + class: "list-group-item#{' active' if params[:c] == c['key']}" do + c['key'].titleize.html_safe + content_tag(:small, c['doc_count'], class: 'badge').html_safe end %> <% end %> @@ -77,14 +98,14 @@
-

<%= link_to 'All Authors →'.html_safe, search_path(params.merge(a: nil))%>

+

<%= link_to 'All Authors →'.html_safe, search_path(params.permit(:q, :a, :c, :s, :w, :comments).merge(a: nil))%>

- <% @articles.response.response['facets']['authors']['terms'].each do |a| %> + <% @articles.response.response['aggregations']['authors']['authors']['buckets'].each do |a| %> <%= - link_to search_path(params.merge(a: a['term'])), - class: "list-group-item#{' active' if params[:a] == a['term']}" do - a['term'].titleize.html_safe + content_tag(:small, a['count'], class: 'badge').html_safe + link_to search_path(params.permit(:q, :a, :c, :s, :w, :comments).merge(a: a['key'])), + class: "list-group-item#{' active' if params[:a] == a['key']}" do + a['key'].titleize.html_safe + content_tag(:small, a['doc_count'], class: 'badge').html_safe end %> <% end %> @@ -92,19 +113,19 @@
-

<%= link_to 'Any Date →'.html_safe, search_path(params.merge(w: nil))%>

+

<%= link_to 'Any Date →'.html_safe, search_path(params.permit(:q, :a, :c, :s, :w, :comments).merge(w: nil))%>

- <% @articles.response.response['facets']['published']['entries'].each do |w| %> + <% @articles.response.response['aggregations']['published']['published']['buckets'].each do |w| %> <%= - __start = Time.at(w['time']/1000) + __start = Time.at(w['key']/1000) __end = __start.end_of_week __date = __start.to_date.to_s(:iso) - link_to search_path(params.merge(w: __date)), + link_to search_path(params.permit(:q, :a, :c, :s, :w, :comments).merge(w: __date)), class: "list-group-item#{' active' if params[:w] == __date}" do "#{__start.to_date.to_s(:short)} — #{__end.to_date.to_s(:short)}".html_safe + \ - content_tag(:small, w['count'], class: 'badge').html_safe + content_tag(:small, w['doc_count'], class: 'badge').html_safe end %> <% end %> @@ -119,7 +140,7 @@

<%= (article.try(:highlight).try(:title) ? article.highlight.title.join.html_safe : article.title) %> - <%= article.categories.to_sentence %> + | <%= article.categories.to_sentence %>

diff --git a/elasticsearch-rails/lib/rails/templates/indexer.rb b/elasticsearch-rails/lib/rails/templates/indexer.rb index 407c06109..4ba92a45e 100644 --- a/elasticsearch-rails/lib/rails/templates/indexer.rb +++ b/elasticsearch-rails/lib/rails/templates/indexer.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # Indexer class for # # Run me with: @@ -20,7 +37,7 @@ def perform(operation, klass, record_id, options={}) record.__elasticsearch__.client = Client record.__elasticsearch__.__send__ "#{operation}_document" when /delete/ - Client.delete index: klass.constantize.index_name, type: klass.constantize.document_type, id: record_id + Client.delete index: klass.constantize.index_name, id: record_id else raise ArgumentError, "Unknown operation '#{operation}'" end end diff --git a/elasticsearch-rails/lib/rails/templates/search.css b/elasticsearch-rails/lib/rails/templates/search.css index a04b9a6a2..dd2211d49 100644 --- a/elasticsearch-rails/lib/rails/templates/search.css +++ b/elasticsearch-rails/lib/rails/templates/search.css @@ -54,9 +54,13 @@ form #form-options input { } .result h3.title { + font-size: 180% !important; font-family: 'Rokkitt', sans-serif; margin-top: 0; } +.result h3.title small { + font-size: 75% !important; +} .result .body { font-family: Georgia, serif; diff --git a/elasticsearch-rails/lib/rails/templates/search_controller_test.dsl.rb b/elasticsearch-rails/lib/rails/templates/search_controller_test.dsl.rb new file mode 100644 index 000000000..fd10ad9e1 --- /dev/null +++ b/elasticsearch-rails/lib/rails/templates/search_controller_test.dsl.rb @@ -0,0 +1,148 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'test_helper' +require 'sidekiq/api' + +class SearchControllerTest < ActionController::TestCase + setup do + travel_to Time.new(2015, 03, 16, 10, 00, 00, 0) + + Article.delete_all + + articles = [ + { title: 'Article One', abstract: 'One', content: 'One', published_on: 1.day.ago, category_title: 'One', author_first_name: 'John', author_last_name: 'Smith' }, + { title: 'Article One Another', abstract: '', content: '', published_on: 2.days.ago, category_title: 'One', author_first_name: 'John', author_last_name: 'Smith' }, + { title: 'Article One Two', abstract: '', content: '', published_on: 10.days.ago, category_title: 'Two', author_first_name: 'Mary', author_last_name: 'Smith' }, + { title: 'Article Two', abstract: '', content: '', published_on: 12.days.ago, category_title: 'Two', author_first_name: 'Mary', author_last_name: 'Smith' }, + { title: 'Article Three', abstract: '', content: '', published_on: 12.days.ago, category_title: 'Three', author_first_name: 'Alice', author_last_name: 'Smith' } + ] + + articles.each do |a| + article = Article.create! \ + title: a[:title], + abstract: a[:abstract], + content: a[:content], + published_on: a[:published_on] + + article.categories << Category.find_or_create_by!(title: a[:category_title]) + + article.authors << Author.find_or_create_by!(first_name: a[:author_first_name], last_name: a[:author_last_name]) + + article.save! + end + + Article.find_by_title('Article Three').comments.create body: 'One' + + Sidekiq::Queue.new("elasticsearch").clear + + Article.__elasticsearch__.import force: true + Article.__elasticsearch__.refresh_index! + end + + test "should return search results" do + get :index, params: { q: 'one' } + assert_response :success + assert_equal 3, assigns(:articles).size + end + + test "should return search results in comments" do + get :index, params: { q: 'one', comments: 'y' } + assert_response :success + assert_equal 4, assigns(:articles).size + end + + test "should return highlighted snippets" do + get :index, params: { q: 'one' } + assert_response :success + assert_match %r{One}, assigns(:articles).first.highlight.title.first + end + + test "should return suggestions" do + get :index, params: { q: 'one' } + assert_response :success + + suggestions = assigns(:articles).response.suggestions + + assert_equal 'one', suggestions['suggest_title'][0]['text'] + end + + test "should return aggregations" do + get :index, params: { q: 'one' } + assert_response :success + + aggregations = assigns(:articles).response.response['aggregations'] + + assert_equal 2, aggregations['categories']['categories']['buckets'].size + assert_equal 2, aggregations['authors']['authors']['buckets'].size + assert_equal 2, aggregations['published']['published']['buckets'].size + + assert_equal 'John Smith', aggregations['authors']['authors']['buckets'][0]['key'] + assert_equal 'One', aggregations['categories']['categories']['buckets'][0]['key'] + assert_equal '2015-03-02T00:00:00.000Z', aggregations['published']['published']['buckets'][0]['key_as_string'] + end + + test "should sort on the published date" do + get :index, params: { q: 'one', s: 'published_on' } + assert_response :success + + assert_equal 3, assigns(:articles).size + assert_equal '2015-03-15', assigns(:articles)[0].published_on + assert_equal '2015-03-14', assigns(:articles)[1].published_on + assert_equal '2015-03-06', assigns(:articles)[2].published_on + end + + test "should sort on the published date when no query is provided" do + get :index, params: { q: '' } + assert_response :success + + assert_equal 5, assigns(:articles).size + assert_equal '2015-03-15', assigns(:articles)[0].published_on + assert_equal '2015-03-14', assigns(:articles)[1].published_on + assert_equal '2015-03-06', assigns(:articles)[2].published_on + end + + test "should filter search results and the author and published date facets when user selects a category" do + get :index, params: { q: 'one', c: 'One' } + assert_response :success + + assert_equal 2, assigns(:articles).size + + aggregations = assigns(:articles).response.response['aggregations'] + + assert_equal 1, aggregations['authors']['authors']['buckets'].size + assert_equal 1, aggregations['published']['published']['buckets'].size + + # Do NOT filter the category facet + assert_equal 2, aggregations['categories']['categories']['buckets'].size + end + + test "should filter search results and the category and published date facets when user selects a category" do + get :index, params: { q: 'one', a: 'Mary Smith' } + assert_response :success + + assert_equal 1, assigns(:articles).size + + aggregations = assigns(:articles).response.response['aggregations'] + + assert_equal 1, aggregations['categories']['categories']['buckets'].size + assert_equal 1, aggregations['published']['published']['buckets'].size + + # Do NOT filter the authors facet + assert_equal 2, aggregations['authors']['authors']['buckets'].size + end +end diff --git a/elasticsearch-rails/lib/rails/templates/search_controller_test.rb b/elasticsearch-rails/lib/rails/templates/search_controller_test.rb new file mode 100644 index 000000000..d627ae528 --- /dev/null +++ b/elasticsearch-rails/lib/rails/templates/search_controller_test.rb @@ -0,0 +1,148 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'test_helper' + +class SearchControllerTest < ActionController::TestCase + setup do + travel_to Time.new(2015, 03, 16, 10, 00, 00, 0) + + Article.delete_all + + articles = [ + { title: 'Article One', abstract: 'One', content: 'One', published_on: 1.day.ago, category_title: 'One', author_first_name: 'John', author_last_name: 'Smith' }, + { title: 'Article One Another', abstract: '', content: '', published_on: 2.days.ago, category_title: 'One', author_first_name: 'John', author_last_name: 'Smith' }, + { title: 'Article One Two', abstract: '', content: '', published_on: 10.days.ago, category_title: 'Two', author_first_name: 'Mary', author_last_name: 'Smith' }, + { title: 'Article Two', abstract: '', content: '', published_on: 12.days.ago, category_title: 'Two', author_first_name: 'Mary', author_last_name: 'Smith' }, + { title: 'Article Three', abstract: '', content: '', published_on: 12.days.ago, category_title: 'Three', author_first_name: 'Alice', author_last_name: 'Smith' } + ] + + articles.each do |a| + article = Article.create! \ + title: a[:title], + abstract: a[:abstract], + content: a[:content], + published_on: a[:published_on] + + article.categories << Category.find_or_create_by!(title: a[:category_title]) + + article.authors << Author.find_or_create_by!(first_name: a[:author_first_name], last_name: a[:author_last_name]) + + article.save! + end + + Article.find_by_title('Article Three').comments.create body: 'One' + + Sidekiq::Worker.clear_all + + Article.__elasticsearch__.import force: true + Article.__elasticsearch__.refresh_index! + end + + test "should return search results" do + get :index, params: { q: 'one' } + assert_response :success + assert_equal 3, assigns(:articles).size + end + + test "should return search results in comments" do + get :index, params: { q: 'one', comments: 'y' } + assert_response :success + + assert_equal 4, assigns(:articles).size + end + + test "should return highlighted snippets" do + get :index, params: { q: 'one' } + assert_response :success + assert_match %r{One}, assigns(:articles).first.highlight.title.first + end + + test "should return suggestions" do + get :index, params: { q: 'one' } + assert_response :success + + suggestions = assigns(:articles).response.suggestions + + assert_equal 'one', suggestions['suggest_title'][0]['text'] + end + + test "should return facets" do + get :index, params: { q: 'one' } + assert_response :success + + aggregations = assigns(:articles).response.response['aggregations'] + + assert_equal 2, aggregations['categories']['categories']['buckets'].size + assert_equal 2, aggregations['authors']['authors']['buckets'].size + assert_equal 2, aggregations['published']['published']['buckets'].size + + assert_equal 'One', aggregations['categories']['categories']['buckets'][0]['key'] + assert_equal 'John Smith', aggregations['authors']['authors']['buckets'][0]['key'] + assert_equal 1425254400000, aggregations['published']['published']['buckets'][0]['key'] + end + + test "should sort on the published date" do + get :index, params: { q: 'one', s: 'published_on' } + assert_response :success + + assert_equal 3, assigns(:articles).size + assert_equal '2015-03-15', assigns(:articles)[0].published_on + assert_equal '2015-03-14', assigns(:articles)[1].published_on + assert_equal '2015-03-06', assigns(:articles)[2].published_on + end + + test "should sort on the published date when no query is provided" do + get :index, params: { q: '' } + assert_response :success + + assert_equal 5, assigns(:articles).size + assert_equal '2015-03-15', assigns(:articles)[0].published_on + assert_equal '2015-03-14', assigns(:articles)[1].published_on + assert_equal '2015-03-06', assigns(:articles)[2].published_on + end + + test "should filter search results and the author and published date facets when user selects a category" do + get :index, params: { q: 'one', c: 'One' } + assert_response :success + + assert_equal 2, assigns(:articles).size + + aggregations = assigns(:articles).response.response['aggregations'] + + assert_equal 1, aggregations['authors']['authors']['buckets'].size + assert_equal 1, aggregations['published']['published']['buckets'].size + + # Do NOT filter the category facet + assert_equal 2, aggregations['categories']['categories']['buckets'].size + end + + test "should filter search results and the category and published date facets when user selects a category" do + get :index, params: { q: 'one', a: 'Mary Smith' } + assert_response :success + + assert_equal 1, assigns(:articles).size + + aggregations = assigns(:articles).response.response['aggregations'] + + assert_equal 1, aggregations['categories']['categories']['buckets'].size + assert_equal 1, aggregations['published']['published']['buckets'].size + + # Do NOT filter the authors facet + assert_equal 2, aggregations['authors']['authors']['buckets'].size + end +end diff --git a/elasticsearch-rails/lib/rails/templates/searchable.dsl.rb b/elasticsearch-rails/lib/rails/templates/searchable.dsl.rb new file mode 100644 index 000000000..a0efeb997 --- /dev/null +++ b/elasticsearch-rails/lib/rails/templates/searchable.dsl.rb @@ -0,0 +1,234 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Searchable + extend ActiveSupport::Concern + + included do + include Elasticsearch::Model + + # Customize the index name + # + index_name [Rails.application.engine_name, Rails.env].join('_') + + # Set up index configuration and mapping + # + settings index: { number_of_shards: 1, number_of_replicas: 0 } do + mapping do + indexes :title, type: 'text' do + indexes :title, analyzer: 'snowball' + indexes :tokenized, analyzer: 'simple' + end + + indexes :content, type: 'text' do + indexes :content, analyzer: 'snowball' + indexes :tokenized, analyzer: 'simple' + end + + indexes :published_on, type: 'date' + + indexes :authors do + indexes :full_name, type: 'text' do + indexes :full_name + indexes :raw, type: 'keyword' + end + end + + indexes :categories, type: 'keyword' + + indexes :comments, type: 'nested' do + indexes :body, analyzer: 'snowball' + indexes :stars + indexes :pick + indexes :user, type: 'keyword' + indexes :user_location, type: 'text' do + indexes :user_location + indexes :raw, type: 'keyword' + end + end + end + end + + # Set up callbacks for updating the index on model changes + # + after_commit lambda { Indexer.perform_async(:index, self.class.to_s, self.id) }, on: :create + after_commit lambda { Indexer.perform_async(:update, self.class.to_s, self.id) }, on: :update + after_commit lambda { Indexer.perform_async(:delete, self.class.to_s, self.id) }, on: :destroy + after_touch lambda { Indexer.perform_async(:update, self.class.to_s, self.id) } + + # Customize the JSON serialization for Elasticsearch + # + def as_indexed_json(options={}) + hash = self.as_json( + include: { authors: { methods: [:full_name], only: [:full_name] }, + comments: { only: [:body, :stars, :pick, :user, :user_location] } + }) + hash['categories'] = self.categories.map(&:title) + hash + end + + # Return documents matching the user's query, include highlights and aggregations in response, + # and implement a "cross" faceted navigation + # + # @param q [String] The user query + # @return [Elasticsearch::Model::Response::Response] + # + def self.search(q, options={}) + @search_definition = Elasticsearch::DSL::Search.search do + query do + + # If a user query is present... + # + unless q.blank? + bool do + + # ... search in `title`, `abstract` and `content`, boosting `title` + # + should do + multi_match do + query q + fields ['title^10', 'abstract^2', 'content'] + operator 'and' + end + end + + # ... search in comment body if user checked the comments checkbox + # + if q.present? && options[:comments] + should do + nested do + path :comments + query do + multi_match do + query q + fields 'comments.body' + operator 'and' + end + end + end + end + end + end + + # ... otherwise, just return all articles + else + match_all + end + end + + # Filter the search results based on user selection + # + post_filter do + bool do + must { term categories: options[:category] } if options[:category] + must { match_all } if options.keys.none? { |k| [:c, :a, :w].include? k } + must { term 'authors.full_name.raw' => options[:author] } if options[:author] + must { range published_on: { gte: options[:published_week], lte: "#{options[:published_week]}||+1w" } } if options[:published_week] + end + end + + # Return top categories for faceted navigation + # + aggregation :categories do + # Filter the aggregation with any selected `author` and `published_week` + # + f = Elasticsearch::DSL::Search::Filters::Bool.new + f.must { match_all } + f.must { term 'authors.full_name.raw' => options[:author] } if options[:author] + f.must { range published_on: { gte: options[:published_week], lte: "#{options[:published_week]}||+1w" } } if options[:published_week] + + filter f.to_hash do + aggregation :categories do + terms field: 'categories' + end + end + end + + # Return top authors for faceted navigation + # + aggregation :authors do + # Filter the aggregation with any selected `category` and `published_week` + # + f = Elasticsearch::DSL::Search::Filters::Bool.new + f.must { match_all } + f.must { term categories: options[:category] } if options[:category] + f.must { range published_on: { gte: options[:published_week], lte: "#{options[:published_week]}||+1w" } } if options[:published_week] + + filter f do + aggregation :authors do + terms field: 'authors.full_name.raw' + end + end + end + + # Return the published date ranges for faceted navigation + # + aggregation :published do + # Filter the aggregation with any selected `author` and `category` + # + f = Elasticsearch::DSL::Search::Filters::Bool.new + f.must { match_all } + f.must { term 'authors.full_name.raw' => options[:author] } if options[:author] + f.must { term categories: options[:category] } if options[:category] + + filter f do + aggregation :published do + date_histogram do + field 'published_on' + interval 'week' + end + end + end + end + + # Highlight the snippets in results + # + highlight do + fields title: { number_of_fragments: 0 }, + abstract: { number_of_fragments: 0 }, + content: { fragment_size: 50 } + + field 'comments.body', fragment_size: 50 if q.present? && options[:comments] + + pre_tags '' + post_tags '' + end + + case + # By default, sort by relevance, but when a specific sort option is present, use it ... + # + when options[:sort] + sort options[:sort].to_sym => 'desc' + track_scores true + # + # ... when there's no user query, sort on published date + # + when q.blank? + sort published_on: 'desc' + end + + # Return suggestions unless there's no query from the user + unless q.blank? + suggest :suggest_title, text: q, term: { field: 'title.tokenized', suggest_mode: 'always' } + suggest :suggest_body, text: q, term: { field: 'content.tokenized', suggest_mode: 'always' } + end + end + + __elasticsearch__.search(@search_definition) + end + end +end diff --git a/elasticsearch-rails/lib/rails/templates/searchable.rb b/elasticsearch-rails/lib/rails/templates/searchable.rb index 0ff826150..cf7e4e800 100644 --- a/elasticsearch-rails/lib/rails/templates/searchable.rb +++ b/elasticsearch-rails/lib/rails/templates/searchable.rb @@ -1,3 +1,20 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + module Searchable extend ActiveSupport::Concern @@ -12,12 +29,12 @@ module Searchable # settings index: { number_of_shards: 1, number_of_replicas: 0 } do mapping do - indexes :title, type: 'multi_field' do + indexes :title, type: 'text' do indexes :title, analyzer: 'snowball' indexes :tokenized, analyzer: 'simple' end - indexes :content, type: 'multi_field' do + indexes :content, type: 'text' do indexes :content, analyzer: 'snowball' indexes :tokenized, analyzer: 'simple' end @@ -25,22 +42,22 @@ module Searchable indexes :published_on, type: 'date' indexes :authors do - indexes :full_name, type: 'multi_field' do + indexes :full_name, type: 'text' do indexes :full_name - indexes :raw, analyzer: 'keyword' + indexes :raw, type: 'keyword' end end - indexes :categories, analyzer: 'keyword' + indexes :categories, type: 'keyword' indexes :comments, type: 'nested' do indexes :body, analyzer: 'snowball' indexes :stars indexes :pick - indexes :user, analyzer: 'keyword' - indexes :user_location, type: 'multi_field' do + indexes :user, type: 'keyword' + indexes :user_location, type: 'text' do indexes :user_location - indexes :raw, analyzer: 'keyword' + indexes :raw, type: 'keyword' end end end @@ -71,15 +88,15 @@ def as_indexed_json(options={}) # def self.search(query, options={}) - # Prefill and set the filters (top-level `filter` and `facet_filter` elements) + # Prefill and set the filters (top-level `post_filter` and aggregation `filter` elements) # __set_filters = lambda do |key, f| + @search_definition[:post_filter][:bool] ||= {} + @search_definition[:post_filter][:bool][:must] ||= [] + @search_definition[:post_filter][:bool][:must] |= [f] - @search_definition[:filter][:and] ||= [] - @search_definition[:filter][:and] |= [f] - - @search_definition[:facets][key.to_sym][:facet_filter][:and] ||= [] - @search_definition[:facets][key.to_sym][:facet_filter][:and] |= [f] + @search_definition[:aggregations][key.to_sym][:filter][:bool][:must] ||= [] + @search_definition[:aggregations][key.to_sym][:filter][:bool][:must] |= [f] end @search_definition = { @@ -95,27 +112,22 @@ def self.search(query, options={}) } }, - filter: {}, + post_filter: { bool: { must: [ match_all: {} ] } }, - facets: { + aggregations: { categories: { - terms: { - field: 'categories' - }, - facet_filter: {} + filter: { bool: { must: [ match_all: {} ] } }, + aggregations: { categories: { terms: { field: 'categories' } } } }, authors: { - terms: { - field: 'authors.full_name.raw' - }, - facet_filter: {} + filter: { bool: { must: [ match_all: {} ] } }, + aggregations: { authors: { terms: { field: 'authors.full_name.raw' } } } }, published: { - date_histogram: { - field: 'published_on', - interval: 'week' - }, - facet_filter: {} + filter: { bool: { must: [ match_all: {} ] } }, + aggregations: { + published: { date_histogram: { field: 'published_on', interval: 'week' } } + } } } } @@ -174,7 +186,7 @@ def self.search(query, options={}) query: { multi_match: { query: query, - fields: ['body'], + fields: ['comments.body'], operator: 'and' } } diff --git a/elasticsearch-rails/lib/rails/templates/seeds.rb b/elasticsearch-rails/lib/rails/templates/seeds.rb index 6b7bacac8..7b7294d2d 100644 --- a/elasticsearch-rails/lib/rails/templates/seeds.rb +++ b/elasticsearch-rails/lib/rails/templates/seeds.rb @@ -1,9 +1,27 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + require 'zlib' require 'yaml' Zlib::GzipReader.open(File.expand_path('../articles.yml.gz', __FILE__)) do |gzip| puts "Reading articles from gzipped YAML..." - @documents = YAML.load_documents(gzip.read) + @documents = YAML.respond_to?(:load_documents) ? YAML.load_documents(gzip.read) : + YAML.load_stream(gzip.read) end # Truncate the default ActiveRecord logger output @@ -53,4 +71,5 @@ # Remove any jobs from the "elasticsearch" Sidekiq queue # +require 'sidekiq/api' Sidekiq::Queue.new("elasticsearch").clear diff --git a/elasticsearch-rails/spec/instrumentation/log_subscriber_spec.rb b/elasticsearch-rails/spec/instrumentation/log_subscriber_spec.rb new file mode 100644 index 000000000..7512f006a --- /dev/null +++ b/elasticsearch-rails/spec/instrumentation/log_subscriber_spec.rb @@ -0,0 +1,57 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' +require 'elasticsearch/rails/instrumentation/log_subscriber' + +describe Elasticsearch::Rails::Instrumentation::LogSubscriber do + subject(:instance) { described_class.new } + + let(:logger) { instance_double(Logger) } + + before do + allow(instance).to receive(:logger) { logger } + end + + describe "#search" do + subject { instance.search(event) } + + let(:event) { double("search.elasticsearch", duration: 1.2345, payload: { name: "execute", search: { query: { match_all: {}}}}) } + + it "logs the event" do + expect(instance).to receive(:color).with(" execute (1.2ms)", described_class::GREEN, { bold: true }).and_call_original + expect(logger).to receive(:debug?) { true } + expect(logger).to receive(:debug).with(" \e[1m\e[32m execute (1.2ms)\e[0m \e[2m{query: {match_all: {}}}\e[0m") + subject + end + + context "when Rails version is older" do + let(:rails_version) { "7.0.0" } + + before do + allow(::Rails).to receive(:gem_version) { Gem::Version.new(rails_version) } + end + + it "logs the event" do + expect(instance).to receive(:color).with(" execute (1.2ms)", described_class::GREEN, true).and_call_original + expect(logger).to receive(:debug?) { true } + expect(logger).to receive(:debug).with(" \e[1m\e[32m execute (1.2ms)\e[0m \e[2m{query: {match_all: {}}}\e[0m") + subject + end + end + end +end diff --git a/elasticsearch-rails/spec/instrumentation_spec.rb b/elasticsearch-rails/spec/instrumentation_spec.rb new file mode 100644 index 000000000..9245549af --- /dev/null +++ b/elasticsearch-rails/spec/instrumentation_spec.rb @@ -0,0 +1,103 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'spec_helper' + +describe 'ActiveSupport::Instrumentation integration' do + before(:all) do + class DummyInstrumentationModel + extend Elasticsearch::Model::Searching::ClassMethods + def self.index_name; 'foo'; end + end + end + + after(:all) do + remove_classes(DummyInstrumentationModel) + end + + let(:response_document) do + { + 'took' => '5ms', + 'hits' => { + 'total' => 123, + 'max_score' => 456, + 'hits' => [] + } + } + end + + let(:search) do + Elasticsearch::Model::Searching::SearchRequest.new(DummyInstrumentationModel, 'foo') + end + + let(:client) do + double('client', search: response_document) + end + + before do + allow(DummyInstrumentationModel).to receive(:client).and_return(client) + Elasticsearch::Rails::Instrumentation::Railtie.run_initializers + end + + context 'SearchRequest#execute!' do + it 'wraps the method with instrumentation' do + expect(search).to respond_to(:execute_without_instrumentation!) + expect(search).to respond_to(:execute_with_instrumentation!) + end + end + + context 'Model#search' do + before do + expect(ActiveSupport::Notifications). + to receive(:instrument). + with('search.elasticsearch', + { + klass: 'DummyInstrumentationModel', + name: 'Search', + search: { + body: query, + index: 'foo', + } + } + ).and_return({}) + end + + let(:query) do + { query: { match: { foo: 'bar' } } } + end + + let(:logged_message) do + @logger.logged(:debug).first + end + + it 'publishes a notification' do + expect(DummyInstrumentationModel.search(query).response).to eq({}) + end + + context 'when a message is logged', unless: defined?(RUBY_VERSION) && RUBY_VERSION > '2.2' do + + let(:query) do + { query: { match: { moo: 'bam' } } } + end + + it 'prints the debug information to the log' do + expect(logged_message).to match(/DummyInstrumentationModel Search \(\d+\.\d+ms\)/) + expect(logged_message).to match(/body\: \{query\: \{match\: \{moo\: "bam"\}\}\}\}/) + end + end + end +end diff --git a/elasticsearch-rails/spec/lograge_spec.rb b/elasticsearch-rails/spec/lograge_spec.rb new file mode 100644 index 000000000..3b54f1839 --- /dev/null +++ b/elasticsearch-rails/spec/lograge_spec.rb @@ -0,0 +1,52 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + + +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +require 'spec_helper' +require 'action_pack' +require 'lograge' +require 'elasticsearch/rails/lograge' + +describe 'ActiveSupport::Instrumentation integration' do + before do + Elasticsearch::Rails::Lograge::Railtie.run_initializers + end + + it 'customizes the Lograge configuration' do + expect( + Elasticsearch::Rails::Lograge::Railtie.initializers + .select { |i| i.name == 'elasticsearch.lograge' } + .first + ).not_to be_nil + end +end diff --git a/elasticsearch-rails/spec/spec_helper.rb b/elasticsearch-rails/spec/spec_helper.rb new file mode 100644 index 000000000..3a9eb8e6c --- /dev/null +++ b/elasticsearch-rails/spec/spec_helper.rb @@ -0,0 +1,65 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require 'pry-nav' +require 'active_record' +require 'elasticsearch/model' +require 'elasticsearch/rails' +require 'rails/railtie' +require 'rails/version' +require 'elasticsearch/rails/instrumentation' + + +unless defined?(ELASTICSEARCH_URL) + ELASTICSEARCH_URL = ENV['ELASTICSEARCH_URL'] || "localhost:#{(ENV['TEST_CLUSTER_PORT'] || 9200)}" +end + +RSpec.configure do |config| + config.formatter = 'documentation' + config.color = true + + config.before(:suite) do + require 'ansi' + tracer = ::Logger.new(STDERR) + tracer.formatter = lambda { |s, d, p, m| "#{m.gsub(/^.*$/) { |n| ' ' + n }.ansi(:faint)}\n" } + Elasticsearch::Model.client = Elasticsearch::Client.new host: ELASTICSEARCH_URL, + tracer: (ENV['QUIET'] ? nil : tracer), + transport_options: { :ssl => { verify: false } } + puts "Elasticsearch Version: #{Elasticsearch::Model.client.info['version']}" + + unless ActiveRecord::Base.connected? + ActiveRecord::Base.establish_connection( :adapter => 'sqlite3', :database => ":memory:" ) + end + + if ::ActiveRecord::Base.respond_to?(:raise_in_transactional_callbacks) && ::ActiveRecord::VERSION::MAJOR.to_s < '5' + ::ActiveRecord::Base.raise_in_transactional_callbacks = true + end + end +end + +# Remove all classes. +# +# @param [ Array ] classes The list of classes to remove. +# +# @return [ true ] +# +# @since 6.0.1 +def remove_classes(*classes) + classes.each do |_class| + Object.send(:remove_const, _class.name.to_sym) if defined?(_class) + end and true +end diff --git a/elasticsearch-rails/test/test_helper.rb b/elasticsearch-rails/test/test_helper.rb deleted file mode 100644 index f416cfeb9..000000000 --- a/elasticsearch-rails/test/test_helper.rb +++ /dev/null @@ -1,57 +0,0 @@ -RUBY_1_8 = defined?(RUBY_VERSION) && RUBY_VERSION < '1.9' - -exit(0) if RUBY_1_8 - -require 'simplecov' and SimpleCov.start { add_filter "/test|test_/" } if ENV["COVERAGE"] - -# Register `at_exit` handler for integration tests shutdown. -# MUST be called before requiring `test/unit`. -at_exit { Elasticsearch::Test::IntegrationTestCase.__run_at_exit_hooks } - -puts '-'*80 - -require 'test/unit' -require 'shoulda-context' -require 'mocha/setup' -require 'turn' unless ENV["TM_FILEPATH"] || ENV["NOTURN"] || RUBY_1_8 - -require 'ansi' -require 'oj' - -require 'rails/version' -require 'active_record' -require 'active_model' - -require 'elasticsearch/model' -require 'elasticsearch/rails' - -require 'elasticsearch/extensions/test/cluster' -require 'elasticsearch/extensions/test/startup_shutdown' - -module Elasticsearch - module Test - class IntegrationTestCase < ::Test::Unit::TestCase - extend Elasticsearch::Extensions::Test::StartupShutdown - - startup { Elasticsearch::Extensions::Test::Cluster.start(nodes: 1) if ENV['SERVER'] and not Elasticsearch::Extensions::Test::Cluster.running? } - shutdown { Elasticsearch::Extensions::Test::Cluster.stop if ENV['SERVER'] && started? } - context "IntegrationTest" do; should "noop on Ruby 1.8" do; end; end if RUBY_1_8 - - def setup - ActiveRecord::Base.establish_connection( :adapter => 'sqlite3', :database => ":memory:" ) - logger = ::Logger.new(STDERR) - logger.formatter = lambda { |s, d, p, m| "#{m.ansi(:faint, :cyan)}\n" } - ActiveRecord::Base.logger = logger unless ENV['QUIET'] - - ActiveRecord::LogSubscriber.colorize_logging = false - ActiveRecord::Migration.verbose = false - - tracer = ::Logger.new(STDERR) - tracer.formatter = lambda { |s, d, p, m| "#{m.gsub(/^.*$/) { |n| ' ' + n }.ansi(:faint)}\n" } - - Elasticsearch::Model.client = Elasticsearch::Client.new host: "localhost:#{(ENV['TEST_CLUSTER_PORT'] || 9250)}", - tracer: (ENV['QUIET'] ? nil : tracer) - end - end - end -end diff --git a/elasticsearch-rails/test/unit/instrumentation/instrumentation_test.rb b/elasticsearch-rails/test/unit/instrumentation/instrumentation_test.rb deleted file mode 100644 index bc68f10ca..000000000 --- a/elasticsearch-rails/test/unit/instrumentation/instrumentation_test.rb +++ /dev/null @@ -1,61 +0,0 @@ -require 'test_helper' - -require 'rails/railtie' -require 'active_support/log_subscriber/test_helper' - -require 'elasticsearch/rails/instrumentation' - -class Elasticsearch::Rails::InstrumentationTest < Test::Unit::TestCase - include ActiveSupport::LogSubscriber::TestHelper - - context "ActiveSupport::Instrumentation integration" do - class ::DummyInstrumentationModel - extend Elasticsearch::Model::Searching::ClassMethods - - def self.index_name; 'foo'; end - def self.document_type; 'bar'; end - end - - RESPONSE = { 'took' => '5ms', 'hits' => { 'total' => 123, 'max_score' => 456, 'hits' => [] } } - - setup do - @search = Elasticsearch::Model::Searching::SearchRequest.new ::DummyInstrumentationModel, '*' - @response = Elasticsearch::Model::Response::Response.new ::DummyInstrumentationModel, @search - - @client = stub('client', search: @response) - DummyInstrumentationModel.stubs(:client).returns(@client) - - Elasticsearch::Rails::Instrumentation::Railtie.run_initializers - end - - should "wrap SearchRequest#execute! with instrumentation" do - s = Elasticsearch::Model::Searching::SearchRequest.new ::DummyInstrumentationModel, 'foo' - assert_respond_to s, :execute_without_instrumentation! - assert_respond_to s, :execute_with_instrumentation! - end - - should "publish the notification" do - @query = { query: { match: { foo: 'bar' } } } - - ActiveSupport::Notifications.expects(:instrument).with do |name, payload| - assert_equal "search.elasticsearch", name - assert_equal 'DummyInstrumentationModel', payload[:klass] - assert_equal @query, payload[:search][:body] - end - - s = ::DummyInstrumentationModel.search @query - s.response - end - - should "print the debug information to the Rails log" do - s = ::DummyInstrumentationModel.search query: { match: { moo: 'bam' } } - s.response - - logged = @logger.logged(:debug).first - - assert_not_nil logged - assert_match /DummyInstrumentationModel Search \(\d+\.\d+ms\)/, logged - assert_match /body\: \{query\: \{match\: \{moo\: "bam"\}\}\}\}/, logged - end - end -end diff --git a/elasticsearch-rails/test/unit/instrumentation/lograge_test.rb b/elasticsearch-rails/test/unit/instrumentation/lograge_test.rb deleted file mode 100644 index 21b5d49ed..000000000 --- a/elasticsearch-rails/test/unit/instrumentation/lograge_test.rb +++ /dev/null @@ -1,20 +0,0 @@ -require 'test_helper' - -require 'rails/railtie' -require 'lograge' - -require 'elasticsearch/rails/lograge' - -class Elasticsearch::Rails::LogrageTest < Test::Unit::TestCase - context "Lograge integration" do - setup do - Elasticsearch::Rails::Lograge::Railtie.run_initializers - end - - should "customize the Lograge configuration" do - assert_not_nil Elasticsearch::Rails::Lograge::Railtie.initializers - .select { |i| i.name == 'elasticsearch.lograge' } - .first - end - end -end