├── .bundle └── config ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .jrubyrc ├── .rspec ├── .standard.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── pg_search.rb └── pg_search │ ├── configuration.rb │ ├── configuration │ ├── association.rb │ ├── column.rb │ └── foreign_column.rb │ ├── document.rb │ ├── features.rb │ ├── features │ ├── dmetaphone.rb │ ├── feature.rb │ ├── trigram.rb │ └── tsearch.rb │ ├── migration │ ├── dmetaphone_generator.rb │ ├── generator.rb │ ├── multisearch_generator.rb │ └── templates │ │ ├── add_pg_search_dmetaphone_support_functions.rb.erb │ │ └── create_pg_search_documents.rb.erb │ ├── model.rb │ ├── multisearch.rb │ ├── multisearch │ └── rebuilder.rb │ ├── multisearchable.rb │ ├── normalizer.rb │ ├── railtie.rb │ ├── scope_options.rb │ ├── tasks.rb │ └── version.rb ├── pg_search.gemspec ├── spec ├── .rubocop.yml ├── integration │ ├── .rubocop.yml │ ├── associations_spec.rb │ ├── deprecation_spec.rb │ ├── pagination_spec.rb │ ├── pg_search_spec.rb │ └── single_table_inheritance_spec.rb ├── lib │ ├── pg_search │ │ ├── configuration │ │ │ ├── association_spec.rb │ │ │ ├── column_spec.rb │ │ │ └── foreign_column_spec.rb │ │ ├── features │ │ │ ├── dmetaphone_spec.rb │ │ │ ├── trigram_spec.rb │ │ │ └── tsearch_spec.rb │ │ ├── multisearch │ │ │ └── rebuilder_spec.rb │ │ ├── multisearch_spec.rb │ │ ├── multisearchable_spec.rb │ │ └── normalizer_spec.rb │ └── pg_search_spec.rb ├── spec_helper.rb └── support │ ├── database.rb │ └── with_model.rb └── sql ├── dmetaphone.sql └── uninstall_dmetaphone.sql /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_BIN: bin 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - github-actions 8 | pull_request: 9 | branches: 10 | - master 11 | schedule: 12 | - cron: "0 18 * * 6" # Saturdays at 12pm CST 13 | workflow_dispatch: 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | services: 19 | postgres: 20 | image: postgres:latest 21 | env: 22 | POSTGRES_USER: postgres 23 | POSTGRES_PASSWORD: postgres 24 | options: >- 25 | --health-cmd pg_isready 26 | --health-interval 10s 27 | --health-timeout 5s 28 | --health-retries 5 29 | ports: 30 | - 5432:5432 31 | env: 32 | CI: true 33 | PGHOST: 127.0.0.1 34 | PGUSER: postgres 35 | PGPASS: postgres 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | ruby-version: ['3.2', '3.3', '3.4'] 40 | active-record-version-env: 41 | - ACTIVE_RECORD_VERSION="~> 7.1.0" 42 | - ACTIVE_RECORD_VERSION="~> 7.2.0" 43 | - ACTIVE_RECORD_VERSION="~> 8.0.0" 44 | allow-failure: [false] 45 | include: 46 | - ruby-version: 'ruby-head' 47 | active-record-version-env: ACTIVE_RECORD_VERSION="~> 8.0.0" 48 | allow-failure: true 49 | - ruby-version: '3.4' 50 | active-record-version-env: ACTIVE_RECORD_BRANCH="main" 51 | allow-failure: true 52 | - ruby-version: '3.4' 53 | active-record-version-env: ACTIVE_RECORD_BRANCH="8-0-stable" 54 | allow-failure: true 55 | - ruby-version: '3.4' 56 | active-record-version-env: ACTIVE_RECORD_BRANCH="7-2-stable" 57 | allow-failure: true 58 | - ruby-version: '3.4' 59 | active-record-version-env: ACTIVE_RECORD_BRANCH="7-1-stable" 60 | allow-failure: true 61 | continue-on-error: ${{ matrix.allow-failure }} 62 | steps: 63 | - uses: actions/checkout@v4 64 | - name: Set up Ruby 65 | uses: ruby/setup-ruby@v1 66 | with: 67 | ruby-version: ${{ matrix.ruby-version }} 68 | bundler-cache: true 69 | - name: Set up test database 70 | env: 71 | PGPASSWORD: postgres 72 | run: createdb pg_search_test 73 | - name: Update bundle 74 | run: ${{ matrix.active-record-version-env }} bundle update 75 | - name: Run tests 76 | run: ${{ matrix.active-record-version-env }} bundle exec rake 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .idea/ 3 | .rbx 4 | .yardoc 5 | /bin/ 6 | /coverage/ 7 | /pkg/* 8 | /tmp/ 9 | Gemfile.lock 10 | doc 11 | tags 12 | -------------------------------------------------------------------------------- /.jrubyrc: -------------------------------------------------------------------------------- 1 | debug.fullTrace=true 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 3.2 2 | 3 | plugins: 4 | - standard-performance 5 | - standard-rails: 6 | target_rails_version: 7.1 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # pg_search changelog 2 | 3 | ## 2.3.7 4 | 5 | * Drop support for Ruby 2.6 and 2.7 6 | * Drop support for Active Record 6.0 and earlier 7 | * Support Ruby 3.2 and 3.3 8 | * Support Active Record 7.1 9 | * Support Active Record 7.2 (fatkodima) 10 | * Add U+02BB/U+02BC to disallowed tsquery characters (Vital Ryabchinskiy) 11 | * add support for Arel::Nodes::SqlLiteral columns (Kyle Fazzari) 12 | * Improve documentation (Prima Aulia Gusta, Ross Baird, Andy Atkinson) 13 | 14 | ## 2.3.6 15 | 16 | * Drop support for Ruby 2.5 17 | * Support Ruby 3.1 18 | * Support Active Record 7.0 19 | * Don't require `:against` if `:tsvector_column` is specified (Travis Hunter) 20 | * Optionally disable transaction when rebuilding documents (Travis Hunter) 21 | * Preserve columns when chaining ::with_pg_search_highlight (jcsanti) 22 | 23 | ## 2.3.5 24 | 25 | * Add table of contents to README (Barry Woolgar) 26 | * Add support for Active Record 6.1 27 | 28 | ## 2.3.4 29 | 30 | * Fix issue when setting various options directly on the `PgSearch` module while 31 | running with a threaded web server, such as Puma. (Anton Rieder) 32 | 33 | ## 2.3.3 34 | 35 | * Drop support for Ruby < 2.5. 36 | * Use keyword argument for `clean_up` setting in `PgSearch::Multisearch.rebuild`. 37 | 38 | ## 2.3.2 39 | 40 | * Autoload `PgSearch::Document` to prevent it from being loaded in projects that are not using multi-search. 41 | * Rebuilder should use `update_pg_search_document` if `additional_attributes` is set. (David Ramalho) 42 | 43 | ## 2.3.1 44 | 45 | * Drop support for Active Record < 5.2. 46 | * Do not load railtie unless Rails::Railtie is defined, to avoid problem when loading alongside Action Mailer. (Adam Schwartz) 47 | 48 | ## 2.3.0 49 | 50 | * Extract `PgSearch::Model` module. 51 | * Deprecate `include PgSearch`. Use `include PgSearch::Model` instead. 52 | 53 | ## 2.2.0 54 | 55 | * Add `word_similarity` option to trigram search. (Severin Räz) 56 | 57 | ## 2.1.7 58 | 59 | * Restore link to GitHub repository to original location. 60 | 61 | ## 2.1.6 62 | 63 | * Update link to GitHub repository to new location. 64 | 65 | ## 2.1.5 66 | 67 | * Drop support for Ruby < 2.4. 68 | 69 | ## 2.1.4 70 | 71 | * Drop support for Ruby < 2.3. 72 | * Use `update` instead of deprecated `update_attributes`. 73 | * Remove explicit Arel dependency to better support Active Record 6 beta. 74 | 75 | ## 2.1.3 76 | 77 | * Drop support for Ruby < 2.2 78 | * Disallow left/right single quotation marks in tsquery. (Fabian Schwahn) (#382) 79 | * Do not attempt to save an already-destroyed `PgSearch::Document`. (Oleg Dashevskii, Vokhmin Aleksei V) (#353) 80 | * Quote column name when rebuilding. (Jed Levin) (#379) 81 | 82 | ## 2.1.2 83 | 84 | * Silence warnings in Rails 5.2.0.beta2. (Kevin Deisz) 85 | 86 | ## 2.1.1 87 | 88 | * Support snake_case `ts_headline` options again. (with deprecation warning) 89 | 90 | ## 2.1.0 91 | 92 | * Allow `ts_headline` options to be passed to `:highlight`. (Ian Heisters) 93 | * Wait to load `PgSearch::Document` until after Active Record has loaded. (Logan Leger) 94 | * Add Rails version to generated migrations. (Erik Eide) 95 | 96 | ## 2.0.1 97 | 98 | * Remove require for generator that no longer exists. (Joshua Bartlett) 99 | 100 | ## 2.0.0 101 | 102 | * Drop support for PostgreSQL < 9.2. 103 | * Drop support for Active Record < 4.2. 104 | * Drop support for Ruby < 2.1. 105 | * Improve performance of has_one and belongs_to associations. (Peter Postma) 106 | 107 | ## 1.0.6 108 | 109 | * Add support for highlighting the matching portion of a search result. (Jose Galisteo) 110 | * Add `:update_if` option to control when PgSearch::Document gets updated. (Adam Becker) 111 | * Add `:additional_attributes` option for adding additional attributes to PgSearch::Document 112 | 113 | ## 1.0.5 114 | 115 | * Clean up rank table aliasing. (Adam Milligan) 116 | * Fix issue when using `#with_pg_search_rank` across a join. (Reid Lynch) 117 | 118 | ## 1.0.4 119 | 120 | * Assert valid options for features. (Janko Marohnić) 121 | * Enable chaining of pg_search scopes. (Nicolas Buduroi) 122 | 123 | ## 1.0.3 124 | 125 | * Support STI models using a custom inheritance column. (Nick Doiron) 126 | 127 | ## 1.0.2 128 | 129 | * Don’t use SQL to rebuild search documents when models are multisearchable against dynamic methods and not just columns. Iterate over each record with `find_each` instead. 130 | 131 | ## 1.0.1 132 | 133 | * Call `.unscoped` on relation used to build subquery, to eliminate unnecessary JOINs. (Markus Doits) 134 | 135 | ## 1.0.0 136 | 137 | * Support more `ActiveRecord::Relation` methods, such as `#pluck` and `#select` by moving search-related operations to subquery. 138 | * Generate index by default in migration for `pg_search_documents` table. 139 | * Start officially using [Semantic Versioning 2.0.0](http://semver.org/spec/v2.0.0.html). 140 | 141 | ## 0.7.9 142 | 143 | * Improve support for single table inheritance (STI) models. (Ewan McDougall) 144 | 145 | ## 0.7.8 146 | 147 | * Stop inadvertently including binstubs for guard and rspec. 148 | 149 | ## 0.7.7 150 | 151 | * Fix future compatibility with Active Record 4.2. 152 | 153 | ## 0.7.6 154 | 155 | * Fix migration generator in Rails 3. (Andrew Marshall and Nora Lin) 156 | * Add `:only` option for limiting search fields per feature. (Jonathan Greenberg) 157 | 158 | ## 0.7.5 159 | 160 | * Add option to make feature available only for sorting. (Brent Wheeldon) 161 | 162 | ## 0.7.4 163 | 164 | * Fix which STI class name is used for searchable_type for PgSearch::Document. (Ewan McDougall) 165 | * Add support for non-standard primary keys. (Matt Beedle) 166 | 167 | ## 0.7.3 168 | 169 | * Allow simultaneously searching using `:associated_against` and `:tsvector_column` (Adam Becker) 170 | 171 | ## 0.7.2 172 | 173 | * Add :threshold option for configuring how permissive trigram searches are. 174 | 175 | ## 0.7.1 176 | 177 | * Fix issue with {:using => :trigram, :ignoring => :accents} that generated 178 | bad SQL. (Steven Harman) 179 | 180 | ## 0.7.0 181 | 182 | * Start requiring Ruby 1.9.2 or later. 183 | 184 | ## 0.6.4 185 | 186 | * Fix issue with using more than two features in the same scope. 187 | 188 | ## 0.6.3 189 | 190 | * Fix issues and deprecations for Active Record 4.0.0.rc1. 191 | 192 | ## 0.6.2 193 | 194 | * Add workaround for issue with how ActiveRecord relations handle Arel OR 195 | nodes. 196 | 197 | ## 0.6.1 198 | 199 | * Fix issue with Arel::InfixOperation that prevented #count from working, 200 | breaking pagination. 201 | 202 | ## 0.6.0 203 | 204 | * Drop support for Active Record 3.0. 205 | * Address warnings in Ruby 2.0. 206 | * Remove all usages of sanitize_sql_array for future Rails 4 compatibility. 207 | * Start using Arel internally to build SQL strings (not yet complete). 208 | * Disable eager loading, fixes issue #14. 209 | * Support named schemas in pg_search:multisearch:rebuild. (Victor Olteanu) 210 | 211 | 212 | ## 0.5.7 213 | 214 | * Fix issue with eager loading now that the Scope class has been removed. 215 | (Piotr Murach) 216 | 217 | 218 | ## 0.5.6 219 | 220 | * PgSearch#multisearchable accepts :if and :unless for conditional inclusion 221 | in search documents table. (Francois Harbec) 222 | * Stop using array_to_string() in SQL since it is not indexable. 223 | 224 | 225 | ## 0.5.5 226 | 227 | * Fix bug with single table inheritance. 228 | * Allow option for specifying an alternate function for unaccent(). 229 | 230 | 231 | ## 0.5.4 232 | 233 | * Fix bug in associated_against join clause when search scope is chained 234 | after other scopes. 235 | * Fix autoloading of PgSearch::VERSION constant. 236 | 237 | 238 | ## 0.5.3 239 | 240 | * Prevent multiple attempts to create pg_search_document within a single 241 | transaction. (JT Archie & Trace Wax) 242 | 243 | 244 | ## 0.5.2 245 | 246 | * Don't save twice if pg_search_document is missing on update. 247 | 248 | 249 | ## 0.5.1 250 | 251 | * Add ability to override multisearch rebuild SQL. 252 | 253 | 254 | ## 0.5 255 | 256 | * Convert migration rake tasks into generators. 257 | * Use rake task arguments for multisearch rebuild instead of environment 258 | variable. 259 | * Always cast columns to text. 260 | 261 | 262 | ## 0.4.2 263 | 264 | * Fill in timestamps correctly when rebuilding multisearch documents. 265 | (Barton McGuire) 266 | * Fix various issues with rebuilding multisearch documents. (Eugen Neagoe) 267 | * Fix syntax error in pg_search_dmetaphone() migration. (Casey Foster) 268 | * Rename PgSearch#rank to PgSearch#pg_search_rank and always return a Float. 269 | * Fix issue with :associated_against and non-text columns. 270 | 271 | 272 | ## 0.4.1 273 | 274 | * Fix Active Record 3.2 deprecation warnings. (Steven Harman) 275 | 276 | * Fix issue with undefined logger when PgSearch::Document.search is already 277 | defined. 278 | 279 | 280 | ## 0.4 281 | 282 | * Add ability to search again tsvector columns. (Kris Hicks) 283 | 284 | 285 | ## 0.3.4 286 | 287 | * Fix issue with {:using => {:tsearch => {:prefix => true}}} and hyphens. 288 | * Get tests running against PostgreSQL 9.1 by using CREATE EXTENSION 289 | 290 | 291 | ## 0.3.3 292 | 293 | * Backport array_agg() aggregate function to PostgreSQL 8.3 and earlier. 294 | This fixes :associated_against searches. 295 | * Backport unnest() function to PostgreSQL 8.3 and earlier. This fixes 296 | {:using => :dmetaphone} searches. 297 | * Disable {:using => {:tsearch => {:prefix => true}}} in PostgreSQL 8.3 and 298 | earlier. 299 | 300 | 301 | ## 0.3.2 302 | 303 | * Fix :prefix search in PostgreSQL 8.x 304 | * Disable {:ignoring => :accents} in PostgreSQL 8.x 305 | 306 | 307 | ## 0.3.1 308 | 309 | * Fix syntax error in generated dmetaphone migration. (Max De Marzi) 310 | 311 | 312 | ## 0.3 313 | 314 | * Drop Active Record 2.0 support. 315 | * Add PgSearch.multisearch for cross-model searching. 316 | * Fix PostgreSQL warnings about truncated identifiers 317 | * Support specifying a method of rank normalisation when using tsearch. 318 | (Arthur Gunn) 319 | * Add :any_word option to :tsearch which uses OR between query terms instead 320 | of AND. (Fernando Espinosa) 321 | 322 | ## 0.2.2 323 | 324 | * Fix a compatibility issue between Ruby 1.8.7 and 1.9.3 when using Rails 2 325 | (James Badger) 326 | 327 | ## 0.2.1 328 | 329 | * Backport support for searching against tsvector columns (Kris Hicks) 330 | 331 | ## 0.2 332 | 333 | * Set dictionary to :simple by default for :tsearch. Before it was unset, 334 | which would fall back to PostgreSQL's default dictionary, usually 335 | "english". 336 | * Fix a bug with search strings containing a colon ":" 337 | * Improve performance of :associated_against by only doing one INNER JOIN 338 | per association 339 | 340 | ## 0.1.1 341 | 342 | * Fix a bug with dmetaphone searches containing " w " (which dmetaphone maps 343 | to an empty string) 344 | 345 | ## 0.1 346 | 347 | * Change API to {:ignoring => :accents} from {:normalizing => :diacritics} 348 | * Improve documentation 349 | * Fix bug where :associated_against would not work without an :against 350 | present 351 | 352 | ## 0.0.2 353 | 354 | * Fix gem ownership. 355 | 356 | ## 0.0.1 357 | 358 | * Initial release. 359 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at pg_search@nertzy.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to pg_search 2 | 3 | First off, if you experience a bug, we welcome you to report it. Please provide a minimal test case showing the code that you ran, its output, and what you expected the output to be instead. If you are able to fix the bug and make a pull request, we are much more likely to get it resolved quickly, but don't feel bad to just report an issue if you don't know how to fix it. 4 | 5 | View the [README](./README.md) to see which versions of Ruby, Active Record, and PostgreSQL are supported by pg_search. It can be hard to test against all of the various versions, but please do your best to avoid coding practices that only work in newer versions. 6 | 7 | If you have a substantial feature to add, you might want to discuss it first on the [mailing list](https://groups.google.com/forum/#!forum/casecommons-dev). We might have thought hard about it already, and can sometimes help with tips and tricks. 8 | 9 | When in doubt, go ahead and make a pull request. If something needs tweaking or rethinking, we will do our best to respond and make that clear. 10 | 11 | Don't be discouraged if the maintainers ask you to change your code. We are always appreciative when people work hard to modify our code, but we also have a lot of opinions about coding style and object design. 12 | 13 | Our automated tests start by updating all gems to their latest version. This is by design, because we want to be proactive about compatibility with other libraries. You can do the same by running `bundle update` at any time. To test against a specific version of Active Record, you can set the `ACTIVE_RECORD_VERSION` environment variable. 14 | 15 | $ ACTIVE_RECORD_VERSION=5.0 bundle update 16 | 17 | Run the tests by running `bundle exec rake`, or `bin/rake` if you use Bundler binstubs. 18 | 19 | Last, but not least, have fun! pg_search is a labor of love. -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "pg", ">= 0.21.0", platform: :ruby 8 | gem "activerecord-jdbcpostgresql-adapter", ">= 1.3.1", platform: :jruby 9 | 10 | if ENV["ACTIVE_RECORD_BRANCH"] 11 | gem "activerecord", git: "https://github.com/rails/rails.git", branch: ENV.fetch("ACTIVE_RECORD_BRANCH", nil) 12 | gem "arel", git: "https://github.com/rails/arel.git" if ENV.fetch("ACTIVE_RECORD_BRANCH", nil) == "master" 13 | end 14 | 15 | gem "activerecord", ENV.fetch("ACTIVE_RECORD_VERSION", nil) if ENV["ACTIVE_RECORD_VERSION"] # standard:disable Bundler/DuplicatedGem 16 | 17 | gem "debug" 18 | gem "irb" 19 | gem "mutex_m" 20 | gem "rake" 21 | gem "rspec" 22 | gem "simplecov" 23 | gem "simplecov-lcov" 24 | gem "standard", require: false 25 | gem "standard-rails", require: false 26 | gem "standard-rspec", require: false 27 | gem "undercover" 28 | gem "warning" 29 | gem "with_model" 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010–2022 Casebook, PBC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler" 4 | Bundler::GemHelper.install_tasks 5 | 6 | require "rspec/core/rake_task" 7 | RSpec::Core::RakeTask.new(:spec) 8 | 9 | require "standard/rake" 10 | 11 | desc "Check test coverage" 12 | task :undercover do 13 | system("git fetch --unshallow") if ENV["CI"] 14 | exit(1) unless system("bin/undercover --compare origin/master") 15 | end 16 | 17 | task default: %w[spec standard undercover] 18 | -------------------------------------------------------------------------------- /lib/pg_search.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | require "active_support/concern" 5 | require "active_support/core_ext/module/attribute_accessors" 6 | require "active_support/core_ext/string/strip" 7 | 8 | require "pg_search/configuration" 9 | require "pg_search/features" 10 | require "pg_search/model" 11 | require "pg_search/multisearch" 12 | require "pg_search/multisearchable" 13 | require "pg_search/normalizer" 14 | require "pg_search/scope_options" 15 | require "pg_search/version" 16 | 17 | module PgSearch 18 | autoload :Document, "pg_search/document" 19 | 20 | def self.included(base) 21 | warn(<<~MESSAGE, category: :deprecated, uplevel: 1) 22 | Directly including `PgSearch` into an Active Record model is deprecated and will be removed in pg_search 3.0. 23 | 24 | Please replace `include PgSearch` with `include PgSearch::Model`. 25 | MESSAGE 26 | 27 | base.include PgSearch::Model 28 | end 29 | 30 | mattr_accessor :multisearch_options 31 | self.multisearch_options = {} 32 | 33 | mattr_accessor :unaccent_function 34 | self.unaccent_function = "unaccent" 35 | 36 | class << self 37 | def multisearch(...) 38 | PgSearch::Document.search(...) 39 | end 40 | 41 | def disable_multisearch 42 | Thread.current["PgSearch.enable_multisearch"] = false 43 | yield 44 | ensure 45 | Thread.current["PgSearch.enable_multisearch"] = true 46 | end 47 | 48 | def multisearch_enabled? 49 | if Thread.current.key?("PgSearch.enable_multisearch") 50 | Thread.current["PgSearch.enable_multisearch"] 51 | else 52 | true 53 | end 54 | end 55 | end 56 | 57 | class PgSearchRankNotSelected < StandardError 58 | def message 59 | "You must chain .with_pg_search_rank after the pg_search_scope " \ 60 | "to access the pg_search_rank attribute on returned records" 61 | end 62 | end 63 | 64 | class PgSearchHighlightNotSelected < StandardError 65 | def message 66 | "You must chain .with_pg_search_highlight after the pg_search_scope " \ 67 | "to access the pg_search_highlight attribute on returned records" 68 | end 69 | end 70 | end 71 | 72 | require "pg_search/railtie" if defined?(Rails::Railtie) 73 | -------------------------------------------------------------------------------- /lib/pg_search/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_search/configuration/association" 4 | require "pg_search/configuration/column" 5 | require "pg_search/configuration/foreign_column" 6 | 7 | module PgSearch 8 | class Configuration 9 | attr_reader :model 10 | 11 | def initialize(options, model) 12 | @options = default_options.merge(options) 13 | @model = model 14 | 15 | assert_valid_options(@options) 16 | end 17 | 18 | class << self 19 | def alias(*strings) 20 | name = Array(strings).compact.join("_") 21 | # By default, PostgreSQL limits names to 32 characters, so we hash and limit to 32 characters. 22 | "pg_search_#{Digest::SHA2.hexdigest(name)}".first(32) 23 | end 24 | end 25 | 26 | def columns 27 | regular_columns + associated_columns 28 | end 29 | 30 | def regular_columns 31 | return [] unless options[:against] 32 | 33 | Array(options[:against]).map do |column_name, weight| 34 | Column.new(column_name, weight, model) 35 | end 36 | end 37 | 38 | def associations 39 | return [] unless options[:associated_against] 40 | 41 | options[:associated_against].map do |association, column_names| 42 | Association.new(model, association, column_names) 43 | end.flatten 44 | end 45 | 46 | def associated_columns 47 | associations.map(&:columns).flatten 48 | end 49 | 50 | def query 51 | options[:query].to_s 52 | end 53 | 54 | def ignore 55 | Array(options[:ignoring]) 56 | end 57 | 58 | def ranking_sql 59 | options[:ranked_by] 60 | end 61 | 62 | def features 63 | Array(options[:using]) 64 | end 65 | 66 | def feature_options 67 | @feature_options ||= {}.tap do |hash| 68 | features.map do |feature_name, feature_options| 69 | hash[feature_name] = feature_options 70 | end 71 | end 72 | end 73 | 74 | def order_within_rank 75 | options[:order_within_rank] 76 | end 77 | 78 | private 79 | 80 | attr_reader :options 81 | 82 | def default_options 83 | {using: :tsearch} 84 | end 85 | 86 | # standard:disable Lint/UselessConstantScoping 87 | VALID_KEYS = %w[ 88 | against ranked_by ignoring using query associated_against order_within_rank 89 | ].map(&:to_sym) 90 | 91 | VALID_VALUES = { 92 | ignoring: [:accents] 93 | }.freeze 94 | # standard:enable Lint/UselessConstantScoping 95 | 96 | def assert_valid_options(options) 97 | unless options[:against] || options[:associated_against] || using_tsvector_column?(options[:using]) 98 | raise( 99 | ArgumentError, 100 | "the search scope #{@name} must have :against, :associated_against, or :tsvector_column in its options" 101 | ) 102 | end 103 | 104 | options.assert_valid_keys(VALID_KEYS) 105 | 106 | VALID_VALUES.each do |key, values_for_key| 107 | Array(options[key]).each do |value| 108 | raise ArgumentError, ":#{key} cannot accept #{value}" unless values_for_key.include?(value) 109 | end 110 | end 111 | end 112 | 113 | def using_tsvector_column?(options) 114 | return unless options.is_a?(Hash) 115 | 116 | options.dig(:dmetaphone, :tsvector_column).present? || 117 | options.dig(:tsearch, :tsvector_column).present? 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/pg_search/configuration/association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "digest" 4 | 5 | module PgSearch 6 | class Configuration 7 | class Association 8 | attr_reader :columns 9 | 10 | def initialize(model, name, column_names) 11 | @model = model 12 | @name = name 13 | @columns = Array(column_names).map do |column_name, weight| 14 | ForeignColumn.new(column_name, weight, @model, self) 15 | end 16 | end 17 | 18 | def table_name 19 | @model.reflect_on_association(@name).table_name 20 | end 21 | 22 | def join(primary_key) 23 | "LEFT OUTER JOIN (#{relation(primary_key).to_sql}) #{subselect_alias} ON #{subselect_alias}.id = #{primary_key}" 24 | end 25 | 26 | def subselect_alias 27 | Configuration.alias(table_name, @name, "subselect") 28 | end 29 | 30 | private 31 | 32 | def selects 33 | if singular_association? 34 | selects_for_singular_association 35 | else 36 | selects_for_multiple_association 37 | end 38 | end 39 | 40 | def selects_for_singular_association 41 | columns.map do |column| 42 | "#{column.full_name}::text AS #{column.alias}" 43 | end.join(", ") 44 | end 45 | 46 | def selects_for_multiple_association 47 | columns.map do |column| 48 | "string_agg(#{column.full_name}::text, ' ') AS #{column.alias}" 49 | end.join(", ") 50 | end 51 | 52 | def relation(primary_key) 53 | result = @model.unscoped.joins(@name).select("#{primary_key} AS id, #{selects}") 54 | result = result.group(primary_key) unless singular_association? 55 | result 56 | end 57 | 58 | def singular_association? 59 | %i[has_one belongs_to].include?(@model.reflect_on_association(@name).macro) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/pg_search/configuration/column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "digest" 4 | 5 | module PgSearch 6 | class Configuration 7 | class Column 8 | attr_reader :weight, :name 9 | 10 | def initialize(column_name, weight, model) 11 | @name = column_name.to_s 12 | @column_name = column_name 13 | @weight = weight 14 | @model = model 15 | @connection = model.connection 16 | end 17 | 18 | def full_name 19 | return @column_name if @column_name.is_a?(Arel::Nodes::SqlLiteral) 20 | 21 | "#{table_name}.#{column_name}" 22 | end 23 | 24 | def to_sql 25 | "coalesce((#{expression})::text, '')" 26 | end 27 | 28 | private 29 | 30 | def table_name 31 | @model.quoted_table_name 32 | end 33 | 34 | def column_name 35 | @connection.quote_column_name(@name) 36 | end 37 | 38 | def expression 39 | full_name 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/pg_search/configuration/foreign_column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "digest" 4 | 5 | module PgSearch 6 | class Configuration 7 | class ForeignColumn < Column 8 | attr_reader :weight 9 | 10 | def initialize(column_name, weight, model, association) 11 | super(column_name, weight, model) 12 | @association = association 13 | end 14 | 15 | def alias 16 | Configuration.alias(@association.subselect_alias, @column_name) 17 | end 18 | 19 | private 20 | 21 | def expression 22 | "#{@association.subselect_alias}.#{self.alias}" 23 | end 24 | 25 | def table_name 26 | @connection.quote_table_name(@association.table_name) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/pg_search/document.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | module PgSearch 6 | class Document < ActiveRecord::Base # standard:disable Rails/ApplicationRecord 7 | include PgSearch::Model 8 | 9 | self.table_name = "pg_search_documents" 10 | belongs_to :searchable, polymorphic: true 11 | 12 | # The logger might not have loaded yet. 13 | # https://github.com/Casecommons/pg_search/issues/26 14 | def self.logger 15 | super || Logger.new($stderr) 16 | end 17 | 18 | pg_search_scope :search, lambda { |*args| 19 | options = if PgSearch.multisearch_options.respond_to?(:call) 20 | PgSearch.multisearch_options.call(*args) 21 | else 22 | {query: args.first}.merge(PgSearch.multisearch_options) 23 | end 24 | 25 | {against: :content}.merge(options) 26 | } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/pg_search/features.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_search/features/feature" 4 | 5 | require "pg_search/features/dmetaphone" 6 | require "pg_search/features/trigram" 7 | require "pg_search/features/tsearch" 8 | 9 | module PgSearch 10 | module Features 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/pg_search/features/dmetaphone.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/module/delegation" 4 | 5 | module PgSearch 6 | module Features 7 | class DMetaphone 8 | def initialize(query, options, columns, model, normalizer) 9 | dmetaphone_normalizer = Normalizer.new(normalizer) 10 | options = (options || {}).merge(dictionary: "simple") 11 | @tsearch = TSearch.new(query, options, columns, model, dmetaphone_normalizer) 12 | end 13 | 14 | delegate :conditions, to: :tsearch 15 | 16 | delegate :rank, to: :tsearch 17 | 18 | private 19 | 20 | attr_reader :tsearch 21 | 22 | # Decorates a normalizer with dmetaphone processing. 23 | class Normalizer 24 | def initialize(normalizer_to_wrap) 25 | @normalizer_to_wrap = normalizer_to_wrap 26 | end 27 | 28 | def add_normalization(original_sql) 29 | otherwise_normalized_sql = Arel.sql( 30 | normalizer_to_wrap.add_normalization(original_sql) 31 | ) 32 | 33 | Arel::Nodes::NamedFunction.new( 34 | "pg_search_dmetaphone", 35 | [otherwise_normalized_sql] 36 | ).to_sql 37 | end 38 | 39 | private 40 | 41 | attr_reader :normalizer_to_wrap 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/pg_search/features/feature.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/module/delegation" 4 | require "active_support/core_ext/hash/keys" 5 | 6 | module PgSearch 7 | module Features 8 | class Feature 9 | def self.valid_options 10 | %i[only sort_only] 11 | end 12 | 13 | delegate :connection, :quoted_table_name, to: :@model 14 | 15 | def initialize(query, options, all_columns, model, normalizer) 16 | @query = query 17 | @options = (options || {}).assert_valid_keys(self.class.valid_options) 18 | @all_columns = all_columns 19 | @model = model 20 | @normalizer = normalizer 21 | end 22 | 23 | private 24 | 25 | attr_reader :query, :options, :all_columns, :model, :normalizer 26 | 27 | def document 28 | columns.map(&:to_sql).join(" || ' ' || ") 29 | end 30 | 31 | def columns 32 | if options[:only] 33 | all_columns.select do |column| 34 | Array.wrap(options[:only]).map(&:to_s).include? column.name 35 | end 36 | else 37 | all_columns 38 | end 39 | end 40 | 41 | def normalize(expression) 42 | normalizer.add_normalization(expression) 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/pg_search/features/trigram.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgSearch 4 | module Features 5 | class Trigram < Feature 6 | def self.valid_options 7 | super + %i[threshold word_similarity] 8 | end 9 | 10 | def conditions 11 | if options[:threshold] 12 | Arel::Nodes::Grouping.new( 13 | similarity.gteq(options[:threshold]) 14 | ) 15 | else 16 | Arel::Nodes::Grouping.new( 17 | Arel::Nodes::InfixOperation.new( 18 | infix_operator, 19 | normalized_query, 20 | normalized_document 21 | ) 22 | ) 23 | end 24 | end 25 | 26 | def rank 27 | Arel::Nodes::Grouping.new(similarity) 28 | end 29 | 30 | private 31 | 32 | def word_similarity? 33 | options[:word_similarity] 34 | end 35 | 36 | def similarity_function 37 | if word_similarity? 38 | "word_similarity" 39 | else 40 | "similarity" 41 | end 42 | end 43 | 44 | def infix_operator 45 | if word_similarity? 46 | "<%" 47 | else 48 | "%" 49 | end 50 | end 51 | 52 | def similarity 53 | Arel::Nodes::NamedFunction.new( 54 | similarity_function, 55 | [ 56 | normalized_query, 57 | normalized_document 58 | ] 59 | ) 60 | end 61 | 62 | def normalized_document 63 | Arel::Nodes::Grouping.new(Arel.sql(normalize(document))) 64 | end 65 | 66 | def normalized_query 67 | sanitized_query = connection.quote(query) 68 | Arel.sql(normalize(sanitized_query)) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/pg_search/features/tsearch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/module/delegation" 4 | require "active_support/deprecation" 5 | 6 | module PgSearch 7 | module Features 8 | class TSearch < Feature 9 | def self.valid_options 10 | super + %i[dictionary prefix negation any_word normalization tsvector_column highlight] 11 | end 12 | 13 | def conditions 14 | Arel::Nodes::Grouping.new( 15 | Arel::Nodes::InfixOperation.new("@@", arel_wrap(tsdocument), arel_wrap(tsquery)) 16 | ) 17 | end 18 | 19 | def rank 20 | arel_wrap(tsearch_rank) 21 | end 22 | 23 | def highlight 24 | arel_wrap(ts_headline) 25 | end 26 | 27 | private 28 | 29 | def ts_headline 30 | Arel::Nodes::NamedFunction.new("ts_headline", [ 31 | dictionary, 32 | arel_wrap(document), 33 | arel_wrap(tsquery), 34 | Arel::Nodes.build_quoted(ts_headline_options) 35 | ]).to_sql 36 | end 37 | 38 | def ts_headline_options 39 | return "" unless options[:highlight].is_a?(Hash) 40 | 41 | headline_options 42 | .merge(deprecated_headline_options) 43 | .filter_map { |key, value| "#{key} = #{value}" unless value.nil? } 44 | .join(", ") 45 | end 46 | 47 | def headline_options 48 | indifferent_options = options.with_indifferent_access 49 | 50 | %w[ 51 | StartSel StopSel MaxFragments MaxWords MinWords ShortWord FragmentDelimiter HighlightAll 52 | ].reduce({}) do |hash, key| 53 | hash.tap do 54 | value = indifferent_options[:highlight][key] 55 | 56 | hash[key] = ts_headline_option_value(value) 57 | end 58 | end 59 | end 60 | 61 | def deprecated_headline_options 62 | indifferent_options = options.with_indifferent_access 63 | 64 | %w[ 65 | start_sel stop_sel max_fragments max_words min_words short_word fragment_delimiter highlight_all 66 | ].reduce({}) do |hash, deprecated_key| 67 | hash.tap do 68 | value = indifferent_options[:highlight][deprecated_key] 69 | 70 | unless value.nil? 71 | key = deprecated_key.camelize 72 | 73 | warn( 74 | "pg_search 3.0 will no longer accept :#{deprecated_key} as an argument to :ts_headline, " \ 75 | "use :#{key} instead.", 76 | category: :deprecated, 77 | uplevel: 1 78 | ) 79 | 80 | hash[key] = ts_headline_option_value(value) 81 | end 82 | end 83 | end 84 | end 85 | 86 | def ts_headline_option_value(value) 87 | case value 88 | when String 89 | %("#{value.gsub('"', '""')}") 90 | when true 91 | "TRUE" 92 | when false 93 | "FALSE" 94 | else 95 | value 96 | end 97 | end 98 | 99 | DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’ʻʼ]/ # standard:disable Lint/UselessConstantScoping 100 | 101 | def tsquery_for_term(unsanitized_term) 102 | if options[:negation] && unsanitized_term.start_with?("!") 103 | unsanitized_term[0] = "" 104 | negated = true 105 | end 106 | 107 | sanitized_term = unsanitized_term.gsub(DISALLOWED_TSQUERY_CHARACTERS, " ") 108 | 109 | term_sql = Arel.sql(normalize(connection.quote(sanitized_term))) 110 | 111 | tsquery = tsquery_expression(term_sql, negated: negated, prefix: options[:prefix]) 112 | 113 | Arel::Nodes::NamedFunction.new("to_tsquery", [dictionary, tsquery]).to_sql 114 | end 115 | 116 | # After this, the SQL expression evaluates to a string containing the term surrounded by single-quotes. 117 | # If :prefix is true, then the term will have :* appended to the end. 118 | # If :negated is true, then the term will have ! prepended to the front. 119 | def tsquery_expression(term_sql, negated:, prefix:) 120 | terms = [ 121 | (Arel::Nodes.build_quoted("!") if negated), 122 | Arel::Nodes.build_quoted("' "), 123 | term_sql, 124 | Arel::Nodes.build_quoted(" '"), 125 | (Arel::Nodes.build_quoted(":*") if prefix) 126 | ].compact 127 | 128 | terms.inject do |memo, term| 129 | Arel::Nodes::InfixOperation.new("||", memo, Arel::Nodes.build_quoted(term)) 130 | end 131 | end 132 | 133 | def tsquery 134 | return "''" if query.blank? 135 | 136 | query_terms = query.split.compact 137 | tsquery_terms = query_terms.map { |term| tsquery_for_term(term) } 138 | tsquery_terms.join(options[:any_word] ? " || " : " && ") 139 | end 140 | 141 | def tsdocument 142 | tsdocument_terms = (columns_to_use || []).map do |search_column| 143 | column_to_tsvector(search_column) 144 | end 145 | 146 | if options[:tsvector_column] 147 | tsvector_columns = Array.wrap(options[:tsvector_column]) 148 | 149 | tsdocument_terms << tsvector_columns.map do |tsvector_column| 150 | column_name = connection.quote_column_name(tsvector_column) 151 | 152 | "#{quoted_table_name}.#{column_name}" 153 | end 154 | end 155 | 156 | tsdocument_terms.join(" || ") 157 | end 158 | 159 | # From http://www.postgresql.org/docs/8.3/static/textsearch-controls.html 160 | # 0 (the default) ignores the document length 161 | # 1 divides the rank by 1 + the logarithm of the document length 162 | # 2 divides the rank by the document length 163 | # 4 divides the rank by the mean harmonic distance between extents (this is implemented only by ts_rank_cd) 164 | # 8 divides the rank by the number of unique words in document 165 | # 16 divides the rank by 1 + the logarithm of the number of unique words in document 166 | # 32 divides the rank by itself + 1 167 | # The integer option controls several behaviors, so it is a bit mask: you can specify one or more behaviors 168 | def normalization 169 | options[:normalization] || 0 170 | end 171 | 172 | def tsearch_rank 173 | Arel::Nodes::NamedFunction.new("ts_rank", [ 174 | arel_wrap(tsdocument), 175 | arel_wrap(tsquery), 176 | normalization 177 | ]).to_sql 178 | end 179 | 180 | def dictionary 181 | Arel::Nodes.build_quoted(options[:dictionary] || :simple) 182 | end 183 | 184 | def arel_wrap(sql_string) 185 | Arel::Nodes::Grouping.new(Arel.sql(sql_string)) 186 | end 187 | 188 | def columns_to_use 189 | if options[:tsvector_column] 190 | columns.select { |c| c.is_a?(PgSearch::Configuration::ForeignColumn) } 191 | else 192 | columns 193 | end 194 | end 195 | 196 | def column_to_tsvector(search_column) 197 | tsvector = Arel::Nodes::NamedFunction.new( 198 | "to_tsvector", 199 | [dictionary, Arel.sql(normalize(search_column.to_sql))] 200 | ).to_sql 201 | 202 | if search_column.weight.nil? 203 | tsvector 204 | else 205 | "setweight(#{tsvector}, #{connection.quote(search_column.weight)})" 206 | end 207 | end 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/pg_search/migration/dmetaphone_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_search/migration/generator" 4 | 5 | module PgSearch 6 | module Migration 7 | class DmetaphoneGenerator < Generator 8 | def migration_name 9 | "add_pg_search_dmetaphone_support_functions" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/pg_search/migration/generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | require "rails/generators/base" 5 | 6 | module PgSearch 7 | module Migration 8 | class Generator < Rails::Generators::Base 9 | Rails::Generators.hide_namespace namespace 10 | 11 | def self.inherited(subclass) 12 | super 13 | subclass.source_root File.expand_path("templates", __dir__) 14 | end 15 | 16 | def create_migration 17 | now = Time.now.utc 18 | filename = "#{now.strftime("%Y%m%d%H%M%S")}_#{migration_name}.rb" 19 | template "#{migration_name}.rb.erb", "db/migrate/#{filename}", migration_version 20 | end 21 | 22 | private 23 | 24 | def read_sql_file(filename) 25 | sql_directory = File.expand_path("../../../sql", __dir__) 26 | source_path = File.join(sql_directory, "#{filename}.sql") 27 | File.read(source_path).strip 28 | end 29 | 30 | def migration_version 31 | if ActiveRecord::VERSION::MAJOR >= 5 32 | "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" 33 | else 34 | "" 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/pg_search/migration/multisearch_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_search/migration/generator" 4 | 5 | module PgSearch 6 | module Migration 7 | class MultisearchGenerator < Generator 8 | def migration_name 9 | "create_pg_search_documents" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/pg_search/migration/templates/add_pg_search_dmetaphone_support_functions.rb.erb: -------------------------------------------------------------------------------- 1 | class AddPgSearchDmetaphoneSupportFunctions < ActiveRecord::Migration<%= migration_version %> 2 | def up 3 | say_with_time("Adding support functions for pg_search :dmetaphone") do 4 | execute <<~'SQL'.squish 5 | <%= indent(read_sql_file("dmetaphone"), 8) %> 6 | SQL 7 | end 8 | end 9 | 10 | def down 11 | say_with_time("Dropping support functions for pg_search :dmetaphone") do 12 | execute <<~'SQL'.squish 13 | <%= indent(read_sql_file("uninstall_dmetaphone"), 8) %> 14 | SQL 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/pg_search/migration/templates/create_pg_search_documents.rb.erb: -------------------------------------------------------------------------------- 1 | class CreatePgSearchDocuments < ActiveRecord::Migration<%= migration_version %> 2 | def up 3 | say_with_time("Creating table for pg_search multisearch") do 4 | create_table :pg_search_documents do |t| 5 | t.text :content 6 | t.belongs_to :searchable, polymorphic: true, index: true 7 | t.timestamps null: false 8 | end 9 | end 10 | end 11 | 12 | def down 13 | say_with_time("Dropping table for pg_search multisearch") do 14 | drop_table :pg_search_documents 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/pg_search/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgSearch 4 | module Model 5 | extend ActiveSupport::Concern 6 | 7 | module ClassMethods 8 | def pg_search_scope(name, options) 9 | options_proc = if options.respond_to?(:call) 10 | options 11 | elsif options.respond_to?(:merge) 12 | ->(query) { {query: query}.merge(options) } 13 | else 14 | raise ArgumentError, "pg_search_scope expects a Hash or Proc" 15 | end 16 | 17 | define_singleton_method(name) do |*args| 18 | config = Configuration.new(options_proc.call(*args), self) 19 | scope_options = ScopeOptions.new(config) 20 | scope_options.apply(self) 21 | end 22 | end 23 | 24 | def multisearchable(options = {}) 25 | include PgSearch::Multisearchable 26 | class_attribute :pg_search_multisearchable_options 27 | self.pg_search_multisearchable_options = options 28 | end 29 | end 30 | 31 | def method_missing(symbol, *args) 32 | case symbol 33 | when :pg_search_rank 34 | raise PgSearchRankNotSelected unless respond_to?(:pg_search_rank) 35 | 36 | read_attribute(:pg_search_rank).to_f 37 | when :pg_search_highlight 38 | raise PgSearchHighlightNotSelected unless respond_to?(:pg_search_highlight) 39 | 40 | read_attribute(:pg_search_highlight) 41 | else 42 | super 43 | end 44 | end 45 | 46 | def respond_to_missing?(symbol, *args) 47 | case symbol 48 | when :pg_search_rank 49 | attributes.key?(:pg_search_rank) 50 | when :pg_search_highlight 51 | attributes.key?(:pg_search_highlight) 52 | else 53 | super 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/pg_search/multisearch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_search/multisearch/rebuilder" 4 | 5 | module PgSearch 6 | module Multisearch 7 | class << self 8 | def rebuild(model, deprecated_clean_up = nil, clean_up: true, transactional: true) 9 | unless deprecated_clean_up.nil? 10 | warn( 11 | "pg_search 3.0 will no longer accept a boolean second argument to PgSearchMultisearch.rebuild, " \ 12 | "use keyword argument `clean_up:` instead.", 13 | category: :deprecated, 14 | uplevel: 1 15 | ) 16 | clean_up = deprecated_clean_up 17 | end 18 | 19 | if transactional 20 | model.transaction { execute(model, clean_up) } 21 | else 22 | execute(model, clean_up) 23 | end 24 | end 25 | 26 | private 27 | 28 | def execute(model, clean_up) 29 | PgSearch::Document.where(searchable_type: model.base_class.name).delete_all if clean_up 30 | Rebuilder.new(model).rebuild 31 | end 32 | end 33 | 34 | class ModelNotMultisearchable < StandardError 35 | def initialize(model_class) 36 | super 37 | @model_class = model_class 38 | end 39 | 40 | def message 41 | "#{@model_class.name} is not multisearchable. See PgSearch::ClassMethods#multisearchable" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/pg_search/multisearch/rebuilder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgSearch 4 | module Multisearch 5 | class Rebuilder 6 | def initialize(model, time_source = Time.method(:now)) 7 | raise ModelNotMultisearchable, model unless model.respond_to?(:pg_search_multisearchable_options) 8 | 9 | @model = model 10 | @time_source = time_source 11 | end 12 | 13 | def rebuild 14 | if model.respond_to?(:rebuild_pg_search_documents) 15 | model.rebuild_pg_search_documents 16 | elsif conditional? || dynamic? || additional_attributes? 17 | model.find_each(&:update_pg_search_document) 18 | else 19 | model.connection.execute(rebuild_sql) 20 | end 21 | end 22 | 23 | private 24 | 25 | attr_reader :model 26 | 27 | def conditional? 28 | model.pg_search_multisearchable_options.key?(:if) || model.pg_search_multisearchable_options.key?(:unless) 29 | end 30 | 31 | def dynamic? 32 | column_names = model.columns.map(&:name) 33 | columns.any? { |column| column_names.exclude?(column.to_s) } 34 | end 35 | 36 | def additional_attributes? 37 | model.pg_search_multisearchable_options.key?(:additional_attributes) 38 | end 39 | 40 | def connection 41 | model.connection 42 | end 43 | 44 | def primary_key 45 | model.primary_key 46 | end 47 | 48 | def rebuild_sql_template 49 | <<~SQL.squish 50 | INSERT INTO :documents_table (searchable_type, searchable_id, content, created_at, updated_at) 51 | SELECT :base_model_name AS searchable_type, 52 | :model_table.#{primary_key} AS searchable_id, 53 | ( 54 | :content_expressions 55 | ) AS content, 56 | :current_time AS created_at, 57 | :current_time AS updated_at 58 | FROM :model_table :sti_clause 59 | SQL 60 | end 61 | 62 | def rebuild_sql 63 | replacements.inject(rebuild_sql_template) do |sql, key| 64 | sql.gsub ":#{key}", send(key) 65 | end 66 | end 67 | 68 | def sti_clause 69 | clause = "" 70 | if model.column_names.include? model.inheritance_column 71 | clause = "WHERE" 72 | clause = "#{clause} #{model.inheritance_column} IS NULL OR" if model.base_class == model 73 | clause = "#{clause} #{model.inheritance_column} = #{model_name}" 74 | end 75 | clause 76 | end 77 | 78 | def replacements 79 | %w[content_expressions base_model_name model_name model_table documents_table current_time sti_clause] 80 | end 81 | 82 | def content_expressions 83 | columns.map do |column| 84 | %{coalesce(:model_table.#{connection.quote_column_name(column)}::text, '')} 85 | end.join(" || ' ' || ") 86 | end 87 | 88 | def columns 89 | Array(model.pg_search_multisearchable_options[:against]) 90 | end 91 | 92 | def model_name 93 | connection.quote(model.name) 94 | end 95 | 96 | def base_model_name 97 | connection.quote(model.base_class.name) 98 | end 99 | 100 | def model_table 101 | model.quoted_table_name 102 | end 103 | 104 | def documents_table 105 | PgSearch::Document.quoted_table_name 106 | end 107 | 108 | def current_time 109 | connection.quote(connection.quoted_date(@time_source.call)) 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/pg_search/multisearchable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/class/attribute" 4 | 5 | module PgSearch 6 | module Multisearchable 7 | def self.included(mod) 8 | mod.class_eval do 9 | has_one :pg_search_document, 10 | as: :searchable, 11 | class_name: "PgSearch::Document", 12 | dependent: :delete 13 | 14 | after_save :update_pg_search_document, 15 | if: -> { PgSearch.multisearch_enabled? } 16 | end 17 | end 18 | 19 | def searchable_text 20 | Array(pg_search_multisearchable_options[:against]) 21 | .map { |symbol| send(symbol) } 22 | .join(" ") 23 | end 24 | 25 | def pg_search_document_attrs 26 | { 27 | content: searchable_text 28 | }.tap do |h| 29 | if (attrs = pg_search_multisearchable_options[:additional_attributes]) 30 | h.merge! attrs.to_proc.call(self) 31 | end 32 | end 33 | end 34 | 35 | def should_update_pg_search_document? 36 | return false if pg_search_document.destroyed? 37 | 38 | conditions = Array(pg_search_multisearchable_options[:update_if]) 39 | conditions.all? { |condition| condition.to_proc.call(self) } 40 | end 41 | 42 | def update_pg_search_document 43 | if_conditions = Array(pg_search_multisearchable_options[:if]) 44 | unless_conditions = Array(pg_search_multisearchable_options[:unless]) 45 | 46 | should_have_document = 47 | if_conditions.all? { |condition| condition.to_proc.call(self) } && 48 | unless_conditions.all? { |condition| !condition.to_proc.call(self) } 49 | 50 | if should_have_document 51 | create_or_update_pg_search_document 52 | else 53 | pg_search_document&.destroy # standard:disable Rails/SaveBang 54 | end 55 | end 56 | 57 | def create_or_update_pg_search_document 58 | if !pg_search_document 59 | create_pg_search_document(pg_search_document_attrs) 60 | elsif should_update_pg_search_document? 61 | pg_search_document.update(pg_search_document_attrs) # standard:disable Rails/SaveBang 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/pg_search/normalizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgSearch 4 | class Normalizer 5 | def initialize(config) 6 | @config = config 7 | end 8 | 9 | def add_normalization(sql_expression) 10 | return sql_expression unless config.ignore.include?(:accents) 11 | 12 | sql_node = case sql_expression 13 | when Arel::Nodes::Node 14 | sql_expression 15 | else 16 | Arel.sql(sql_expression) 17 | end 18 | 19 | Arel::Nodes::NamedFunction.new( 20 | PgSearch.unaccent_function, 21 | [sql_node] 22 | ).to_sql 23 | end 24 | 25 | private 26 | 27 | attr_reader :config 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/pg_search/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgSearch 4 | class Railtie < Rails::Railtie 5 | rake_tasks do 6 | load "pg_search/tasks.rb" 7 | end 8 | 9 | generators do 10 | require "pg_search/migration/multisearch_generator" 11 | require "pg_search/migration/dmetaphone_generator" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/pg_search/scope_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/module/delegation" 4 | 5 | module PgSearch 6 | class ScopeOptions 7 | attr_reader :config, :feature_options, :model 8 | 9 | def initialize(config) 10 | @config = config 11 | @model = config.model 12 | @feature_options = config.feature_options 13 | end 14 | 15 | def apply(scope) 16 | scope = include_table_aliasing_for_rank(scope) 17 | rank_table_alias = scope.pg_search_rank_table_alias(include_counter: true) 18 | 19 | scope 20 | .joins(rank_join(rank_table_alias)) 21 | .order(Arel.sql("#{rank_table_alias}.rank DESC, #{order_within_rank}")) 22 | .extend(WithPgSearchRank) 23 | .extend(WithPgSearchHighlight[feature_for(:tsearch)]) 24 | end 25 | 26 | module WithPgSearchHighlight 27 | def self.[](tsearch) 28 | Module.new do 29 | include WithPgSearchHighlight 30 | define_method(:tsearch) { tsearch } 31 | end 32 | end 33 | 34 | def tsearch 35 | raise TypeError, "You need to instantiate this module with []" 36 | end 37 | 38 | def with_pg_search_highlight 39 | scope = self 40 | scope = scope.select("#{table_name}.*") unless scope.select_values.any? 41 | scope.select("(#{highlight}) AS pg_search_highlight") 42 | end 43 | 44 | def highlight 45 | tsearch.highlight.to_sql 46 | end 47 | end 48 | 49 | module WithPgSearchRank 50 | def with_pg_search_rank 51 | scope = self 52 | scope = scope.select("#{table_name}.*") unless scope.select_values.any? 53 | scope.select("#{pg_search_rank_table_alias}.rank AS pg_search_rank") 54 | end 55 | end 56 | 57 | module PgSearchRankTableAliasing 58 | def pg_search_rank_table_alias(include_counter: false) 59 | components = [arel_table.name] 60 | if include_counter 61 | count = increment_counter 62 | components << count if count > 0 63 | end 64 | 65 | Configuration.alias(components) 66 | end 67 | 68 | private 69 | 70 | def increment_counter 71 | @counter ||= 0 72 | ensure 73 | @counter += 1 74 | end 75 | end 76 | 77 | private 78 | 79 | delegate :connection, :quoted_table_name, to: :model 80 | 81 | def subquery 82 | model 83 | .unscoped 84 | .select("#{primary_key} AS pg_search_id") 85 | .select("#{rank} AS rank") 86 | .joins(subquery_join) 87 | .where(conditions) 88 | .limit(nil) 89 | .offset(nil) 90 | end 91 | 92 | def conditions 93 | expressions = 94 | config.features 95 | .reject { |_feature_name, feature_options| feature_options && feature_options[:sort_only] } 96 | .map { |feature_name, _feature_options| feature_for(feature_name).conditions } 97 | 98 | or_node(expressions) 99 | end 100 | 101 | # https://github.com/rails/rails/pull/51492 102 | # :nocov: 103 | # standard:disable Lint/DuplicateMethods 104 | or_arity = Arel::Nodes::Or.instance_method(:initialize).arity 105 | case or_arity 106 | when 1 107 | def or_node(expressions) 108 | Arel::Nodes::Or.new(expressions) 109 | end 110 | when 2 111 | def or_node(expressions) 112 | expressions.inject { |accumulator, expression| Arel::Nodes::Or.new(accumulator, expression) } 113 | end 114 | else 115 | raise "Unsupported arity #{or_arity} for Arel::Nodes::Or#initialize" 116 | end 117 | # :nocov: 118 | # standard:enable Lint/DuplicateMethods 119 | 120 | def order_within_rank 121 | config.order_within_rank || "#{primary_key} ASC" 122 | end 123 | 124 | def primary_key 125 | "#{quoted_table_name}.#{connection.quote_column_name(model.primary_key)}" 126 | end 127 | 128 | def subquery_join 129 | if config.associations.any? 130 | config.associations.map do |association| 131 | association.join(primary_key) 132 | end.join(" ") 133 | end 134 | end 135 | 136 | FEATURE_CLASSES = { # standard:disable Lint/UselessConstantScoping 137 | dmetaphone: Features::DMetaphone, 138 | tsearch: Features::TSearch, 139 | trigram: Features::Trigram 140 | }.freeze 141 | 142 | def feature_for(feature_name) 143 | feature_name = feature_name.to_sym 144 | feature_class = FEATURE_CLASSES[feature_name] 145 | 146 | raise ArgumentError, "Unknown feature: #{feature_name}" unless feature_class 147 | 148 | normalizer = Normalizer.new(config) 149 | 150 | feature_class.new( 151 | config.query, 152 | feature_options[feature_name], 153 | config.columns, 154 | config.model, 155 | normalizer 156 | ) 157 | end 158 | 159 | def rank 160 | (config.ranking_sql || ":tsearch").gsub(/:(\w*)/) do 161 | feature_for(Regexp.last_match(1)).rank.to_sql 162 | end 163 | end 164 | 165 | def rank_join(rank_table_alias) 166 | "INNER JOIN (#{subquery.to_sql}) AS #{rank_table_alias} ON #{primary_key} = #{rank_table_alias}.pg_search_id" 167 | end 168 | 169 | def include_table_aliasing_for_rank(scope) 170 | return scope if scope.included_modules.include?(PgSearchRankTableAliasing) 171 | 172 | scope.all.spawn.tap do |new_scope| 173 | new_scope.instance_eval { extend PgSearchRankTableAliasing } 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/pg_search/tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rake" 4 | require "pg_search" 5 | 6 | namespace :pg_search do 7 | namespace :multisearch do 8 | desc "Rebuild PgSearch multisearch records for a given model" 9 | task :rebuild, %i[model schema] => :environment do |_task, args| 10 | raise ArgumentError, <<~MESSAGE unless args.model 11 | 12 | You must pass a model as an argument. 13 | Example: rake pg_search:multisearch:rebuild[BlogPost] 14 | MESSAGE 15 | 16 | model_class = args.model.classify.constantize 17 | connection = PgSearch::Document.connection 18 | original_schema_search_path = connection.schema_search_path 19 | begin 20 | connection.schema_search_path = args.schema if args.schema 21 | PgSearch::Multisearch.rebuild(model_class) 22 | ensure 23 | connection.schema_search_path = original_schema_search_path 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/pg_search/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgSearch 4 | VERSION = "2.3.7" 5 | end 6 | -------------------------------------------------------------------------------- /pg_search.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path("lib", __dir__) 4 | require "pg_search/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "pg_search" 8 | s.version = PgSearch::VERSION 9 | s.platform = Gem::Platform::RUBY 10 | s.authors = ["Grant Hutchins", "Case Commons, LLC"] 11 | s.email = %w[gems@nertzy.com casecommons-dev@googlegroups.com] 12 | s.homepage = "https://github.com/Casecommons/pg_search" 13 | s.summary = "PgSearch builds Active Record named scopes that take advantage of PostgreSQL's full text search" 14 | s.description = "PgSearch builds Active Record named scopes that take advantage of PostgreSQL's full text search" 15 | s.licenses = ["MIT"] 16 | s.metadata["rubygems_mfa_required"] = "true" 17 | 18 | s.files = `git ls-files -z`.split("\x0") 19 | s.require_paths = ["lib"] 20 | 21 | s.add_dependency "activerecord", ">= 7.1" 22 | s.add_dependency "activesupport", ">= 7.1" 23 | 24 | s.required_ruby_version = ">= 3.2" 25 | end 26 | -------------------------------------------------------------------------------- /spec/.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - ../.rubocop.yml 3 | 4 | RSpec/ContextWording: 5 | Prefixes: 6 | - using 7 | - via 8 | - when 9 | - with 10 | - without 11 | 12 | RSpec/DescribedClass: 13 | Enabled: true 14 | 15 | RSpec/ExampleLength: 16 | Max: 15 17 | 18 | RSpec/ExpectInHook: 19 | Enabled: false 20 | 21 | RSpec/FilePath: 22 | CustomTransform: 23 | TSearch: "tsearch" 24 | DMetaphone: "dmetaphone" 25 | 26 | RSpec/MultipleExpectations: 27 | Max: 5 28 | -------------------------------------------------------------------------------- /spec/integration/.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - ../.rubocop.yml 3 | 4 | RSpec/DescribeClass: 5 | Enabled: false 6 | 7 | RSpec/ExampleLength: 8 | Enabled: false 9 | 10 | RSpec/MultipleExpectations: 11 | Enabled: false 12 | -------------------------------------------------------------------------------- /spec/integration/associations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | # standard:disable RSpec/NestedGroups 6 | describe "a pg_search_scope" do 7 | context "when joining to another table" do 8 | context "without an :against" do 9 | with_model :AssociatedModel do 10 | table do |t| 11 | t.string "title" 12 | end 13 | end 14 | 15 | with_model :ModelWithoutAgainst do 16 | table do |t| 17 | t.string "title" 18 | t.belongs_to :another_model, index: false 19 | end 20 | 21 | model do 22 | include PgSearch::Model 23 | belongs_to :another_model, class_name: "AssociatedModel" 24 | 25 | pg_search_scope :with_another, associated_against: {another_model: :title} 26 | end 27 | end 28 | 29 | it "returns rows that match the query in the columns of the associated model only" do 30 | associated = AssociatedModel.create!(title: "abcdef") 31 | included = [ 32 | ModelWithoutAgainst.create!(title: "abcdef", another_model: associated), 33 | ModelWithoutAgainst.create!(title: "ghijkl", another_model: associated) 34 | ] 35 | excluded = [ 36 | ModelWithoutAgainst.create!(title: "abcdef") 37 | ] 38 | 39 | results = ModelWithoutAgainst.with_another("abcdef") 40 | expect(results.map(&:title)).to match_array(included.map(&:title)) 41 | expect(results).not_to include(excluded) 42 | end 43 | end 44 | 45 | context "via a belongs_to association" do 46 | with_model :AssociatedModel do 47 | table do |t| 48 | t.string "title" 49 | end 50 | end 51 | 52 | with_model :ModelWithBelongsTo do 53 | table do |t| 54 | t.string "title" 55 | t.belongs_to "another_model", index: false 56 | end 57 | 58 | model do 59 | include PgSearch::Model 60 | belongs_to :another_model, class_name: "AssociatedModel" 61 | 62 | pg_search_scope :with_associated, against: :title, associated_against: {another_model: :title} 63 | end 64 | end 65 | 66 | it "returns rows that match the query in either its own columns or the columns of the associated model" do 67 | associated = AssociatedModel.create!(title: "abcdef") 68 | included = [ 69 | ModelWithBelongsTo.create!(title: "ghijkl", another_model: associated), 70 | ModelWithBelongsTo.create!(title: "abcdef") 71 | ] 72 | excluded = ModelWithBelongsTo.create!(title: "mnopqr", 73 | another_model: AssociatedModel.create!(title: "stuvwx")) 74 | 75 | results = ModelWithBelongsTo.with_associated("abcdef") 76 | expect(results.map(&:title)).to match_array(included.map(&:title)) 77 | expect(results).not_to include(excluded) 78 | end 79 | end 80 | 81 | context "via a has_many association" do 82 | with_model :AssociatedModelWithHasMany do 83 | table do |t| 84 | t.string "title" 85 | t.belongs_to "ModelWithHasMany", index: false 86 | end 87 | end 88 | 89 | with_model :ModelWithHasMany do 90 | table do |t| 91 | t.string "title" 92 | end 93 | 94 | model do 95 | include PgSearch::Model 96 | has_many :other_models, class_name: "AssociatedModelWithHasMany", foreign_key: "ModelWithHasMany_id" 97 | 98 | pg_search_scope :with_associated, against: [:title], associated_against: {other_models: :title} 99 | end 100 | end 101 | 102 | it "returns rows that match the query in either its own columns or the columns of the associated model" do 103 | included = [ 104 | ModelWithHasMany.create!(title: "abcdef", other_models: [ 105 | AssociatedModelWithHasMany.create!(title: "foo"), 106 | AssociatedModelWithHasMany.create!(title: "bar") 107 | ]), 108 | ModelWithHasMany.create!(title: "ghijkl", other_models: [ 109 | AssociatedModelWithHasMany.create!(title: "foo bar"), 110 | AssociatedModelWithHasMany.create!(title: "mnopqr") 111 | ]), 112 | ModelWithHasMany.create!(title: "foo bar") 113 | ] 114 | excluded = ModelWithHasMany.create!(title: "stuvwx", other_models: [ 115 | AssociatedModelWithHasMany.create!(title: "abcdef") 116 | ]) 117 | 118 | results = ModelWithHasMany.with_associated("foo bar") 119 | expect(results.map(&:title)).to match_array(included.map(&:title)) 120 | expect(results).not_to include(excluded) 121 | end 122 | 123 | it "uses an unscoped relation of the associated model" do 124 | excluded = ModelWithHasMany.create!(title: "abcdef", other_models: [ 125 | AssociatedModelWithHasMany.create!(title: "abcdef") 126 | ]) 127 | 128 | included = [ 129 | ModelWithHasMany.create!(title: "abcdef", other_models: [ 130 | AssociatedModelWithHasMany.create!(title: "foo"), 131 | AssociatedModelWithHasMany.create!(title: "bar") 132 | ]) 133 | ] 134 | 135 | results = ModelWithHasMany 136 | .limit(1) 137 | .order(Arel.sql("#{ModelWithHasMany.quoted_table_name}.id ASC")) 138 | .with_associated("foo bar") 139 | 140 | expect(results.map(&:title)).to match_array(included.map(&:title)) 141 | expect(results).not_to include(excluded) 142 | end 143 | end 144 | 145 | context "when across multiple associations" do 146 | context "when on different tables" do 147 | with_model :FirstAssociatedModel do 148 | table do |t| 149 | t.string "title" 150 | t.belongs_to "ModelWithManyAssociations", index: false 151 | end 152 | end 153 | 154 | with_model :SecondAssociatedModel do 155 | table do |t| 156 | t.string "title" 157 | end 158 | end 159 | 160 | with_model :ModelWithManyAssociations do 161 | table do |t| 162 | t.string "title" 163 | t.belongs_to "model_of_second_type", index: false 164 | end 165 | 166 | model do 167 | include PgSearch::Model 168 | 169 | has_many :models_of_first_type, 170 | class_name: "FirstAssociatedModel", 171 | foreign_key: "ModelWithManyAssociations_id" 172 | 173 | belongs_to :model_of_second_type, 174 | class_name: "SecondAssociatedModel" 175 | 176 | pg_search_scope :with_associated, 177 | against: :title, 178 | associated_against: {models_of_first_type: :title, model_of_second_type: :title} 179 | end 180 | end 181 | 182 | it "returns rows that match the query in either its own columns or the columns of the associated model" do 183 | matching_second = SecondAssociatedModel.create!(title: "foo bar") 184 | unmatching_second = SecondAssociatedModel.create!(title: "uiop") 185 | 186 | included = [ 187 | ModelWithManyAssociations.create!(title: "abcdef", models_of_first_type: [ 188 | FirstAssociatedModel.create!(title: "foo"), 189 | FirstAssociatedModel.create!(title: "bar") 190 | ]), 191 | ModelWithManyAssociations.create!(title: "ghijkl", models_of_first_type: [ 192 | FirstAssociatedModel.create!(title: "foo bar"), 193 | FirstAssociatedModel.create!(title: "mnopqr") 194 | ]), 195 | ModelWithManyAssociations.create!(title: "foo bar"), 196 | ModelWithManyAssociations.create!(title: "qwerty", model_of_second_type: matching_second) 197 | ] 198 | excluded = [ 199 | ModelWithManyAssociations.create!(title: "stuvwx", models_of_first_type: [ 200 | FirstAssociatedModel.create!(title: "abcdef") 201 | ]), 202 | ModelWithManyAssociations.create!(title: "qwerty", model_of_second_type: unmatching_second) 203 | ] 204 | 205 | results = ModelWithManyAssociations.with_associated("foo bar") 206 | expect(results.map(&:title)).to match_array(included.map(&:title)) 207 | excluded.each { |object| expect(results).not_to include(object) } 208 | end 209 | end 210 | 211 | context "when on the same table" do 212 | with_model :DoublyAssociatedModel do 213 | table do |t| 214 | t.string "title" 215 | t.belongs_to "ModelWithDoubleAssociation", index: false 216 | t.belongs_to "ModelWithDoubleAssociation_again", index: false 217 | end 218 | end 219 | 220 | with_model :ModelWithDoubleAssociation do 221 | table do |t| 222 | t.string "title" 223 | end 224 | 225 | model do 226 | include PgSearch::Model 227 | 228 | has_many :things, 229 | class_name: "DoublyAssociatedModel", 230 | foreign_key: "ModelWithDoubleAssociation_id" 231 | 232 | has_many :thingamabobs, 233 | class_name: "DoublyAssociatedModel", 234 | foreign_key: "ModelWithDoubleAssociation_again_id" 235 | 236 | pg_search_scope :with_associated, against: :title, 237 | associated_against: {things: :title, thingamabobs: :title} 238 | end 239 | end 240 | 241 | it "returns rows that match the query in either its own columns or the columns of the associated model" do 242 | included = [ 243 | ModelWithDoubleAssociation.create!(title: "abcdef", things: [ 244 | DoublyAssociatedModel.create!(title: "foo"), 245 | DoublyAssociatedModel.create!(title: "bar") 246 | ]), 247 | ModelWithDoubleAssociation.create!(title: "ghijkl", things: [ 248 | DoublyAssociatedModel.create!(title: "foo bar"), 249 | DoublyAssociatedModel.create!(title: "mnopqr") 250 | ]), 251 | ModelWithDoubleAssociation.create!(title: "foo bar"), 252 | ModelWithDoubleAssociation.create!(title: "qwerty", thingamabobs: [ 253 | DoublyAssociatedModel.create!(title: "foo bar") 254 | ]) 255 | ] 256 | excluded = [ 257 | ModelWithDoubleAssociation.create!(title: "stuvwx", things: [ 258 | DoublyAssociatedModel.create!(title: "abcdef") 259 | ]), 260 | ModelWithDoubleAssociation.create!(title: "qwerty", thingamabobs: [ 261 | DoublyAssociatedModel.create!(title: "uiop") 262 | ]) 263 | ] 264 | 265 | results = ModelWithDoubleAssociation.with_associated("foo bar") 266 | expect(results.map(&:title)).to match_array(included.map(&:title)) 267 | excluded.each { |object| expect(results).not_to include(object) } 268 | end 269 | end 270 | end 271 | 272 | context "when against multiple attributes on one association" do 273 | with_model :AssociatedModel do 274 | table do |t| 275 | t.string "title" 276 | t.text "author" 277 | end 278 | end 279 | 280 | with_model :ModelWithAssociation do 281 | table do |t| 282 | t.belongs_to "another_model", index: false 283 | end 284 | 285 | model do 286 | include PgSearch::Model 287 | belongs_to :another_model, class_name: "AssociatedModel" 288 | 289 | pg_search_scope :with_associated, associated_against: {another_model: %i[title author]} 290 | end 291 | end 292 | 293 | it "joins only once" do 294 | included = [ 295 | ModelWithAssociation.create!( 296 | another_model: AssociatedModel.create!( 297 | title: "foo", 298 | author: "bar" 299 | ) 300 | ), 301 | ModelWithAssociation.create!( 302 | another_model: AssociatedModel.create!( 303 | title: "foo bar", 304 | author: "baz" 305 | ) 306 | ) 307 | ] 308 | excluded = [ 309 | ModelWithAssociation.create!( 310 | another_model: AssociatedModel.create!( 311 | title: "foo", 312 | author: "baz" 313 | ) 314 | ) 315 | ] 316 | 317 | results = ModelWithAssociation.with_associated("foo bar") 318 | 319 | expect(results.to_sql.scan("INNER JOIN #{AssociatedModel.quoted_table_name}").length).to eq(1) 320 | included.each { |object| expect(results).to include(object) } 321 | excluded.each { |object| expect(results).not_to include(object) } 322 | end 323 | end 324 | 325 | context "when against non-text columns" do 326 | with_model :AssociatedModel do 327 | table do |t| 328 | t.integer "number" 329 | end 330 | end 331 | 332 | with_model :Model do 333 | table do |t| 334 | t.integer "number" 335 | t.belongs_to "another_model", index: false 336 | end 337 | 338 | model do 339 | include PgSearch::Model 340 | belongs_to :another_model, class_name: "AssociatedModel" 341 | 342 | pg_search_scope :with_associated, associated_against: {another_model: :number} 343 | end 344 | end 345 | 346 | it "casts the columns to text" do 347 | associated = AssociatedModel.create!(number: 123) 348 | included = [ 349 | Model.create!(number: 123, another_model: associated), 350 | Model.create!(number: 456, another_model: associated) 351 | ] 352 | excluded = [ 353 | Model.create!(number: 123) 354 | ] 355 | 356 | results = Model.with_associated("123") 357 | expect(results.map(&:number)).to match_array(included.map(&:number)) 358 | expect(results).not_to include(excluded) 359 | end 360 | end 361 | 362 | context "when including the associated model" do 363 | with_model :Parent do 364 | table do |t| 365 | t.text :name 366 | end 367 | 368 | model do 369 | has_many :children 370 | include PgSearch::Model 371 | pg_search_scope :search_name, against: :name 372 | end 373 | end 374 | 375 | with_model :Child do 376 | table do |t| 377 | t.belongs_to :parent 378 | end 379 | 380 | model do 381 | belongs_to :parent 382 | end 383 | end 384 | 385 | # https://github.com/Casecommons/pg_search/issues/14 386 | it "supports queries with periods" do 387 | included = Parent.create!(name: "bar.foo") 388 | excluded = Parent.create!(name: "foo.bar") 389 | 390 | results = Parent.search_name("bar.foo").includes(:children) 391 | results.to_a 392 | 393 | expect(results).to include(included) 394 | expect(results).not_to include(excluded) 395 | end 396 | end 397 | end 398 | 399 | context "when merging a pg_search_scope into another model's scope" do 400 | with_model :ModelWithAssociation do 401 | model do 402 | has_many :associated_models 403 | end 404 | end 405 | 406 | with_model :AssociatedModel do 407 | table do |t| 408 | t.string :content 409 | t.belongs_to :model_with_association, index: false 410 | end 411 | 412 | model do 413 | include PgSearch::Model 414 | belongs_to :model_with_association 415 | 416 | pg_search_scope :search_content, against: :content 417 | end 418 | end 419 | 420 | it "finds records of the other model" do 421 | included_associated_1 = AssociatedModel.create!(content: "foo bar") 422 | included_associated_2 = AssociatedModel.create!(content: "foo baz") 423 | excluded_associated_1 = AssociatedModel.create!(content: "baz quux") 424 | excluded_associated_2 = AssociatedModel.create!(content: "baz bar") 425 | 426 | included = [ 427 | ModelWithAssociation.create(associated_models: [included_associated_1]), 428 | ModelWithAssociation.create(associated_models: [included_associated_2, excluded_associated_1]) 429 | ] 430 | 431 | excluded = [ 432 | ModelWithAssociation.create(associated_models: [excluded_associated_2]), 433 | ModelWithAssociation.create(associated_models: []) 434 | ] 435 | 436 | relation = AssociatedModel.search_content("foo") 437 | 438 | results = ModelWithAssociation.joins(:associated_models).merge(relation) 439 | 440 | expect(results).to include(*included) 441 | expect(results).not_to include(*excluded) 442 | end 443 | end 444 | 445 | context "when chained onto a has_many association" do 446 | with_model :Company do 447 | model do 448 | has_many :positions 449 | end 450 | end 451 | 452 | with_model :Position do 453 | table do |t| 454 | t.string :title 455 | t.belongs_to :company 456 | end 457 | 458 | model do 459 | include PgSearch::Model 460 | pg_search_scope :search, against: :title, using: %i[tsearch trigram] 461 | end 462 | end 463 | 464 | # https://github.com/Casecommons/pg_search/issues/106 465 | it "handles numbers in a trigram query properly" do 466 | company = Company.create! 467 | another_company = Company.create! 468 | 469 | included = [ 470 | Position.create!(company_id: company.id, title: "teller 1"), 471 | Position.create!(company_id: company.id, title: "teller 2") # close enough 472 | ] 473 | 474 | excluded = [ 475 | Position.create!(company_id: nil, title: "teller 1"), 476 | Position.create!(company_id: another_company.id, title: "teller 1"), 477 | Position.create!(company_id: company.id, title: "penn 1") 478 | ] 479 | 480 | results = company.positions.search("teller 1") 481 | 482 | expect(results).to include(*included) 483 | expect(results).not_to include(*excluded) 484 | end 485 | end 486 | end 487 | # standard:enable RSpec/NestedGroups 488 | -------------------------------------------------------------------------------- /spec/integration/deprecation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "active_support/core_ext/kernel/reporting" 5 | 6 | describe "Including the deprecated PgSearch module" do 7 | with_model :SomeModel do 8 | model do 9 | silence_warnings do 10 | include PgSearch 11 | end 12 | end 13 | end 14 | 15 | with_model :AnotherModel 16 | 17 | it "includes PgSearch::Model" do 18 | expect(SomeModel.ancestors).to include PgSearch::Model 19 | end 20 | 21 | it "prints a deprecation message" do 22 | allow(PgSearch).to receive(:warn) 23 | 24 | AnotherModel.include(PgSearch) 25 | 26 | expect(PgSearch).to have_received(:warn).with(<<~MESSAGE, category: :deprecated, uplevel: 1) 27 | Directly including `PgSearch` into an Active Record model is deprecated and will be removed in pg_search 3.0. 28 | 29 | Please replace `include PgSearch` with `include PgSearch::Model`. 30 | MESSAGE 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/integration/pagination_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe "pagination" do 6 | describe "using LIMIT and OFFSET" do 7 | with_model :PaginatedModel do 8 | table do |t| 9 | t.string :name 10 | end 11 | 12 | model do 13 | include PgSearch::Model 14 | pg_search_scope :search_name, against: :name 15 | 16 | def self.page(page_number) 17 | offset = (page_number - 1) * 2 18 | limit(2).offset(offset) 19 | end 20 | end 21 | end 22 | 23 | it "is chainable before a search scope" do 24 | better = PaginatedModel.create!(name: "foo foo bar") 25 | best = PaginatedModel.create!(name: "foo foo foo") 26 | good = PaginatedModel.create!(name: "foo bar bar") 27 | 28 | expect(PaginatedModel.page(1).search_name("foo")).to eq([best, better]) 29 | expect(PaginatedModel.page(2).search_name("foo")).to eq([good]) 30 | end 31 | 32 | it "is chainable after a search scope" do 33 | better = PaginatedModel.create!(name: "foo foo bar") 34 | best = PaginatedModel.create!(name: "foo foo foo") 35 | good = PaginatedModel.create!(name: "foo bar bar") 36 | 37 | expect(PaginatedModel.search_name("foo").page(1)).to eq([best, better]) 38 | expect(PaginatedModel.search_name("foo").page(2)).to eq([good]) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/integration/single_table_inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe "a pg_search_scope on an STI subclass" do 6 | context "with the standard type column" do 7 | with_model :SuperclassModel do 8 | table do |t| 9 | t.text "content" 10 | t.string "type" 11 | end 12 | 13 | model do 14 | include PgSearch::Model 15 | pg_search_scope :search_content, against: :content 16 | end 17 | end 18 | 19 | before do 20 | stub_const("SearchableSubclassModel", Class.new(SuperclassModel)) 21 | stub_const("AnotherSearchableSubclassModel", Class.new(SuperclassModel)) 22 | end 23 | 24 | it "returns only results for that subclass" do 25 | included = [ 26 | SearchableSubclassModel.create!(content: "foo bar") 27 | ] 28 | excluded = [ 29 | SearchableSubclassModel.create!(content: "baz"), 30 | SuperclassModel.create!(content: "foo bar"), 31 | SuperclassModel.create!(content: "baz"), 32 | AnotherSearchableSubclassModel.create!(content: "foo bar"), 33 | AnotherSearchableSubclassModel.create!(content: "baz") 34 | ] 35 | 36 | expect(SuperclassModel.count).to eq(6) 37 | expect(SearchableSubclassModel.count).to eq(2) 38 | 39 | results = SearchableSubclassModel.search_content("foo bar") 40 | 41 | expect(results).to include(*included) 42 | expect(results).not_to include(*excluded) 43 | end 44 | end 45 | 46 | context "with a custom type column" do 47 | with_model :SuperclassModel do 48 | table do |t| 49 | t.text "content" 50 | t.string "custom_type" 51 | end 52 | 53 | model do 54 | include PgSearch::Model 55 | self.inheritance_column = "custom_type" 56 | pg_search_scope :search_content, against: :content 57 | end 58 | end 59 | 60 | before do 61 | stub_const("SearchableSubclassModel", Class.new(SuperclassModel)) 62 | stub_const("AnotherSearchableSubclassModel", Class.new(SuperclassModel)) 63 | end 64 | 65 | it "returns only results for that subclass" do 66 | included = [ 67 | SearchableSubclassModel.create!(content: "foo bar") 68 | ] 69 | excluded = [ 70 | SearchableSubclassModel.create!(content: "baz"), 71 | SuperclassModel.create!(content: "foo bar"), 72 | SuperclassModel.create!(content: "baz"), 73 | AnotherSearchableSubclassModel.create!(content: "foo bar"), 74 | AnotherSearchableSubclassModel.create!(content: "baz") 75 | ] 76 | 77 | expect(SuperclassModel.count).to eq(6) 78 | expect(SearchableSubclassModel.count).to eq(2) 79 | 80 | results = SearchableSubclassModel.search_content("foo bar") 81 | 82 | expect(results).to include(*included) 83 | expect(results).not_to include(*excluded) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/lib/pg_search/configuration/association_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | # standard:disable RSpec/NestedGroups 6 | describe PgSearch::Configuration::Association do 7 | with_model :Avatar do 8 | table do |t| 9 | t.string :url 10 | t.references :user 11 | end 12 | end 13 | 14 | with_model :User do 15 | table do |t| 16 | t.string :name 17 | t.belongs_to :site 18 | end 19 | 20 | model do 21 | include PgSearch::Model 22 | has_one :avatar, class_name: "Avatar" 23 | belongs_to :site 24 | 25 | pg_search_scope :with_avatar, associated_against: {avatar: :url} 26 | pg_search_scope :with_site, associated_against: {site: :title} 27 | end 28 | end 29 | 30 | with_model :Site do 31 | table do |t| 32 | t.string :title 33 | end 34 | 35 | model do 36 | include PgSearch::Model 37 | has_many :users, class_name: "User" 38 | 39 | pg_search_scope :with_users, associated_against: {users: :name} 40 | end 41 | end 42 | 43 | context "with has_one" do 44 | let(:association) { described_class.new(User, :avatar, :url) } 45 | 46 | describe "#table_name" do 47 | it "returns the table name for the associated model" do 48 | expect(association.table_name).to eq Avatar.table_name 49 | end 50 | end 51 | 52 | describe "#join" do 53 | let(:expected_sql) do 54 | <<~SQL.squish 55 | LEFT OUTER JOIN 56 | (SELECT model_id AS id, 57 | #{column_select} AS #{association.columns.first.alias} 58 | FROM "#{User.table_name}" 59 | INNER JOIN "#{association.table_name}" 60 | ON "#{association.table_name}"."user_id" = "#{User.table_name}"."id") #{association.subselect_alias} 61 | ON #{association.subselect_alias}.id = model_id 62 | SQL 63 | end 64 | let(:column_select) do 65 | "\"#{association.table_name}\".\"url\"::text" 66 | end 67 | 68 | it "returns the correct SQL join" do 69 | expect(association.join("model_id")).to eq(expected_sql) 70 | end 71 | end 72 | end 73 | 74 | context "with belongs_to" do 75 | let(:association) { described_class.new(User, :site, :title) } 76 | 77 | describe "#table_name" do 78 | it "returns the table name for the associated model" do 79 | expect(association.table_name).to eq Site.table_name 80 | end 81 | end 82 | 83 | describe "#join" do 84 | let(:expected_sql) do 85 | <<~SQL.squish 86 | LEFT OUTER JOIN 87 | (SELECT model_id AS id, 88 | #{column_select} AS #{association.columns.first.alias} 89 | FROM "#{User.table_name}" 90 | INNER JOIN "#{association.table_name}" 91 | ON "#{association.table_name}"."id" = "#{User.table_name}"."site_id") #{association.subselect_alias} 92 | ON #{association.subselect_alias}.id = model_id 93 | SQL 94 | end 95 | let(:column_select) do 96 | "\"#{association.table_name}\".\"title\"::text" 97 | end 98 | 99 | it "returns the correct SQL join" do 100 | expect(association.join("model_id")).to eq(expected_sql) 101 | end 102 | end 103 | end 104 | 105 | context "with has_many" do 106 | let(:association) { described_class.new(Site, :users, :name) } 107 | 108 | describe "#table_name" do 109 | it "returns the table name for the associated model" do 110 | expect(association.table_name).to eq User.table_name 111 | end 112 | end 113 | 114 | describe "#join" do 115 | let(:expected_sql) do 116 | <<~SQL.squish 117 | LEFT OUTER JOIN 118 | (SELECT model_id AS id, 119 | string_agg("#{association.table_name}"."name"::text, ' ') AS #{association.columns.first.alias} 120 | FROM "#{Site.table_name}" 121 | INNER JOIN "#{association.table_name}" 122 | ON "#{association.table_name}"."site_id" = "#{Site.table_name}"."id" 123 | GROUP BY model_id) #{association.subselect_alias} 124 | ON #{association.subselect_alias}.id = model_id 125 | SQL 126 | end 127 | 128 | it "returns the correct SQL join" do 129 | expect(association.join("model_id")).to eq(expected_sql) 130 | end 131 | 132 | describe "#subselect_alias" do 133 | it "returns a consistent string" do 134 | subselect_alias = association.subselect_alias 135 | expect(subselect_alias).to be_a String 136 | expect(association.subselect_alias).to eq subselect_alias 137 | end 138 | end 139 | end 140 | end 141 | end 142 | # standard:enable RSpec/NestedGroups 143 | -------------------------------------------------------------------------------- /spec/lib/pg_search/configuration/column_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe PgSearch::Configuration::Column do 6 | describe "#full_name" do 7 | with_model :Model do 8 | table do |t| 9 | t.string :name 10 | t.json :object 11 | end 12 | end 13 | 14 | it "returns the fully-qualified table and column name" do 15 | column = described_class.new("name", nil, Model) 16 | expect(column.full_name).to eq(%(#{Model.quoted_table_name}."name")) 17 | end 18 | 19 | it "returns nested json attributes" do 20 | column = described_class.new(Arel.sql("object->>'name'"), nil, Model) 21 | expect(column.full_name).to eq(%(object->>'name')) 22 | end 23 | end 24 | 25 | describe "#to_sql" do 26 | with_model :Model do 27 | table do |t| 28 | t.string :name 29 | t.json :object 30 | end 31 | end 32 | 33 | it "returns an expression that casts the column to text and coalesces it with an empty string" do 34 | column = described_class.new("name", nil, Model) 35 | expect(column.to_sql).to eq(%{coalesce((#{Model.quoted_table_name}."name")::text, '')}) 36 | end 37 | 38 | it "returns an expression that casts the nested json attribute to text and coalesces it with an empty string" do 39 | column = described_class.new(Arel.sql("object->>'name'"), nil, Model) 40 | expect(column.to_sql).to eq(%{coalesce((object->>'name')::text, '')}) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/lib/pg_search/configuration/foreign_column_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe PgSearch::Configuration::ForeignColumn do 6 | describe "#alias" do 7 | with_model :AssociatedModel do 8 | table do |t| 9 | t.string "title" 10 | end 11 | end 12 | 13 | with_model :Model do 14 | table do |t| 15 | t.string "title" 16 | t.belongs_to :another_model, index: false 17 | end 18 | 19 | model do 20 | include PgSearch::Model 21 | belongs_to :another_model, class_name: "AssociatedModel" 22 | 23 | pg_search_scope :with_another, associated_against: {another_model: :title} 24 | end 25 | end 26 | 27 | it "returns a consistent string" do 28 | association = PgSearch::Configuration::Association.new(Model, 29 | :another_model, 30 | :title) 31 | foreign_column = described_class.new("title", nil, Model, association) 32 | 33 | column_alias = foreign_column.alias 34 | expect(column_alias).to be_a String 35 | expect(foreign_column.alias).to eq column_alias 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/lib/pg_search/features/dmetaphone_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe PgSearch::Features::DMetaphone do 6 | describe "#rank" do 7 | with_model :Model do 8 | table do |t| 9 | t.string :name 10 | t.text :content 11 | end 12 | end 13 | 14 | it "returns an expression similar to a TSearch, but wraps the arguments in pg_search_dmetaphone()" do 15 | query = "query" 16 | columns = [ 17 | PgSearch::Configuration::Column.new(:name, nil, Model), 18 | PgSearch::Configuration::Column.new(:content, nil, Model) 19 | ] 20 | options = {} 21 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 22 | normalizer = PgSearch::Normalizer.new(config) 23 | 24 | feature = described_class.new(query, options, columns, Model, normalizer) 25 | expect(feature.rank.to_sql).to eq( 26 | %{(ts_rank((to_tsvector('simple', pg_search_dmetaphone(coalesce((#{Model.quoted_table_name}."name")::text, ''))) || to_tsvector('simple', pg_search_dmetaphone(coalesce((#{Model.quoted_table_name}."content")::text, '')))), (to_tsquery('simple', ''' ' || pg_search_dmetaphone('query') || ' ''')), 0))} 27 | ) 28 | end 29 | end 30 | 31 | describe "#conditions" do 32 | with_model :Model do 33 | table do |t| 34 | t.string :name 35 | t.text :content 36 | end 37 | end 38 | 39 | it "returns an expression similar to a TSearch, but wraps the arguments in pg_search_dmetaphone()" do 40 | query = "query" 41 | columns = [ 42 | PgSearch::Configuration::Column.new(:name, nil, Model), 43 | PgSearch::Configuration::Column.new(:content, nil, Model) 44 | ] 45 | options = {} 46 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 47 | normalizer = PgSearch::Normalizer.new(config) 48 | 49 | feature = described_class.new(query, options, columns, Model, normalizer) 50 | expect(feature.conditions.to_sql).to eq( 51 | %{((to_tsvector('simple', pg_search_dmetaphone(coalesce((#{Model.quoted_table_name}."name")::text, ''))) || to_tsvector('simple', pg_search_dmetaphone(coalesce((#{Model.quoted_table_name}."content")::text, '')))) @@ (to_tsquery('simple', ''' ' || pg_search_dmetaphone('query') || ' ''')))} 52 | ) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/lib/pg_search/features/trigram_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | # standard:disable RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups 6 | describe PgSearch::Features::Trigram do 7 | subject(:feature) { described_class.new(query, options, columns, Model, normalizer) } 8 | 9 | let(:query) { "lolwut" } 10 | let(:options) { {} } 11 | let(:columns) { 12 | [ 13 | PgSearch::Configuration::Column.new(:name, nil, Model), 14 | PgSearch::Configuration::Column.new(:content, nil, Model) 15 | ] 16 | } 17 | let(:normalizer) { PgSearch::Normalizer.new(config) } 18 | let(:config) { instance_double(PgSearch::Configuration, ignore: ignore) } 19 | let(:ignore) { [] } 20 | 21 | let(:coalesced_columns) do 22 | <<~SQL.squish 23 | coalesce((#{Model.quoted_table_name}."name")::text, '') 24 | || ' ' 25 | || coalesce((#{Model.quoted_table_name}."content")::text, '') 26 | SQL 27 | end 28 | 29 | with_model :Model do 30 | table do |t| 31 | t.string :name 32 | t.string :content 33 | end 34 | end 35 | 36 | describe "conditions" do 37 | it "escapes the search document and query" do 38 | expect(feature.conditions.to_sql).to eq("('#{query}' % (#{coalesced_columns}))") 39 | end 40 | 41 | context "when searching by word_similarity" do 42 | let(:options) do 43 | {word_similarity: true} 44 | end 45 | 46 | it 'uses the "<%" operator when searching by word_similarity' do 47 | expect(feature.conditions.to_sql).to eq("('#{query}' <% (#{coalesced_columns}))") 48 | end 49 | end 50 | 51 | context "when ignoring accents" do 52 | let(:ignore) { [:accents] } 53 | 54 | it "escapes the search document and query, but not the accent function" do 55 | expect(feature.conditions.to_sql).to eq("(unaccent('#{query}') % (unaccent(#{coalesced_columns})))") 56 | end 57 | end 58 | 59 | context "when a threshold is specified" do 60 | context "when searching by similarity" do 61 | let(:options) do 62 | {threshold: 0.5} 63 | end 64 | 65 | it 'uses a minimum similarity expression instead of the "%" operator' do 66 | expect(feature.conditions.to_sql).to eq( 67 | "(similarity('#{query}', (#{coalesced_columns})) >= 0.5)" 68 | ) 69 | end 70 | end 71 | 72 | context "when searching by word_similarity" do 73 | let(:options) do 74 | {threshold: 0.5, word_similarity: true} 75 | end 76 | 77 | it 'uses a minimum similarity expression instead of the "<%" operator' do 78 | expect(feature.conditions.to_sql).to eq( 79 | "(word_similarity('#{query}', (#{coalesced_columns})) >= 0.5)" 80 | ) 81 | end 82 | end 83 | end 84 | 85 | context "when only certain columns are selected" do 86 | context "with one column" do 87 | let(:options) { {only: :name} } 88 | 89 | it "only searches against the select column" do 90 | coalesced_column = "coalesce((#{Model.quoted_table_name}.\"name\")::text, '')" 91 | expect(feature.conditions.to_sql).to eq("('#{query}' % (#{coalesced_column}))") 92 | end 93 | end 94 | 95 | context "with multiple columns" do 96 | let(:options) { {only: %i[name content]} } 97 | 98 | it "concatenates when multiples columns are selected" do 99 | expect(feature.conditions.to_sql).to eq("('#{query}' % (#{coalesced_columns}))") 100 | end 101 | end 102 | end 103 | end 104 | 105 | describe "#rank" do 106 | it "returns an expression using the similarity() function" do 107 | expect(feature.rank.to_sql).to eq("(similarity('#{query}', (#{coalesced_columns})))") 108 | end 109 | end 110 | end 111 | # standard:enable RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups 112 | -------------------------------------------------------------------------------- /spec/lib/pg_search/features/tsearch_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "active_support/core_ext/kernel/reporting" 5 | 6 | describe PgSearch::Features::TSearch do 7 | describe "#rank" do 8 | with_model :Model do 9 | table do |t| 10 | t.string :name 11 | t.text :content 12 | end 13 | end 14 | 15 | it "returns an expression using the ts_rank() function" do 16 | query = "query" 17 | columns = [ 18 | PgSearch::Configuration::Column.new(:name, nil, Model), 19 | PgSearch::Configuration::Column.new(:content, nil, Model) 20 | ] 21 | options = {} 22 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 23 | normalizer = PgSearch::Normalizer.new(config) 24 | 25 | feature = described_class.new(query, options, columns, Model, normalizer) 26 | expect(feature.rank.to_sql).to eq( 27 | %{(ts_rank((to_tsvector('simple', coalesce((#{Model.quoted_table_name}."name")::text, '')) || to_tsvector('simple', coalesce((#{Model.quoted_table_name}."content")::text, ''))), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 0))} 28 | ) 29 | end 30 | 31 | context "with a tsvector column and a custom normalization" do 32 | it "works?" do 33 | query = "query" 34 | columns = [ 35 | PgSearch::Configuration::Column.new(:name, nil, Model), 36 | PgSearch::Configuration::Column.new(:content, nil, Model) 37 | ] 38 | options = {tsvector_column: :my_tsvector, normalization: 2} 39 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 40 | normalizer = PgSearch::Normalizer.new(config) 41 | 42 | feature = described_class.new(query, options, columns, Model, normalizer) 43 | expect(feature.rank.to_sql).to eq( 44 | %{(ts_rank((#{Model.quoted_table_name}."my_tsvector"), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 2))} 45 | ) 46 | end 47 | end 48 | end 49 | 50 | describe "#conditions" do 51 | with_model :Model do 52 | table do |t| 53 | t.string :name 54 | t.text :content 55 | end 56 | end 57 | 58 | it "returns an expression using the @@ infix operator" do 59 | query = "query" 60 | columns = [ 61 | PgSearch::Configuration::Column.new(:name, nil, Model), 62 | PgSearch::Configuration::Column.new(:content, nil, Model) 63 | ] 64 | options = {} 65 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 66 | normalizer = PgSearch::Normalizer.new(config) 67 | 68 | feature = described_class.new(query, options, columns, Model, normalizer) 69 | expect(feature.conditions.to_sql).to eq( 70 | %{((to_tsvector('simple', coalesce((#{Model.quoted_table_name}."name")::text, '')) || to_tsvector('simple', coalesce((#{Model.quoted_table_name}."content")::text, ''))) @@ (to_tsquery('simple', ''' ' || 'query' || ' ''')))} 71 | ) 72 | end 73 | 74 | context "when options[:negation] is true" do 75 | it "returns a negated expression when a query is prepended with !" do 76 | query = "!query" 77 | columns = [ 78 | PgSearch::Configuration::Column.new(:name, nil, Model), 79 | PgSearch::Configuration::Column.new(:content, nil, Model) 80 | ] 81 | options = {negation: true} 82 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 83 | normalizer = PgSearch::Normalizer.new(config) 84 | 85 | feature = described_class.new(query, options, columns, Model, normalizer) 86 | expect(feature.conditions.to_sql).to eq( 87 | %{((to_tsvector('simple', coalesce((#{Model.quoted_table_name}."name")::text, '')) || to_tsvector('simple', coalesce((#{Model.quoted_table_name}."content")::text, ''))) @@ (to_tsquery('simple', '!' || ''' ' || 'query' || ' ''')))} 88 | ) 89 | end 90 | end 91 | 92 | context "when options[:negation] is false" do 93 | it "does not return a negated expression when a query is prepended with !" do 94 | query = "!query" 95 | columns = [ 96 | PgSearch::Configuration::Column.new(:name, nil, Model), 97 | PgSearch::Configuration::Column.new(:content, nil, Model) 98 | ] 99 | options = {negation: false} 100 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 101 | normalizer = PgSearch::Normalizer.new(config) 102 | 103 | feature = described_class.new(query, options, columns, Model, normalizer) 104 | expect(feature.conditions.to_sql).to eq( 105 | %{((to_tsvector('simple', coalesce((#{Model.quoted_table_name}."name")::text, '')) || to_tsvector('simple', coalesce((#{Model.quoted_table_name}."content")::text, ''))) @@ (to_tsquery('simple', ''' ' || '!query' || ' ''')))} 106 | ) 107 | end 108 | end 109 | 110 | context "when options[:tsvector_column] is a string" do 111 | it "uses the tsvector column" do 112 | query = "query" 113 | columns = [ 114 | PgSearch::Configuration::Column.new(:name, nil, Model), 115 | PgSearch::Configuration::Column.new(:content, nil, Model) 116 | ] 117 | options = {tsvector_column: "my_tsvector"} 118 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 119 | normalizer = PgSearch::Normalizer.new(config) 120 | 121 | feature = described_class.new(query, options, columns, Model, normalizer) 122 | expect(feature.conditions.to_sql).to eq( 123 | %{((#{Model.quoted_table_name}."my_tsvector") @@ (to_tsquery('simple', ''' ' || 'query' || ' ''')))} 124 | ) 125 | end 126 | end 127 | 128 | context "when options[:tsvector_column] is an array of strings" do 129 | it "uses the tsvector column" do 130 | query = "query" 131 | columns = [ 132 | PgSearch::Configuration::Column.new(:name, nil, Model), 133 | PgSearch::Configuration::Column.new(:content, nil, Model) 134 | ] 135 | options = {tsvector_column: ["tsvector1", "tsvector2"]} 136 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 137 | normalizer = PgSearch::Normalizer.new(config) 138 | 139 | feature = described_class.new(query, options, columns, Model, normalizer) 140 | expect(feature.conditions.to_sql).to eq( 141 | %{((#{Model.quoted_table_name}."tsvector1" || #{Model.quoted_table_name}."tsvector2") @@ (to_tsquery('simple', ''' ' || 'query' || ' ''')))} 142 | ) 143 | end 144 | end 145 | end 146 | 147 | describe "#highlight" do 148 | with_model :Model do 149 | table do |t| 150 | t.string :name 151 | t.text :content 152 | end 153 | end 154 | 155 | it "generates SQL to call ts_headline" do 156 | query = "query" 157 | columns = [ 158 | PgSearch::Configuration::Column.new(:name, nil, Model) 159 | ] 160 | options = {} 161 | 162 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 163 | normalizer = PgSearch::Normalizer.new(config) 164 | 165 | feature = described_class.new(query, options, columns, Model, normalizer) 166 | expect(feature.highlight.to_sql).to eq( 167 | "(ts_headline('simple', (coalesce((#{Model.quoted_table_name}.\"name\")::text, '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), ''))" 168 | ) 169 | end 170 | 171 | context "when options[:dictionary] is passed" do 172 | # standard:disable RSpec/ExampleLength 173 | it "uses the provided dictionary" do 174 | query = "query" 175 | columns = [ 176 | PgSearch::Configuration::Column.new(:name, nil, Model), 177 | PgSearch::Configuration::Column.new(:content, nil, Model) 178 | ] 179 | options = { 180 | dictionary: "spanish", 181 | highlight: { 182 | StartSel: "", 183 | StopSel: "" 184 | } 185 | } 186 | 187 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 188 | normalizer = PgSearch::Normalizer.new(config) 189 | 190 | feature = described_class.new(query, options, columns, Model, normalizer) 191 | 192 | expected_sql = %{(ts_headline('spanish', (coalesce((#{Model.quoted_table_name}."name")::text, '') || ' ' || coalesce((#{Model.quoted_table_name}."content")::text, '')), (to_tsquery('spanish', ''' ' || 'query' || ' ''')), 'StartSel = "", StopSel = ""'))} 193 | 194 | expect(feature.highlight.to_sql).to eq(expected_sql) 195 | end 196 | # standard:enable RSpec/ExampleLength 197 | end 198 | 199 | context "when options[:highlight] has options set" do 200 | # standard:disable RSpec/ExampleLength 201 | it "passes the options to ts_headline" do 202 | query = "query" 203 | columns = [ 204 | PgSearch::Configuration::Column.new(:name, nil, Model) 205 | ] 206 | options = { 207 | highlight: { 208 | StartSel: '', 209 | StopSel: "", 210 | MaxWords: 123, 211 | MinWords: 456, 212 | ShortWord: 4, 213 | HighlightAll: true, 214 | MaxFragments: 3, 215 | FragmentDelimiter: "…" 216 | } 217 | } 218 | 219 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 220 | normalizer = PgSearch::Normalizer.new(config) 221 | 222 | feature = described_class.new(query, options, columns, Model, normalizer) 223 | 224 | expected_sql = %{(ts_headline('simple', (coalesce((#{Model.quoted_table_name}."name")::text, '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 'StartSel = "", StopSel = "", MaxFragments = 3, MaxWords = 123, MinWords = 456, ShortWord = 4, FragmentDelimiter = "…", HighlightAll = TRUE'))} 225 | 226 | expect(feature.highlight.to_sql).to eq(expected_sql) 227 | end 228 | # standard:enable RSpec/ExampleLength 229 | 230 | # standard:disable RSpec/ExampleLength 231 | it "passes deprecated options to ts_headline" do 232 | query = "query" 233 | columns = [ 234 | PgSearch::Configuration::Column.new(:name, nil, Model) 235 | ] 236 | options = { 237 | highlight: { 238 | start_sel: '', 239 | stop_sel: "", 240 | max_words: 123, 241 | min_words: 456, 242 | short_word: 4, 243 | highlight_all: false, 244 | max_fragments: 3, 245 | fragment_delimiter: "…" 246 | } 247 | } 248 | 249 | config = instance_double(PgSearch::Configuration, :config, ignore: []) 250 | normalizer = PgSearch::Normalizer.new(config) 251 | 252 | feature = described_class.new(query, options, columns, Model, normalizer) 253 | 254 | highlight_sql = silence_warnings { feature.highlight.to_sql } 255 | expected_sql = %{(ts_headline('simple', (coalesce((#{Model.quoted_table_name}."name")::text, '')), (to_tsquery('simple', ''' ' || 'query' || ' ''')), 'StartSel = "", StopSel = "", MaxFragments = 3, MaxWords = 123, MinWords = 456, ShortWord = 4, FragmentDelimiter = "…", HighlightAll = FALSE'))} 256 | 257 | expect(highlight_sql).to eq(expected_sql) 258 | end 259 | # standard:enable RSpec/ExampleLength 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /spec/lib/pg_search/multisearch/rebuilder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | # standard:disable RSpec/NestedGroups 6 | describe PgSearch::Multisearch::Rebuilder do 7 | with_table "pg_search_documents", &DOCUMENTS_SCHEMA 8 | 9 | describe "when initialized with a model that is not multisearchable" do 10 | with_model :not_multisearchable 11 | 12 | it "raises an exception" do 13 | expect { 14 | described_class.new(NotMultisearchable) 15 | }.to raise_exception( 16 | PgSearch::Multisearch::ModelNotMultisearchable, 17 | "NotMultisearchable is not multisearchable. See PgSearch::ClassMethods#multisearchable" 18 | ) 19 | end 20 | end 21 | 22 | describe "#rebuild" do 23 | context "when the model defines .rebuild_pg_search_documents" do 24 | context "when multisearchable is not conditional" do 25 | with_model :Model do 26 | model do 27 | include PgSearch::Model 28 | multisearchable 29 | 30 | def rebuild_pg_search_documents 31 | end 32 | end 33 | end 34 | 35 | it "calls .rebuild_pg_search_documents" do 36 | rebuilder = described_class.new(Model) 37 | 38 | without_partial_double_verification do 39 | allow(Model).to receive(:rebuild_pg_search_documents) 40 | rebuilder.rebuild 41 | expect(Model).to have_received(:rebuild_pg_search_documents) 42 | end 43 | end 44 | end 45 | 46 | context "when multisearchable is conditional" do 47 | %i[if unless].each do |conditional_key| 48 | context "via :#{conditional_key}" do 49 | with_model :Model do 50 | table do |t| 51 | t.boolean :active 52 | end 53 | 54 | model do 55 | include PgSearch::Model 56 | multisearchable conditional_key => :active? 57 | 58 | def rebuild_pg_search_documents 59 | end 60 | end 61 | end 62 | 63 | it "calls .rebuild_pg_search_documents" do 64 | rebuilder = described_class.new(Model) 65 | 66 | without_partial_double_verification do 67 | allow(Model).to receive(:rebuild_pg_search_documents) 68 | rebuilder.rebuild 69 | expect(Model).to have_received(:rebuild_pg_search_documents) 70 | end 71 | end 72 | end 73 | end 74 | end 75 | end 76 | 77 | context "when the model does not define .rebuild_pg_search_documents" do 78 | context "when multisearchable is not conditional" do 79 | context "when :against only includes columns" do 80 | with_model :Model do 81 | table do |t| 82 | t.string :name 83 | end 84 | 85 | model do 86 | include PgSearch::Model 87 | multisearchable against: :name 88 | end 89 | end 90 | 91 | it "does not call :rebuild_pg_search_documents" do 92 | rebuilder = described_class.new(Model) 93 | 94 | # stub respond_to? to return false since should_not_receive defines the method 95 | original_respond_to = Model.method(:respond_to?) 96 | allow(Model).to receive(:respond_to?) do |method_name, *args| 97 | if method_name == :rebuild_pg_search_documents 98 | false 99 | else 100 | original_respond_to.call(method_name, *args) 101 | end 102 | end 103 | 104 | without_partial_double_verification do 105 | allow(Model).to receive(:rebuild_pg_search_documents) 106 | rebuilder.rebuild 107 | expect(Model).not_to have_received(:rebuild_pg_search_documents) 108 | end 109 | end 110 | 111 | # standard:disable RSpec/ExampleLength 112 | it "executes the default SQL" do 113 | time = Time.utc(2001, 1, 1, 0, 0, 0) 114 | rebuilder = described_class.new(Model, -> { time }) 115 | 116 | expected_sql = <<~SQL.squish 117 | INSERT INTO "pg_search_documents" (searchable_type, searchable_id, content, created_at, updated_at) 118 | SELECT 'Model' AS searchable_type, 119 | #{Model.quoted_table_name}.#{Model.primary_key} AS searchable_id, 120 | ( 121 | coalesce(#{Model.quoted_table_name}."name"::text, '') 122 | ) AS content, 123 | '2001-01-01 00:00:00' AS created_at, 124 | '2001-01-01 00:00:00' AS updated_at 125 | FROM #{Model.quoted_table_name} 126 | SQL 127 | 128 | executed_sql = [] 129 | 130 | notifier = ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, _start, _finish, _id, payload| 131 | executed_sql << payload[:sql] if payload[:sql].include?(%(INSERT INTO "pg_search_documents")) 132 | end 133 | 134 | rebuilder.rebuild 135 | ActiveSupport::Notifications.unsubscribe(notifier) 136 | 137 | expect(executed_sql.length).to eq(1) 138 | expect(executed_sql.first.strip).to eq(expected_sql.strip) 139 | end 140 | # standard:enable RSpec/ExampleLength 141 | 142 | context "with a model with a camel case column" do 143 | with_model :ModelWithCamelCaseColumn do 144 | table do |t| 145 | t.string :camelName 146 | end 147 | 148 | model do 149 | include PgSearch::Model 150 | multisearchable against: :name 151 | end 152 | end 153 | 154 | it "rebuilds without error" do 155 | time = Time.utc(2001, 1, 1, 0, 0, 0) 156 | rebuilder = described_class.new(Model, -> { time }) 157 | expect { rebuilder.rebuild }.not_to raise_error 158 | end 159 | end 160 | 161 | context "with a model with a non-standard primary key" do 162 | with_model :ModelWithNonStandardPrimaryKey do 163 | table primary_key: :non_standard_primary_key do |t| 164 | t.string :name 165 | end 166 | 167 | model do 168 | include PgSearch::Model 169 | multisearchable against: :name 170 | end 171 | end 172 | 173 | # standard:disable RSpec/ExampleLength 174 | it "generates SQL with the correct primary key" do 175 | time = Time.utc(2001, 1, 1, 0, 0, 0) 176 | rebuilder = described_class.new(ModelWithNonStandardPrimaryKey, -> { time }) 177 | 178 | expected_sql = <<~SQL.squish 179 | INSERT INTO "pg_search_documents" (searchable_type, searchable_id, content, created_at, updated_at) 180 | SELECT 'ModelWithNonStandardPrimaryKey' AS searchable_type, 181 | #{ModelWithNonStandardPrimaryKey.quoted_table_name}.non_standard_primary_key AS searchable_id, 182 | ( 183 | coalesce(#{ModelWithNonStandardPrimaryKey.quoted_table_name}."name"::text, '') 184 | ) AS content, 185 | '2001-01-01 00:00:00' AS created_at, 186 | '2001-01-01 00:00:00' AS updated_at 187 | FROM #{ModelWithNonStandardPrimaryKey.quoted_table_name} 188 | SQL 189 | 190 | executed_sql = [] 191 | 192 | notifier = ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, _start, _finish, _id, payload| 193 | executed_sql << payload[:sql] if payload[:sql].include?(%(INSERT INTO "pg_search_documents")) 194 | end 195 | 196 | rebuilder.rebuild 197 | ActiveSupport::Notifications.unsubscribe(notifier) 198 | 199 | expect(executed_sql.length).to eq(1) 200 | expect(executed_sql.first.strip).to eq(expected_sql.strip) 201 | end 202 | # standard:enable RSpec/ExampleLength 203 | end 204 | end 205 | 206 | context "when :against includes non-column dynamic methods" do 207 | with_model :Model do 208 | model do 209 | include PgSearch::Model 210 | multisearchable against: [:foo] 211 | 212 | def foo 213 | "bar" 214 | end 215 | end 216 | end 217 | 218 | # standard:disable RSpec/ExampleLength 219 | it "calls update_pg_search_document on each record" do 220 | record = Model.create! 221 | 222 | rebuilder = described_class.new(Model) 223 | 224 | # stub respond_to? to return false since should_not_receive defines the method 225 | original_respond_to = Model.method(:respond_to?) 226 | allow(Model).to receive(:respond_to?) do |method_name, *args| 227 | if method_name == :rebuild_pg_search_documents 228 | false 229 | else 230 | original_respond_to.call(method_name, *args) 231 | end 232 | end 233 | 234 | without_partial_double_verification do 235 | allow(Model).to receive(:rebuild_pg_search_documents) 236 | 237 | rebuilder.rebuild 238 | 239 | expect(Model).not_to have_received(:rebuild_pg_search_documents) 240 | end 241 | 242 | expect(record.pg_search_document).to be_present 243 | end 244 | # standard:enable RSpec/ExampleLength 245 | end 246 | 247 | context "when only additional_attributes is set" do 248 | with_model :Model do 249 | table do |t| 250 | t.string :name 251 | end 252 | 253 | model do 254 | include PgSearch::Model 255 | multisearchable against: :name, 256 | additional_attributes: ->(obj) { {additional_attribute_column: "#{obj.class}::#{obj.id}"} } 257 | end 258 | end 259 | 260 | it "calls update_pg_search_document on each record" do 261 | record_1 = Model.create!(name: "record_1", id: 1) 262 | record_2 = Model.create!(name: "record_2", id: 2) 263 | 264 | PgSearch::Document.delete_all 265 | 266 | rebuilder = described_class.new(Model) 267 | rebuilder.rebuild 268 | 269 | expect(record_1.reload.pg_search_document.additional_attribute_column).to eq("Model::1") 270 | expect(record_2.reload.pg_search_document.additional_attribute_column).to eq("Model::2") 271 | end 272 | end 273 | end 274 | 275 | context "when multisearchable is conditional" do 276 | context "via :if" do 277 | with_model :Model do 278 | table do |t| 279 | t.boolean :active 280 | end 281 | 282 | model do 283 | include PgSearch::Model 284 | multisearchable if: :active? 285 | end 286 | end 287 | 288 | # standard:disable RSpec/ExampleLength 289 | it "calls update_pg_search_document on each record" do 290 | record_1 = Model.create!(active: true) 291 | record_2 = Model.create!(active: false) 292 | 293 | rebuilder = described_class.new(Model) 294 | 295 | # stub respond_to? to return false since should_not_receive defines the method 296 | original_respond_to = Model.method(:respond_to?) 297 | allow(Model).to receive(:respond_to?) do |method_name, *args| 298 | if method_name == :rebuild_pg_search_documents 299 | false 300 | else 301 | original_respond_to.call(method_name, *args) 302 | end 303 | end 304 | 305 | without_partial_double_verification do 306 | allow(Model).to receive(:rebuild_pg_search_documents) 307 | rebuilder.rebuild 308 | expect(Model).not_to have_received(:rebuild_pg_search_documents) 309 | end 310 | 311 | expect(record_1.pg_search_document).to be_present 312 | expect(record_2.pg_search_document).not_to be_present 313 | end 314 | # standard:enable RSpec/ExampleLength 315 | end 316 | 317 | context "via :unless" do 318 | with_model :Model do 319 | table do |t| 320 | t.boolean :inactive 321 | end 322 | 323 | model do 324 | include PgSearch::Model 325 | multisearchable unless: :inactive? 326 | end 327 | end 328 | 329 | # standard:disable RSpec/ExampleLength 330 | it "calls update_pg_search_document on each record" do 331 | record_1 = Model.create!(inactive: true) 332 | record_2 = Model.create!(inactive: false) 333 | 334 | rebuilder = described_class.new(Model) 335 | 336 | # stub respond_to? to return false since should_not_receive defines the method 337 | original_respond_to = Model.method(:respond_to?) 338 | allow(Model).to receive(:respond_to?) do |method_name, *args| 339 | if method_name == :rebuild_pg_search_documents 340 | false 341 | else 342 | original_respond_to.call(method_name, *args) 343 | end 344 | end 345 | 346 | without_partial_double_verification do 347 | allow(Model).to receive(:rebuild_pg_search_documents) 348 | rebuilder.rebuild 349 | expect(Model).not_to have_received(:rebuild_pg_search_documents) 350 | end 351 | 352 | expect(record_1.pg_search_document).not_to be_present 353 | expect(record_2.pg_search_document).to be_present 354 | end 355 | # standard:enable RSpec/ExampleLength 356 | end 357 | end 358 | end 359 | end 360 | end 361 | # standard:enable RSpec/NestedGroups 362 | -------------------------------------------------------------------------------- /spec/lib/pg_search/multisearch_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "active_support/core_ext/kernel/reporting" 5 | 6 | # standard:disable RSpec/NestedGroups 7 | describe PgSearch::Multisearch do 8 | with_table "pg_search_documents", &DOCUMENTS_SCHEMA 9 | 10 | with_model :MultisearchableModel do 11 | table do |t| 12 | t.string :title 13 | t.text :content 14 | t.timestamps null: false 15 | end 16 | model do 17 | include PgSearch::Model 18 | end 19 | end 20 | 21 | let(:model) { MultisearchableModel } 22 | let(:connection) { model.connection } 23 | 24 | describe ".rebuild" do 25 | before do 26 | model.multisearchable against: :title 27 | end 28 | 29 | it "operates inside a transaction" do 30 | allow(model).to receive(:transaction) 31 | 32 | described_class.rebuild(model) 33 | expect(model).to have_received(:transaction).once 34 | end 35 | 36 | context "when transactional is false" do 37 | it "does not operate inside a transaction" do 38 | allow(model).to receive(:transaction) 39 | 40 | described_class.rebuild(model, transactional: false) 41 | expect(model).not_to have_received(:transaction) 42 | end 43 | end 44 | 45 | describe "cleaning up search documents for this model" do 46 | before do 47 | connection.execute <<~SQL.squish 48 | INSERT INTO pg_search_documents 49 | (searchable_type, searchable_id, content, created_at, updated_at) 50 | VALUES 51 | ('#{model.name}', 123, 'foo', now(), now()); 52 | INSERT INTO pg_search_documents 53 | (searchable_type, searchable_id, content, created_at, updated_at) 54 | VALUES 55 | ('Bar', 123, 'foo', now(), now()); 56 | SQL 57 | expect(PgSearch::Document.count).to eq(2) 58 | end 59 | 60 | context "when clean_up is not passed" do 61 | it "deletes the document for the model" do 62 | described_class.rebuild(model) 63 | expect(PgSearch::Document.count).to eq(1) 64 | expect(PgSearch::Document.first.searchable_type).to eq("Bar") 65 | end 66 | end 67 | 68 | context "when clean_up is true" do 69 | it "deletes the document for the model" do 70 | described_class.rebuild(model, clean_up: true) 71 | expect(PgSearch::Document.count).to eq(1) 72 | expect(PgSearch::Document.first.searchable_type).to eq("Bar") 73 | end 74 | end 75 | 76 | context "when clean_up is false" do 77 | it "does not delete the document for the model" do 78 | described_class.rebuild(model, clean_up: false) 79 | expect(PgSearch::Document.count).to eq(2) 80 | end 81 | end 82 | 83 | context "when deprecated_clean_up is true" do 84 | it "deletes the document for the model" do 85 | silence_warnings { described_class.rebuild(model, true) } 86 | expect(PgSearch::Document.count).to eq(1) 87 | expect(PgSearch::Document.first.searchable_type).to eq("Bar") 88 | end 89 | end 90 | 91 | context "when deprecated_clean_up is false" do 92 | it "does not delete the document for the model" do 93 | silence_warnings { described_class.rebuild(model, false) } 94 | expect(PgSearch::Document.count).to eq(2) 95 | end 96 | end 97 | 98 | context "when the model implements .rebuild_pg_search_documents" do 99 | before do 100 | def model.rebuild_pg_search_documents 101 | connection.execute <<~SQL.squish 102 | INSERT INTO pg_search_documents 103 | (searchable_type, searchable_id, content, created_at, updated_at) 104 | VALUES 105 | ('Baz', 789, 'baz', now(), now()); 106 | SQL 107 | end 108 | end 109 | 110 | it "calls .rebuild_pg_search_documents and skips the default behavior" do 111 | without_partial_double_verification do 112 | allow(model).to receive(:rebuild_sql) 113 | described_class.rebuild(model) 114 | 115 | record = PgSearch::Document.find_by(searchable_type: "Baz", searchable_id: 789) 116 | expect(model).not_to have_received(:rebuild_sql) 117 | expect(record.content).to eq("baz") 118 | end 119 | end 120 | end 121 | end 122 | 123 | describe "inserting the new documents" do 124 | let!(:new_models) { [] } 125 | 126 | before do 127 | new_models << model.create!(title: "Foo", content: "Bar") 128 | new_models << model.create!(title: "Baz", content: "Bar") 129 | end 130 | 131 | it "creates new documents for the two models" do 132 | described_class.rebuild(model) 133 | expect(PgSearch::Document.last(2).map(&:searchable).map(&:title)).to match_array(new_models.map(&:title)) 134 | end 135 | end 136 | 137 | describe "the generated SQL" do 138 | let(:now) { Time.now } # standard:disable Rails/TimeZone 139 | 140 | before { allow(Time).to receive(:now).and_return(now) } 141 | 142 | context "with one attribute" do 143 | before do 144 | model.multisearchable against: [:title] 145 | end 146 | 147 | it "generates the proper SQL code" do 148 | expected_sql = <<~SQL.squish 149 | INSERT INTO #{PgSearch::Document.quoted_table_name} (searchable_type, searchable_id, content, created_at, updated_at) 150 | SELECT #{connection.quote(model.name)} AS searchable_type, 151 | #{model.quoted_table_name}.id AS searchable_id, 152 | ( 153 | coalesce(#{model.quoted_table_name}."title"::text, '') 154 | ) AS content, 155 | #{connection.quote(connection.quoted_date(now))} AS created_at, 156 | #{connection.quote(connection.quoted_date(now))} AS updated_at 157 | FROM #{model.quoted_table_name} 158 | SQL 159 | 160 | statements = [] 161 | allow(connection).to receive(:execute) { |sql| statements << sql.strip } 162 | 163 | described_class.rebuild(model) 164 | 165 | expect(statements).to include(expected_sql.strip) 166 | end 167 | end 168 | 169 | context "with multiple attributes" do 170 | before do 171 | model.multisearchable against: %i[title content] 172 | end 173 | 174 | it "generates the proper SQL code" do 175 | expected_sql = <<~SQL.squish 176 | INSERT INTO #{PgSearch::Document.quoted_table_name} (searchable_type, searchable_id, content, created_at, updated_at) 177 | SELECT #{connection.quote(model.name)} AS searchable_type, 178 | #{model.quoted_table_name}.id AS searchable_id, 179 | ( 180 | coalesce(#{model.quoted_table_name}."title"::text, '') || ' ' || coalesce(#{model.quoted_table_name}."content"::text, '') 181 | ) AS content, 182 | #{connection.quote(connection.quoted_date(now))} AS created_at, 183 | #{connection.quote(connection.quoted_date(now))} AS updated_at 184 | FROM #{model.quoted_table_name} 185 | SQL 186 | 187 | statements = [] 188 | allow(connection).to receive(:execute) { |sql| statements << sql.strip } 189 | 190 | described_class.rebuild(model) 191 | 192 | expect(statements).to include(expected_sql.strip) 193 | end 194 | end 195 | end 196 | end 197 | end 198 | # standard:enable RSpec/NestedGroups 199 | -------------------------------------------------------------------------------- /spec/lib/pg_search/multisearchable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | # standard:disable RSpec/NestedGroups 6 | describe PgSearch::Multisearchable do 7 | with_table "pg_search_documents", &DOCUMENTS_SCHEMA 8 | 9 | describe "a model that is multisearchable" do 10 | with_model :ModelThatIsMultisearchable do 11 | model do 12 | include PgSearch::Model 13 | multisearchable 14 | end 15 | end 16 | 17 | with_model :MultisearchableParent do 18 | table do |t| 19 | t.string :secret 20 | end 21 | 22 | model do 23 | include PgSearch::Model 24 | multisearchable 25 | 26 | has_many :multisearchable_children, dependent: :destroy 27 | end 28 | end 29 | 30 | with_model :MultisearchableChild do 31 | table do |t| 32 | t.belongs_to :multisearchable_parent, index: false 33 | end 34 | 35 | model do 36 | belongs_to :multisearchable_parent 37 | 38 | after_destroy do 39 | multisearchable_parent.update_attribute(:secret, rand(1000).to_s) 40 | end 41 | end 42 | end 43 | 44 | describe "callbacks" do 45 | describe "after_create" do 46 | let(:record) { ModelThatIsMultisearchable.new } 47 | 48 | describe "saving the record" do 49 | it "creates a PgSearch::Document record" do 50 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 51 | end 52 | 53 | context "with multisearch disabled" do 54 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 55 | 56 | it "does not create a PgSearch::Document record" do 57 | expect { record.save! }.not_to change(PgSearch::Document, :count) 58 | end 59 | end 60 | end 61 | 62 | describe "the document" do 63 | it "is associated to the record" do 64 | record.save! 65 | newest_pg_search_document = PgSearch::Document.last 66 | expect(record.pg_search_document).to eq(newest_pg_search_document) 67 | expect(newest_pg_search_document.searchable).to eq(record) 68 | end 69 | end 70 | end 71 | 72 | describe "after_update" do 73 | let!(:record) { ModelThatIsMultisearchable.create! } 74 | 75 | context "when the document is present" do 76 | before { expect(record.pg_search_document).to be_present } 77 | 78 | describe "saving the record" do 79 | it "calls save on the pg_search_document" do 80 | allow(record.pg_search_document).to receive(:save) 81 | record.save! 82 | expect(record.pg_search_document).to have_received(:save) 83 | end 84 | 85 | it "does not create a PgSearch::Document record" do 86 | expect { record.save! }.not_to change(PgSearch::Document, :count) 87 | end 88 | 89 | context "with multisearch disabled" do 90 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 91 | 92 | it "does not create a PgSearch::Document record" do 93 | allow(record.pg_search_document).to receive(:save) 94 | expect { record.save! }.not_to change(PgSearch::Document, :count) 95 | expect(record.pg_search_document).not_to have_received(:save) 96 | end 97 | end 98 | end 99 | end 100 | 101 | context "when the document is missing" do 102 | before { record.pg_search_document = nil } 103 | 104 | describe "saving the record" do 105 | it "creates a PgSearch::Document record" do 106 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 107 | end 108 | 109 | context "with multisearch disabled" do 110 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 111 | 112 | it "does not create a PgSearch::Document record" do 113 | expect { record.save! }.not_to change(PgSearch::Document, :count) 114 | end 115 | end 116 | end 117 | end 118 | end 119 | 120 | describe "after_destroy" do 121 | it "removes its document" do 122 | record = ModelThatIsMultisearchable.create! 123 | document = record.pg_search_document 124 | expect { record.destroy }.to change(PgSearch::Document, :count).by(-1) 125 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 126 | end 127 | 128 | it "removes its document in case of complex associations" do 129 | parent = MultisearchableParent.create! 130 | 131 | MultisearchableChild.create!(multisearchable_parent: parent) 132 | MultisearchableChild.create!(multisearchable_parent: parent) 133 | 134 | document = parent.pg_search_document 135 | 136 | expect { parent.destroy }.to change(PgSearch::Document, :count).by(-1) 137 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 138 | end 139 | end 140 | end 141 | 142 | describe "populating the searchable text" do 143 | subject { record } 144 | 145 | let(:record) { ModelThatIsMultisearchable.new } 146 | 147 | before do 148 | ModelThatIsMultisearchable.multisearchable(multisearchable_options) 149 | end 150 | 151 | context "when searching against a single column" do 152 | let(:multisearchable_options) { {against: :some_content} } 153 | let(:text) { "foo bar" } 154 | 155 | before do 156 | without_partial_double_verification do 157 | allow(record).to receive(:some_content) { text } 158 | end 159 | record.save! 160 | end 161 | 162 | describe "#content" do 163 | subject { super().pg_search_document.content } 164 | 165 | it { is_expected.to eq(text) } 166 | end 167 | end 168 | 169 | context "when searching against multiple columns" do 170 | let(:multisearchable_options) { {against: %i[attr_1 attr_2]} } 171 | 172 | before do 173 | without_partial_double_verification do 174 | allow(record).to receive(:attr_1).and_return("1") 175 | allow(record).to receive(:attr_2).and_return("2") 176 | end 177 | record.save! 178 | end 179 | 180 | describe "#content" do 181 | subject { super().pg_search_document.content } 182 | 183 | it { is_expected.to eq("1 2") } 184 | end 185 | end 186 | end 187 | 188 | describe "populating the searchable attributes" do 189 | subject { record } 190 | 191 | let(:record) { ModelThatIsMultisearchable.new } 192 | 193 | before do 194 | ModelThatIsMultisearchable.multisearchable(multisearchable_options) 195 | end 196 | 197 | context "when searching against a single column" do 198 | let(:multisearchable_options) { {against: :some_content} } 199 | let(:text) { "foo bar" } 200 | 201 | before do 202 | without_partial_double_verification do 203 | allow(record).to receive(:some_content) { text } 204 | end 205 | record.save! 206 | end 207 | 208 | describe "#content" do 209 | subject { super().pg_search_document.content } 210 | 211 | it { is_expected.to eq(text) } 212 | end 213 | end 214 | 215 | context "when searching against multiple columns" do 216 | let(:multisearchable_options) { {against: %i[attr_1 attr_2]} } 217 | 218 | before do 219 | without_partial_double_verification do 220 | allow(record).to receive(:attr_1).and_return("1") 221 | allow(record).to receive(:attr_2).and_return("2") 222 | end 223 | record.save! 224 | end 225 | 226 | describe "#content" do 227 | subject { super().pg_search_document.content } 228 | 229 | it { is_expected.to eq("1 2") } 230 | end 231 | end 232 | 233 | context "with additional_attributes" do 234 | let(:multisearchable_options) do 235 | { 236 | additional_attributes: lambda do |record| 237 | {foo: record.bar} 238 | end 239 | } 240 | end 241 | let(:text) { "foo bar" } 242 | 243 | it "sets the attributes" do 244 | without_partial_double_verification do 245 | allow(record).to receive(:bar).and_return(text) 246 | allow(record).to receive(:create_pg_search_document) 247 | record.save! 248 | expect(record) 249 | .to have_received(:create_pg_search_document) 250 | .with(content: "", foo: text) 251 | end 252 | end 253 | end 254 | 255 | context "when selectively updating" do 256 | let(:multisearchable_options) do 257 | { 258 | update_if: lambda do |record| 259 | record.bar? 260 | end 261 | } 262 | end 263 | let(:text) { "foo bar" } 264 | 265 | it "creates the document" do 266 | without_partial_double_verification do 267 | allow(record).to receive(:bar?).and_return(false) 268 | allow(record).to receive(:create_pg_search_document) 269 | record.save! 270 | expect(record) 271 | .to have_received(:create_pg_search_document) 272 | .with(content: "") 273 | end 274 | end 275 | 276 | context "when the document is created" do 277 | before { record.save } 278 | 279 | context "when update_if returns false" do 280 | before do 281 | without_partial_double_verification do 282 | allow(record).to receive(:bar?).and_return(false) 283 | end 284 | end 285 | 286 | it "does not update the document" do 287 | without_partial_double_verification do 288 | allow(record.pg_search_document).to receive(:update) 289 | record.save! 290 | expect(record.pg_search_document).not_to have_received(:update) 291 | end 292 | end 293 | end 294 | 295 | context "when update_if returns true" do 296 | before do 297 | without_partial_double_verification do 298 | allow(record).to receive(:bar?).and_return(true) 299 | end 300 | end 301 | 302 | it "updates the document" do 303 | allow(record.pg_search_document).to receive(:update) 304 | record.save! 305 | expect(record.pg_search_document).to have_received(:update) 306 | end 307 | end 308 | end 309 | end 310 | end 311 | end 312 | 313 | describe "a model which is conditionally multisearchable using a Proc" do 314 | context "via :if" do 315 | with_model :ModelThatIsMultisearchable do 316 | table do |t| 317 | t.boolean :multisearchable 318 | end 319 | 320 | model do 321 | include PgSearch::Model 322 | multisearchable if: ->(record) { record.multisearchable? } 323 | end 324 | end 325 | 326 | describe "callbacks" do 327 | describe "after_create" do 328 | describe "saving the record" do 329 | context "when the condition is true" do 330 | let(:record) { ModelThatIsMultisearchable.new(multisearchable: true) } 331 | 332 | it "creates a PgSearch::Document record" do 333 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 334 | end 335 | 336 | context "with multisearch disabled" do 337 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 338 | 339 | it "does not create a PgSearch::Document record" do 340 | expect { record.save! }.not_to change(PgSearch::Document, :count) 341 | end 342 | end 343 | end 344 | 345 | context "when the condition is false" do 346 | let(:record) { ModelThatIsMultisearchable.new(multisearchable: false) } 347 | 348 | it "does not create a PgSearch::Document record" do 349 | expect { record.save! }.not_to change(PgSearch::Document, :count) 350 | end 351 | end 352 | end 353 | end 354 | 355 | describe "after_update" do 356 | let(:record) { ModelThatIsMultisearchable.create!(multisearchable: true) } 357 | 358 | context "when the document is present" do 359 | before { expect(record.pg_search_document).to be_present } 360 | 361 | describe "saving the record" do 362 | context "when the condition is true" do 363 | it "calls save on the pg_search_document" do 364 | allow(record.pg_search_document).to receive(:save) 365 | record.save! 366 | expect(record.pg_search_document).to have_received(:save) 367 | end 368 | 369 | it "does not create a PgSearch::Document record" do 370 | expect { record.save! }.not_to change(PgSearch::Document, :count) 371 | end 372 | end 373 | 374 | context "when the condition is false" do 375 | before { record.multisearchable = false } 376 | 377 | it "calls destroy on the pg_search_document" do 378 | allow(record.pg_search_document).to receive(:destroy) 379 | record.save! 380 | expect(record.pg_search_document).to have_received(:destroy) 381 | end 382 | 383 | it "removes its document" do 384 | document = record.pg_search_document 385 | expect { record.save! }.to change(PgSearch::Document, :count).by(-1) 386 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 387 | end 388 | end 389 | 390 | context "with multisearch disabled" do 391 | before do 392 | allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) 393 | end 394 | 395 | it "does not create a PgSearch::Document record" do 396 | allow(record.pg_search_document).to receive(:save) 397 | expect { record.save! }.not_to change(PgSearch::Document, :count) 398 | expect(record.pg_search_document).not_to have_received(:save) 399 | end 400 | end 401 | end 402 | end 403 | 404 | context "when the document is missing" do 405 | before { record.pg_search_document = nil } 406 | 407 | describe "saving the record" do 408 | context "when the condition is true" do 409 | it "creates a PgSearch::Document record" do 410 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 411 | end 412 | 413 | context "with multisearch disabled" do 414 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 415 | 416 | it "does not create a PgSearch::Document record" do 417 | expect { record.save! }.not_to change(PgSearch::Document, :count) 418 | end 419 | end 420 | end 421 | 422 | context "when the condition is false" do 423 | before { record.multisearchable = false } 424 | 425 | it "does not create a PgSearch::Document record" do 426 | expect { record.save! }.not_to change(PgSearch::Document, :count) 427 | end 428 | end 429 | end 430 | end 431 | end 432 | 433 | describe "after_destroy" do 434 | let(:record) { ModelThatIsMultisearchable.create!(multisearchable: true) } 435 | 436 | it "removes its document" do 437 | document = record.pg_search_document 438 | expect { record.destroy }.to change(PgSearch::Document, :count).by(-1) 439 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 440 | end 441 | end 442 | end 443 | end 444 | 445 | context "using :unless" do 446 | with_model :ModelThatIsMultisearchable do 447 | table do |t| 448 | t.boolean :not_multisearchable 449 | end 450 | 451 | model do 452 | include PgSearch::Model 453 | multisearchable unless: ->(record) { record.not_multisearchable? } 454 | end 455 | end 456 | 457 | describe "callbacks" do 458 | describe "after_create" do 459 | describe "saving the record" do 460 | context "when the condition is false" do 461 | let(:record) { ModelThatIsMultisearchable.new(not_multisearchable: false) } 462 | 463 | it "creates a PgSearch::Document record" do 464 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 465 | end 466 | 467 | context "with multisearch disabled" do 468 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 469 | 470 | it "does not create a PgSearch::Document record" do 471 | expect { record.save! }.not_to change(PgSearch::Document, :count) 472 | end 473 | end 474 | end 475 | 476 | context "when the condition is true" do 477 | let(:record) { ModelThatIsMultisearchable.new(not_multisearchable: true) } 478 | 479 | it "does not create a PgSearch::Document record" do 480 | expect { record.save! }.not_to change(PgSearch::Document, :count) 481 | end 482 | end 483 | end 484 | end 485 | 486 | describe "after_update" do 487 | let!(:record) { ModelThatIsMultisearchable.create!(not_multisearchable: false) } 488 | 489 | context "when the document is present" do 490 | before { expect(record.pg_search_document).to be_present } 491 | 492 | describe "saving the record" do 493 | context "when the condition is false" do 494 | it "calls save on the pg_search_document" do 495 | allow(record.pg_search_document).to receive(:save) 496 | record.save! 497 | expect(record.pg_search_document).to have_received(:save) 498 | end 499 | 500 | it "does not create a PgSearch::Document record" do 501 | expect { record.save! }.not_to change(PgSearch::Document, :count) 502 | end 503 | 504 | context "with multisearch disabled" do 505 | before do 506 | allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) 507 | allow(record.pg_search_document).to receive(:save) 508 | end 509 | 510 | it "does not call save on the document" do 511 | expect(record.pg_search_document).not_to have_received(:save) 512 | end 513 | 514 | it "does not create a PgSearch::Document record" do 515 | expect { record.save! }.not_to change(PgSearch::Document, :count) 516 | end 517 | end 518 | end 519 | 520 | context "when the condition is true" do 521 | before { record.not_multisearchable = true } 522 | 523 | it "calls destroy on the pg_search_document" do 524 | allow(record.pg_search_document).to receive(:destroy) 525 | record.save! 526 | expect(record.pg_search_document).to have_received(:destroy) 527 | end 528 | 529 | it "removes its document" do 530 | document = record.pg_search_document 531 | expect { record.save! }.to change(PgSearch::Document, :count).by(-1) 532 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 533 | end 534 | end 535 | end 536 | end 537 | 538 | context "when the document is missing" do 539 | before { record.pg_search_document = nil } 540 | 541 | describe "saving the record" do 542 | context "when the condition is false" do 543 | it "creates a PgSearch::Document record" do 544 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 545 | end 546 | end 547 | 548 | context "when the condition is true" do 549 | before { record.not_multisearchable = true } 550 | 551 | it "does not create a PgSearch::Document record" do 552 | expect { record.save! }.not_to change(PgSearch::Document, :count) 553 | end 554 | end 555 | 556 | context "with multisearch disabled" do 557 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 558 | 559 | it "does not create a PgSearch::Document record" do 560 | expect { record.save! }.not_to change(PgSearch::Document, :count) 561 | end 562 | end 563 | end 564 | end 565 | end 566 | 567 | describe "after_destroy" do 568 | it "removes its document" do 569 | record = ModelThatIsMultisearchable.create! 570 | document = record.pg_search_document 571 | expect { record.destroy }.to change(PgSearch::Document, :count).by(-1) 572 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 573 | end 574 | end 575 | end 576 | end 577 | end 578 | 579 | describe "a model which is conditionally multisearchable using a Symbol" do 580 | context "via :if" do 581 | with_model :ModelThatIsMultisearchable do 582 | table do |t| 583 | t.boolean :multisearchable 584 | end 585 | 586 | model do 587 | include PgSearch::Model 588 | multisearchable if: :multisearchable? 589 | end 590 | end 591 | 592 | describe "callbacks" do 593 | describe "after_create" do 594 | describe "saving the record" do 595 | context "when the condition is true" do 596 | let(:record) { ModelThatIsMultisearchable.new(multisearchable: true) } 597 | 598 | it "creates a PgSearch::Document record" do 599 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 600 | end 601 | 602 | context "with multisearch disabled" do 603 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 604 | 605 | it "does not create a PgSearch::Document record" do 606 | expect { record.save! }.not_to change(PgSearch::Document, :count) 607 | end 608 | end 609 | end 610 | 611 | context "when the condition is false" do 612 | let(:record) { ModelThatIsMultisearchable.new(multisearchable: false) } 613 | 614 | it "does not create a PgSearch::Document record" do 615 | expect { record.save! }.not_to change(PgSearch::Document, :count) 616 | end 617 | end 618 | end 619 | end 620 | 621 | describe "after_update" do 622 | let!(:record) { ModelThatIsMultisearchable.create!(multisearchable: true) } 623 | 624 | context "when the document is present" do 625 | before { expect(record.pg_search_document).to be_present } 626 | 627 | describe "saving the record" do 628 | context "when the condition is true" do 629 | it "calls save on the pg_search_document" do 630 | allow(record.pg_search_document).to receive(:save) 631 | record.save! 632 | expect(record.pg_search_document).to have_received(:save) 633 | end 634 | 635 | it "does not create a PgSearch::Document record" do 636 | expect { record.save! }.not_to change(PgSearch::Document, :count) 637 | end 638 | 639 | context "with multisearch disabled" do 640 | before do 641 | allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) 642 | allow(record.pg_search_document).to receive(:save) 643 | end 644 | 645 | it "does not call save on the document" do 646 | expect(record.pg_search_document).not_to have_received(:save) 647 | end 648 | 649 | it "does not create a PgSearch::Document record" do 650 | expect { record.save! }.not_to change(PgSearch::Document, :count) 651 | end 652 | end 653 | end 654 | 655 | context "when the condition is false" do 656 | before { record.multisearchable = false } 657 | 658 | it "calls destroy on the pg_search_document" do 659 | allow(record.pg_search_document).to receive(:destroy) 660 | record.save! 661 | expect(record.pg_search_document).to have_received(:destroy) 662 | end 663 | 664 | it "removes its document" do 665 | document = record.pg_search_document 666 | expect { record.save! }.to change(PgSearch::Document, :count).by(-1) 667 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 668 | end 669 | end 670 | end 671 | end 672 | 673 | context "when the document is missing" do 674 | before { record.pg_search_document = nil } 675 | 676 | describe "saving the record" do 677 | context "with multisearch enabled" do 678 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(true) } 679 | 680 | context "when the condition is true" do 681 | it "creates a PgSearch::Document record" do 682 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 683 | end 684 | end 685 | 686 | context "when the condition is false" do 687 | before { record.multisearchable = false } 688 | 689 | it "does not create a PgSearch::Document record" do 690 | expect { record.save! }.not_to change(PgSearch::Document, :count) 691 | end 692 | end 693 | end 694 | 695 | context "with multisearch disabled" do 696 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 697 | 698 | it "does not create a PgSearch::Document record" do 699 | expect { record.save! }.not_to change(PgSearch::Document, :count) 700 | end 701 | end 702 | end 703 | end 704 | end 705 | 706 | describe "after_destroy" do 707 | let(:record) { ModelThatIsMultisearchable.create!(multisearchable: true) } 708 | 709 | it "removes its document" do 710 | document = record.pg_search_document 711 | expect { record.destroy }.to change(PgSearch::Document, :count).by(-1) 712 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 713 | end 714 | end 715 | end 716 | end 717 | 718 | context "using :unless" do 719 | with_model :ModelThatIsMultisearchable do 720 | table do |t| 721 | t.boolean :not_multisearchable 722 | end 723 | 724 | model do 725 | include PgSearch::Model 726 | multisearchable unless: :not_multisearchable? 727 | end 728 | end 729 | 730 | describe "callbacks" do 731 | describe "after_create" do 732 | describe "saving the record" do 733 | context "when the condition is true" do 734 | let(:record) { ModelThatIsMultisearchable.new(not_multisearchable: true) } 735 | 736 | it "does not create a PgSearch::Document record" do 737 | expect { record.save! }.not_to change(PgSearch::Document, :count) 738 | end 739 | end 740 | 741 | context "when the condition is false" do 742 | let(:record) { ModelThatIsMultisearchable.new(not_multisearchable: false) } 743 | 744 | it "creates a PgSearch::Document record" do 745 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 746 | end 747 | 748 | context "with multisearch disabled" do 749 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 750 | 751 | it "does not create a PgSearch::Document record" do 752 | expect { record.save! }.not_to change(PgSearch::Document, :count) 753 | end 754 | end 755 | end 756 | end 757 | end 758 | 759 | describe "after_update" do 760 | let!(:record) { ModelThatIsMultisearchable.create!(not_multisearchable: false) } 761 | 762 | context "when the document is present" do 763 | before { expect(record.pg_search_document).to be_present } 764 | 765 | describe "saving the record" do 766 | context "when the condition is true" do 767 | before { record.not_multisearchable = true } 768 | 769 | it "calls destroy on the pg_search_document" do 770 | allow(record.pg_search_document).to receive(:destroy) 771 | record.save! 772 | expect(record.pg_search_document).to have_received(:destroy) 773 | end 774 | 775 | it "removes its document" do 776 | document = record.pg_search_document 777 | expect { record.save! }.to change(PgSearch::Document, :count).by(-1) 778 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 779 | end 780 | end 781 | 782 | context "when the condition is false" do 783 | it "calls save on the pg_search_document" do 784 | allow(record.pg_search_document).to receive(:save) 785 | record.save! 786 | expect(record.pg_search_document).to have_received(:save) 787 | end 788 | 789 | it "does not create a PgSearch::Document record" do 790 | expect { record.save! }.not_to change(PgSearch::Document, :count) 791 | end 792 | 793 | context "with multisearch disabled" do 794 | before do 795 | allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) 796 | allow(record.pg_search_document).to receive(:save) 797 | end 798 | 799 | it "does not call save on the document" do 800 | expect(record.pg_search_document).not_to have_received(:save) 801 | end 802 | 803 | it "does not create a PgSearch::Document record" do 804 | expect { record.save! }.not_to change(PgSearch::Document, :count) 805 | end 806 | end 807 | end 808 | end 809 | end 810 | 811 | context "when the document is missing" do 812 | before { record.pg_search_document = nil } 813 | 814 | describe "saving the record" do 815 | context "with multisearch enabled" do 816 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(true) } 817 | 818 | context "when the condition is true" do 819 | before { record.not_multisearchable = true } 820 | 821 | it "does not create a PgSearch::Document record" do 822 | expect { record.save! }.not_to change(PgSearch::Document, :count) 823 | end 824 | end 825 | 826 | context "when the condition is false" do 827 | it "creates a PgSearch::Document record" do 828 | expect { record.save! }.to change(PgSearch::Document, :count).by(1) 829 | end 830 | 831 | context "with multisearch disabled" do 832 | before { allow(PgSearch).to receive(:multisearch_enabled?).and_return(false) } 833 | 834 | it "does not create a PgSearch::Document record" do 835 | expect { record.save! }.not_to change(PgSearch::Document, :count) 836 | end 837 | end 838 | end 839 | end 840 | end 841 | end 842 | end 843 | 844 | describe "after_destroy" do 845 | let(:record) { ModelThatIsMultisearchable.create!(not_multisearchable: false) } 846 | 847 | it "removes its document" do 848 | document = record.pg_search_document 849 | expect { record.destroy }.to change(PgSearch::Document, :count).by(-1) 850 | expect { PgSearch::Document.find(document.id) }.to raise_error(ActiveRecord::RecordNotFound) 851 | end 852 | end 853 | end 854 | end 855 | end 856 | end 857 | # standard:enable RSpec/NestedGroups 858 | -------------------------------------------------------------------------------- /spec/lib/pg_search/normalizer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | # standard:disable RSpec/NestedGroups 6 | describe PgSearch::Normalizer do 7 | describe "#add_normalization" do 8 | context "when config[:ignore] includes :accents" do 9 | context "when passed an Arel node" do 10 | it "wraps the expression in unaccent()" do 11 | config = instance_double(PgSearch::Configuration, "config", ignore: [:accents]) 12 | node = Arel::Nodes::NamedFunction.new("foo", [Arel::Nodes.build_quoted("bar")]) 13 | 14 | normalizer = described_class.new(config) 15 | expect(normalizer.add_normalization(node)).to eq("unaccent(foo('bar'))") 16 | end 17 | 18 | context "when a custom unaccent function is specified" do 19 | it "wraps the expression in that function" do 20 | allow(PgSearch).to receive(:unaccent_function).and_return("my_unaccent") 21 | node = Arel::Nodes::NamedFunction.new("foo", [Arel::Nodes.build_quoted("bar")]) 22 | 23 | config = instance_double(PgSearch::Configuration, "config", ignore: [:accents]) 24 | 25 | normalizer = described_class.new(config) 26 | expect(normalizer.add_normalization(node)).to eq("my_unaccent(foo('bar'))") 27 | end 28 | end 29 | end 30 | 31 | context "when passed a String" do 32 | it "wraps the expression in unaccent()" do 33 | config = instance_double(PgSearch::Configuration, "config", ignore: [:accents]) 34 | 35 | normalizer = described_class.new(config) 36 | expect(normalizer.add_normalization("foo")).to eq("unaccent(foo)") 37 | end 38 | 39 | context "when a custom unaccent function is specified" do 40 | it "wraps the expression in that function" do 41 | allow(PgSearch).to receive(:unaccent_function).and_return("my_unaccent") 42 | 43 | config = instance_double(PgSearch::Configuration, "config", ignore: [:accents]) 44 | 45 | normalizer = described_class.new(config) 46 | expect(normalizer.add_normalization("foo")).to eq("my_unaccent(foo)") 47 | end 48 | end 49 | end 50 | end 51 | 52 | context "when config[:ignore] does not include :accents" do 53 | it "passes the expression through" do 54 | config = instance_double(PgSearch::Configuration, "config", ignore: []) 55 | 56 | normalizer = described_class.new(config) 57 | expect(normalizer.add_normalization("foo")).to eq("foo") 58 | end 59 | end 60 | end 61 | end 62 | # standard:enable RSpec/NestedGroups 63 | -------------------------------------------------------------------------------- /spec/lib/pg_search_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | # For Active Record 5.x, the association reflection's cache needs be cleared 6 | # because we're stubbing the related constants. 7 | if ActiveRecord::VERSION::MAJOR == 5 8 | def clear_searchable_cache 9 | PgSearch::Document.reflect_on_association(:searchable).clear_association_scope_cache 10 | end 11 | else 12 | def clear_searchable_cache 13 | end 14 | end 15 | 16 | # standard:disable RSpec/NestedGroups 17 | describe PgSearch do 18 | describe ".multisearch" do 19 | with_table "pg_search_documents", &DOCUMENTS_SCHEMA 20 | 21 | describe "delegation to PgSearch::Document.search" do 22 | subject { described_class.multisearch(query) } 23 | 24 | let(:query) { instance_double(String, "query") } 25 | let(:relation) { instance_double(ActiveRecord::Relation, "relation") } 26 | 27 | before do 28 | allow(PgSearch::Document).to receive(:search).with(query).and_return(relation) 29 | end 30 | 31 | it { is_expected.to eq(relation) } 32 | end 33 | 34 | context "with PgSearch.multisearch_options set to a Hash" do 35 | subject do 36 | clear_searchable_cache 37 | described_class.multisearch(query).map(&:searchable) 38 | end 39 | 40 | before { allow(described_class).to receive(:multisearch_options).and_return(using: :dmetaphone) } 41 | 42 | with_model :MultisearchableModel do 43 | table do |t| 44 | t.string :title 45 | end 46 | model do 47 | include PgSearch::Model 48 | multisearchable against: :title 49 | end 50 | end 51 | 52 | let!(:soundalike_record) { MultisearchableModel.create!(title: "foning") } 53 | let(:query) { "Phoning" } 54 | 55 | it { is_expected.to include(soundalike_record) } 56 | end 57 | 58 | context "with PgSearch.multisearch_options set to a Proc" do 59 | subject do 60 | clear_searchable_cache 61 | described_class.multisearch(query, soundalike).map(&:searchable) 62 | end 63 | 64 | before do 65 | allow(described_class).to receive(:multisearch_options) do 66 | lambda do |query, soundalike| 67 | if soundalike 68 | {using: :dmetaphone, query: query} 69 | else 70 | {query: query} 71 | end 72 | end 73 | end 74 | end 75 | 76 | with_model :MultisearchableModel do 77 | table do |t| 78 | t.string :title 79 | end 80 | model do 81 | include PgSearch::Model 82 | multisearchable against: :title 83 | end 84 | end 85 | 86 | let!(:soundalike_record) { MultisearchableModel.create!(title: "foning") } 87 | let(:query) { "Phoning" } 88 | 89 | context "with soundalike true" do 90 | let(:soundalike) { true } 91 | 92 | it { is_expected.to include(soundalike_record) } 93 | end 94 | 95 | context "with soundalike false" do 96 | let(:soundalike) { false } 97 | 98 | it { is_expected.not_to include(soundalike_record) } 99 | end 100 | end 101 | 102 | context "when on an STI subclass" do 103 | context "with standard type column" do 104 | with_model :SuperclassModel do 105 | table do |t| 106 | t.text "content" 107 | t.string "type" 108 | end 109 | end 110 | 111 | before do 112 | searchable_subclass_model = Class.new(SuperclassModel) do 113 | include PgSearch::Model 114 | multisearchable against: :content 115 | end 116 | stub_const("SearchableSubclassModel", searchable_subclass_model) 117 | stub_const("AnotherSearchableSubclassModel", searchable_subclass_model) 118 | stub_const("NonSearchableSubclassModel", Class.new(SuperclassModel)) 119 | end 120 | 121 | it "returns only results for that subclass" do 122 | included = SearchableSubclassModel.create!(content: "foo bar") 123 | 124 | SearchableSubclassModel.create!(content: "baz") 125 | SuperclassModel.create!(content: "foo bar") 126 | SuperclassModel.create!(content: "baz") 127 | NonSearchableSubclassModel.create!(content: "foo bar") 128 | NonSearchableSubclassModel.create!(content: "baz") 129 | 130 | expect(SuperclassModel.count).to be 6 131 | expect(SearchableSubclassModel.count).to be 2 132 | 133 | expect(PgSearch::Document.count).to be 2 134 | 135 | results = described_class.multisearch("foo bar") 136 | 137 | expect(results).to eq [included.pg_search_document] 138 | end 139 | 140 | it "updates an existing STI model does not create a new pg_search document" do 141 | model = SearchableSubclassModel.create!(content: "foo bar") 142 | expect(SearchableSubclassModel.count).to eq(1) 143 | # We fetch the model from the database again otherwise 144 | # the pg_search_document from the cache is used. 145 | model = SearchableSubclassModel.find(model.id) 146 | model.content = "foo" 147 | model.save! 148 | results = described_class.multisearch("foo") 149 | expect(results.size).to eq(SearchableSubclassModel.count) 150 | end 151 | 152 | # standard:disable RSpec/MultipleExpectations 153 | specify "reindexing works" do 154 | NonSearchableSubclassModel.create!(content: "foo bar") 155 | NonSearchableSubclassModel.create!(content: "baz") 156 | expected = SearchableSubclassModel.create!(content: "baz") 157 | SuperclassModel.create!(content: "foo bar") 158 | SuperclassModel.create!(content: "baz") 159 | SuperclassModel.create!(content: "baz2") 160 | 161 | expect(SuperclassModel.count).to be 6 162 | expect(NonSearchableSubclassModel.count).to be 2 163 | expect(SearchableSubclassModel.count).to be 1 164 | 165 | expect(PgSearch::Document.count).to be 1 166 | 167 | PgSearch::Multisearch.rebuild(SearchableSubclassModel) 168 | 169 | clear_searchable_cache 170 | expect(PgSearch::Document.count).to be 1 171 | expect(PgSearch::Document.first.searchable.class).to be SearchableSubclassModel 172 | expect(PgSearch::Document.first.searchable).to eq expected 173 | end 174 | # standard:enable RSpec/MultipleExpectations 175 | 176 | it "reindexing searchable STI doesn't clobber other related STI models" do 177 | SearchableSubclassModel.create!(content: "baz") 178 | AnotherSearchableSubclassModel.create!(content: "baz") 179 | 180 | expect(PgSearch::Document.count).to be 2 181 | PgSearch::Multisearch.rebuild(SearchableSubclassModel) 182 | expect(PgSearch::Document.count).to be 2 183 | 184 | clear_searchable_cache 185 | classes = PgSearch::Document.all.collect { |d| d.searchable.class } 186 | expect(classes).to include SearchableSubclassModel 187 | expect(classes).to include AnotherSearchableSubclassModel 188 | end 189 | end 190 | 191 | context "with custom type column" do 192 | with_model :SuperclassModel do 193 | table do |t| 194 | t.text "content" 195 | t.string "inherit" 196 | end 197 | 198 | model do 199 | self.inheritance_column = "inherit" 200 | end 201 | end 202 | 203 | before do 204 | searchable_subclass_model = Class.new(SuperclassModel) do 205 | include PgSearch::Model 206 | multisearchable against: :content 207 | end 208 | stub_const("SearchableSubclassModel", searchable_subclass_model) 209 | stub_const("AnotherSearchableSubclassModel", searchable_subclass_model) 210 | stub_const("NonSearchableSubclassModel", Class.new(SuperclassModel)) 211 | end 212 | 213 | it "returns only results for that subclass" do 214 | included = SearchableSubclassModel.create!(content: "foo bar") 215 | 216 | SearchableSubclassModel.create!(content: "baz") 217 | SuperclassModel.create!(content: "foo bar") 218 | SuperclassModel.create!(content: "baz") 219 | NonSearchableSubclassModel.create!(content: "foo bar") 220 | NonSearchableSubclassModel.create!(content: "baz") 221 | 222 | expect(SuperclassModel.count).to be 6 223 | expect(SearchableSubclassModel.count).to be 2 224 | 225 | expect(PgSearch::Document.count).to be 2 226 | 227 | results = described_class.multisearch("foo bar") 228 | 229 | expect(results).to eq [included.pg_search_document] 230 | end 231 | end 232 | end 233 | end 234 | 235 | describe ".disable_multisearch" do 236 | it "disables multisearch temporarily" do 237 | multisearch_enabled_before = described_class.multisearch_enabled? 238 | multisearch_enabled_inside = nil 239 | described_class.disable_multisearch do 240 | multisearch_enabled_inside = described_class.multisearch_enabled? 241 | end 242 | multisearch_enabled_after = described_class.multisearch_enabled? 243 | 244 | expect(multisearch_enabled_before).to be(true) 245 | expect(multisearch_enabled_inside).to be(false) 246 | expect(multisearch_enabled_after).to be(true) 247 | end 248 | 249 | it "reenables multisearch after an error" do 250 | multisearch_enabled_before = described_class.multisearch_enabled? 251 | multisearch_enabled_inside = nil 252 | begin 253 | described_class.disable_multisearch do 254 | multisearch_enabled_inside = described_class.multisearch_enabled? 255 | raise 256 | end 257 | rescue 258 | end 259 | multisearch_enabled_after = described_class.multisearch_enabled? 260 | 261 | expect(multisearch_enabled_before).to be(true) 262 | expect(multisearch_enabled_inside).to be(false) 263 | expect(multisearch_enabled_after).to be(true) 264 | end 265 | 266 | # standard:disable RSpec/ExampleLength 267 | it "does not disable multisearch on other threads" do 268 | values = Queue.new 269 | sync = Queue.new 270 | Thread.new do 271 | values.push described_class.multisearch_enabled? 272 | sync.pop # wait 273 | values.push described_class.multisearch_enabled? 274 | sync.pop # wait 275 | values.push described_class.multisearch_enabled? 276 | end 277 | 278 | multisearch_enabled_before = values.pop 279 | multisearch_enabled_inside = nil 280 | described_class.disable_multisearch do 281 | sync.push :go 282 | multisearch_enabled_inside = values.pop 283 | end 284 | sync.push :go 285 | multisearch_enabled_after = values.pop 286 | 287 | expect(multisearch_enabled_before).to be(true) 288 | expect(multisearch_enabled_inside).to be(true) 289 | expect(multisearch_enabled_after).to be(true) 290 | end 291 | # standard:enable RSpec/ExampleLength 292 | end 293 | end 294 | # standard:enable RSpec/NestedGroups 295 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | require "warning" 5 | 6 | # https://github.com/grodowski/undercover#setting-up-required-lcov-reporting 7 | require "simplecov" 8 | require "simplecov-lcov" 9 | SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true 10 | SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter 11 | SimpleCov.start do 12 | add_filter(%r{^/spec/}) 13 | enable_coverage(:branch) 14 | end 15 | require "undercover" 16 | 17 | require "bundler/setup" 18 | require "pg_search" 19 | 20 | RSpec.configure do |config| 21 | config.expect_with :rspec do |expects| 22 | expects.syntax = :expect 23 | end 24 | 25 | config.mock_with :rspec do |mocks| 26 | mocks.syntax = :expect 27 | mocks.verify_doubled_constant_names = true 28 | mocks.verify_partial_doubles = true 29 | end 30 | 31 | config.example_status_persistence_file_path = "tmp/examples.txt" 32 | end 33 | 34 | require "support/database" 35 | require "support/with_model" 36 | 37 | DOCUMENTS_SCHEMA = lambda do |t| 38 | t.belongs_to :searchable, polymorphic: true, index: true 39 | t.text :content 40 | t.timestamps null: false 41 | 42 | # Used to test additional_attributes setup 43 | t.text :additional_attribute_column 44 | end 45 | -------------------------------------------------------------------------------- /spec/support/database.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | case RUBY_PLATFORM 4 | when "java" 5 | require "activerecord-jdbc-adapter" 6 | ERROR_CLASS = ActiveRecord::JDBCError 7 | else 8 | require "pg" 9 | ERROR_CLASS = PG::Error 10 | end 11 | 12 | begin 13 | connection_options = {adapter: "postgresql", database: "pg_search_test", min_messages: "warning"} 14 | if ENV["CI"] 15 | connection_options[:username] = "postgres" 16 | connection_options[:password] = "postgres" 17 | end 18 | ActiveRecord::Base.establish_connection(connection_options) 19 | connection = ActiveRecord::Base.connection 20 | connection.execute("SELECT 1") 21 | rescue ERROR_CLASS, ActiveRecord::NoDatabaseError => e 22 | at_exit do 23 | puts "-" * 80 24 | puts "Unable to connect to database. Please run:" 25 | puts 26 | puts " createdb pg_search_test" 27 | puts "-" * 80 28 | end 29 | raise e 30 | end 31 | 32 | if ENV["LOGGER"] 33 | require "logger" 34 | ActiveRecord::Base.logger = Logger.new($stdout) 35 | end 36 | 37 | def install_extension(name) 38 | connection = ActiveRecord::Base.connection 39 | extension = connection.execute "SELECT * FROM pg_catalog.pg_extension WHERE extname = '#{name}';" 40 | return unless extension.none? 41 | 42 | connection.execute "CREATE EXTENSION #{name};" 43 | rescue => e 44 | at_exit do 45 | puts "-" * 80 46 | puts "Please install the #{name} extension" 47 | puts "-" * 80 48 | end 49 | raise e 50 | end 51 | 52 | def install_extension_if_missing(name, query, expected_result) 53 | result = ActiveRecord::Base.connection.select_value(query) 54 | raise "Unexpected output for #{query}: #{result.inspect}" unless result.casecmp(expected_result).zero? 55 | rescue 56 | install_extension(name) 57 | end 58 | 59 | install_extension_if_missing("pg_trgm", "SELECT 'abcdef' % 'cdef'", "t") 60 | install_extension_if_missing("unaccent", "SELECT unaccent('foo')", "foo") 61 | install_extension_if_missing("fuzzystrmatch", "SELECT dmetaphone('foo')", "f") 62 | 63 | def load_sql(filename) 64 | connection = ActiveRecord::Base.connection 65 | file_contents = File.read(File.join(File.dirname(__FILE__), "..", "..", "sql", filename)) 66 | connection.execute(file_contents) 67 | end 68 | 69 | load_sql("dmetaphone.sql") 70 | -------------------------------------------------------------------------------- /spec/support/with_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "with_model" 4 | 5 | RSpec.configure do |config| 6 | config.extend WithModel 7 | end 8 | -------------------------------------------------------------------------------- /sql/dmetaphone.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION pg_search_dmetaphone(text) RETURNS text LANGUAGE SQL IMMUTABLE STRICT AS $function$ 2 | SELECT array_to_string(ARRAY(SELECT dmetaphone(unnest(regexp_split_to_array($1, E'\\s+')))), ' ') 3 | $function$; 4 | -------------------------------------------------------------------------------- /sql/uninstall_dmetaphone.sql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION pg_search_dmetaphone(text); 2 | --------------------------------------------------------------------------------