diff --git a/.codeclimate.yml b/.codeclimate.yml
new file mode 100644
index 00000000..6cdba48a
--- /dev/null
+++ b/.codeclimate.yml
@@ -0,0 +1,8 @@
+---
+plugins:
+ rubocop:
+ enabled: true
+ channel: rubocop-1-31-0
+exclude_patterns:
+ - spec/
+ - lib/generators/rails/templates/
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..9d1a58d9
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,128 @@
+---
+name: CI
+
+on:
+ push:
+ branches:
+ - '**'
+ pull_request:
+ branches:
+ - '**'
+ schedule:
+ - cron: '0 4 1 * *'
+ # Run workflow manually
+ workflow_dispatch:
+
+jobs:
+ rubocop:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.1'
+
+ - name: Bundler
+ run: bundle install
+
+ - name: Rubocop
+ run: bin/rubocop
+
+ rspec:
+ runs-on: ubuntu-latest
+
+ env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
+ BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.rails }}_with_${{ matrix.adapter }}.gemfile
+
+ services:
+ postgres:
+ image: 'postgres:16'
+ ports: ['5432:5432']
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: ajax_datatables_rails
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ # Using docker image fails with
+ # invalid reference format
+ # mariadb:
+ # image: 'mariadb:10.3'
+ # ports: ['3306:3306']
+ # env:
+ # MYSQL_ROOT_PASSWORD: root
+ # MYSQL_DATABASE: ajax_datatables_rails
+ # options: >-
+ # --health-cmd 'mysqladmin ping'
+ # --health-interval 10s
+ # --health-timeout 5s
+ # --health-retries 3
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby:
+ - '3.4'
+ - '3.3'
+ - '3.2'
+ - '3.1'
+ - 'head'
+ rails:
+ - rails_8.0
+ - rails_7.2
+ - rails_7.1
+ adapter:
+ - sqlite3
+ - postgresql
+ - mysql2
+ - postgis
+ # Disabled for now:
+ # Rails 7.0: trilogy_auth_recv: caching_sha2_password requires either TCP with TLS or a unix socket: TRILOGY_UNSUPPORTED
+ # Rails 7.1: unknown keyword: :uses_transaction
+ # Rails 7.2: NotImplementedError
+ # - trilogy
+ exclude:
+ # Rails 8.0 needs Ruby > 3.2
+ - rails: 'rails_8.0'
+ ruby: '3.1'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set DB Adapter
+ env:
+ DB_ADAPTER: ${{ matrix.adapter }}
+
+ # See: https://github.com/actions/virtual-environments/blob/main/images/linux/Ubuntu2004-README.md#mysql
+ run: |
+ if [[ "${DB_ADAPTER}" == "mysql2" ]] || [[ "${DB_ADAPTER}" == "trilogy" ]]; then
+ sudo systemctl start mysql.service
+ mysql -u root -proot -e 'create database ajax_datatables_rails;'
+ fi
+
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby }}
+ bundler-cache: true
+ env:
+ DB_ADAPTER: ${{ matrix.adapter }}
+
+ - name: Run RSpec
+ env:
+ DB_ADAPTER: ${{ matrix.adapter }}
+ run: bin/rspec
+
+ - name: Publish code coverage
+ uses: qltysh/qlty-action/coverage@v1
+ with:
+ token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
+ files: coverage/coverage.json
diff --git a/.github/workflows/ci_oracle.yml b/.github/workflows/ci_oracle.yml
new file mode 100644
index 00000000..7df46ebf
--- /dev/null
+++ b/.github/workflows/ci_oracle.yml
@@ -0,0 +1,104 @@
+---
+name: CI Oracle
+
+on:
+ push:
+ branches:
+ - '**'
+ pull_request:
+ branches:
+ - '**'
+ schedule:
+ - cron: '0 4 1 * *'
+ # Run workflow manually
+ workflow_dispatch:
+
+jobs:
+ rspec:
+ runs-on: ubuntu-latest
+
+ env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
+ BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.rails }}_with_${{ matrix.adapter }}.gemfile
+ ORACLE_HOME: /opt/oracle/instantclient_23_8
+ LD_LIBRARY_PATH: /opt/oracle/instantclient_23_8
+ TNS_ADMIN: ./ci/network/admin
+ DATABASE_SYS_PASSWORD: Oracle18
+ DATABASE_NAME: FREEPDB1
+
+ services:
+ oracle:
+ image: gvenzl/oracle-free:latest
+ ports:
+ - 1521:1521
+ env:
+ TZ: Europe/Paris
+ ORACLE_PASSWORD: Oracle18
+ options: >-
+ --health-cmd healthcheck.sh
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 10
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby:
+ - '3.4'
+ - '3.3'
+ - '3.2'
+ - '3.1'
+ - 'head'
+ rails:
+ - rails_8.0
+ - rails_7.2
+ - rails_7.1
+ adapter:
+ - oracle_enhanced
+ exclude:
+ - rails: 'rails_8.0'
+ ruby: '3.1'
+ adapter: 'oracle_enhanced'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Create symbolic link for libaio library compatibility
+ run: |
+ sudo ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1
+
+ - name: Download Oracle instant client
+ run: |
+ wget -q https://download.oracle.com/otn_software/linux/instantclient/2380000/instantclient-basic-linux.x64-23.8.0.25.04.zip
+ wget -q https://download.oracle.com/otn_software/linux/instantclient/2380000/instantclient-sdk-linux.x64-23.8.0.25.04.zip
+ wget -q https://download.oracle.com/otn_software/linux/instantclient/2380000/instantclient-sqlplus-linux.x64-23.8.0.25.04.zip
+
+ - name: Install Oracle instant client
+ run: |
+ sudo unzip instantclient-basic-linux.x64-23.8.0.25.04.zip -d /opt/oracle/
+ sudo unzip -o instantclient-sdk-linux.x64-23.8.0.25.04.zip -d /opt/oracle/
+ sudo unzip -o instantclient-sqlplus-linux.x64-23.8.0.25.04.zip -d /opt/oracle/
+ echo "/opt/oracle/instantclient_23_8" >> $GITHUB_PATH
+
+ - name: Create database user
+ run: |
+ ./ci/setup_accounts.sh
+
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby }}
+ bundler-cache: true
+ env:
+ DB_ADAPTER: ${{ matrix.adapter }}
+
+ - name: Run RSpec
+ env:
+ DB_ADAPTER: ${{ matrix.adapter }}
+ run: bin/rspec
+
+ - name: Publish code coverage
+ uses: qltysh/qlty-action/coverage@v1
+ with:
+ token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
+ files: coverage/coverage.json
diff --git a/.gitignore b/.gitignore
index d87d4be6..bb537c9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,17 +1,26 @@
-*.gem
-*.rbc
-.bundle
-.config
-.yardoc
-Gemfile.lock
-InstalledFiles
-_yardoc
-coverage
-doc/
-lib/bundler/man
-pkg
-rdoc
-spec/reports
-test/tmp
-test/version_tmp
-tmp
+# Ignore bundler config.
+/.bundle
+
+# Ignore Gemfile.lock
+/Gemfile.lock
+/gemfiles/*.lock
+/gemfiles/.bundle
+
+# Ignore test files
+/coverage
+/tmp
+
+# RVM files
+/.ruby-version
+
+# Gem files
+/*.gem
+
+# Ignore dummy app files
+spec/dummy/db/*.sqlite3
+spec/dummy/db/*.sqlite3-journal
+spec/dummy/log/*.log
+spec/dummy/tmp/
+
+# Ignore MacOS files
+.DS_Store
diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml
new file mode 100644
index 00000000..75811a42
--- /dev/null
+++ b/.qlty/qlty.toml
@@ -0,0 +1,35 @@
+config_version = "0"
+
+[[source]]
+name = "default"
+default = true
+
+[[plugin]]
+name = "actionlint"
+
+[[plugin]]
+name = "checkov"
+version = "3.2.49"
+
+[[plugin]]
+name = "markdownlint"
+version = "0.31.1"
+
+[[plugin]]
+name = "osv-scanner"
+
+[[plugin]]
+name = "prettier"
+version = "2.8.4"
+
+[[plugin]]
+name = "ripgrep"
+
+[[plugin]]
+name = "trivy"
+
+[[plugin]]
+name = "trufflehog"
+
+[[plugin]]
+name = "yamllint"
diff --git a/.rspec b/.rspec
index 4e1e0d2f..372b5acf 100644
--- a/.rspec
+++ b/.rspec
@@ -1 +1 @@
---color
+--warnings
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 00000000..63074818
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,85 @@
+---
+plugins:
+ - rubocop-factory_bot
+ - rubocop-performance
+ - rubocop-rake
+ - rubocop-rspec
+
+AllCops:
+ NewCops: enable
+ TargetRubyVersion: 3.1
+ Exclude:
+ - bin/*
+ - gemfiles/*
+ - spec/dummy/**/*
+
+#########
+# STYLE #
+#########
+
+Style/Documentation:
+ Enabled: false
+
+Style/TrailingCommaInArrayLiteral:
+ EnforcedStyleForMultiline: comma
+
+Style/TrailingCommaInHashLiteral:
+ EnforcedStyleForMultiline: comma
+
+Style/BlockDelimiters:
+ AllowedPatterns: ['expect']
+
+##########
+# LAYOUT #
+##########
+
+Layout/LineLength:
+ Max: 150
+ Exclude:
+ - ajax-datatables-rails.gemspec
+
+Layout/EmptyLines:
+ Enabled: false
+
+Layout/EmptyLineBetweenDefs:
+ Enabled: false
+
+Layout/EmptyLinesAroundClassBody:
+ Enabled: false
+
+Layout/EmptyLinesAroundBlockBody:
+ Enabled: false
+
+Layout/EmptyLinesAroundModuleBody:
+ Enabled: false
+
+Layout/HashAlignment:
+ EnforcedColonStyle: table
+ EnforcedHashRocketStyle: table
+
+##########
+# NAMING #
+##########
+
+Naming/FileName:
+ Exclude:
+ - lib/ajax-datatables-rails.rb
+
+#########
+# RSPEC #
+#########
+
+RSpec/MultipleExpectations:
+ Max: 7
+
+RSpec/NestedGroups:
+ Max: 6
+
+RSpec/ExampleLength:
+ Max: 9
+
+RSpec/MultipleMemoizedHelpers:
+ Max: 6
+
+RSpec/NotToNot:
+ EnforcedStyle: to_not
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 6328daba..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-language: ruby
-rvm:
- - 1.9.2
- - 1.9.3
- - 2.0.0
- - 2.1.0
- - 2.1.1
- - 2.1.2
diff --git a/Appraisals b/Appraisals
new file mode 100644
index 00000000..d39912b0
--- /dev/null
+++ b/Appraisals
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+###############
+# RAILS 7.1.0 #
+###############
+
+appraise 'rails_7.1_with_postgresql' do
+ gem 'rails', '~> 7.1.0'
+ gem 'pg'
+end
+
+appraise 'rails_7.1_with_sqlite3' do
+ gem 'rails', '~> 7.1.0'
+ gem 'sqlite3', '~> 1.5.0'
+ remove_gem 'pg'
+end
+
+appraise 'rails_7.1_with_mysql2' do
+ gem 'rails', '~> 7.1.0'
+ gem 'mysql2'
+ remove_gem 'pg'
+end
+
+appraise 'rails_7.1_with_trilogy' do
+ gem 'rails', '~> 7.1.0'
+ gem 'activerecord-trilogy-adapter'
+ remove_gem 'pg'
+end
+
+appraise 'rails_7.1_with_oracle_enhanced' do
+ gem 'rails', '~> 7.1.0'
+ gem 'activerecord-oracle_enhanced-adapter', '~> 7.1.0'
+ remove_gem 'pg'
+end
+
+appraise 'rails_7.1_with_postgis' do
+ gem 'rails', '~> 7.1.0'
+ gem 'pg'
+ gem 'activerecord-postgis-adapter', '~> 9.0.0'
+end
+
+###############
+# RAILS 7.2.0 #
+###############
+
+appraise 'rails_7.2_with_postgresql' do
+ gem 'rails', '~> 7.2.0'
+ gem 'pg'
+end
+
+appraise 'rails_7.2_with_sqlite3' do
+ gem 'rails', '~> 7.2.0'
+ gem 'sqlite3', '~> 1.5.0'
+ remove_gem 'pg'
+end
+
+appraise 'rails_7.2_with_mysql2' do
+ gem 'rails', '~> 7.2.0'
+ gem 'mysql2'
+ remove_gem 'pg'
+end
+
+appraise 'rails_7.2_with_trilogy' do
+ gem 'rails', '~> 7.2.0'
+ gem 'activerecord-trilogy-adapter'
+ remove_gem 'pg'
+end
+
+appraise 'rails_7.2_with_oracle_enhanced' do
+ gem 'rails', '~> 7.2.0'
+ gem 'activerecord-oracle_enhanced-adapter', '~> 7.2.0'
+ remove_gem 'pg'
+end
+
+appraise 'rails_7.2_with_postgis' do
+ gem 'rails', '~> 7.2.0'
+ gem 'pg'
+ gem 'activerecord-postgis-adapter', '~> 10.0.0'
+end
+
+###############
+# RAILS 8.0.0 #
+###############
+
+appraise 'rails_8.0_with_postgresql' do
+ gem 'rails', '~> 8.0.0'
+ gem 'pg'
+end
+
+appraise 'rails_8.0_with_sqlite3' do
+ gem 'rails', '~> 8.0.0'
+ gem 'sqlite3'
+ remove_gem 'pg'
+end
+
+appraise 'rails_8.0_with_mysql2' do
+ gem 'rails', '~> 8.0.0'
+ gem 'mysql2'
+ remove_gem 'pg'
+end
+
+appraise 'rails_8.0_with_trilogy' do
+ gem 'rails', '~> 8.0.0'
+ gem 'activerecord-trilogy-adapter'
+ remove_gem 'pg'
+end
+
+appraise 'rails_8.0_with_oracle_enhanced' do
+ gem 'rails', '~> 8.0.0'
+ gem 'activerecord-oracle_enhanced-adapter', '~> 8.0.0'
+ remove_gem 'pg'
+end
+
+appraise 'rails_8.0_with_postgis' do
+ gem 'rails', '~> 8.0.0'
+ gem 'pg'
+ gem 'activerecord-postgis-adapter', '~> 11.0.0'
+end
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c2a6f66..10407924 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,40 +1,191 @@
# CHANGELOG
-## 0.2.1
-* Fix count method to work with select statements under Rails 4.1. Thanks to [Jason Mitchell](https://github.com/mitchej123) for the contribution.
-* Edits to `README` documentation about the `options` hash. Thanks to [Jonathan E Hogue](https://github.com/hoguej) for pointing out that previous documentation was confusing and didn't address its usage properly.
-* Edits to `README` documentation on complex model queries inside the `get_raw_records` method. A round of applause to [Zoltan Paulovics](https://github.com/zpaulovics) for contributing this awesome piece of documentation. :smile:
-* Adds typecast step to `search_condition` method, so now we support having non-text columns inside the `searchable_columns` array.
-* Adds support for multi-column sorting and multi-term search. Thanks to [Zoltan Paulovics](https://github.com/zpaulovics) for contributing this feature.
-* Adds optional config initializer, so we can have a base to typecast non text-based columns and perform searches depending on the use of `:mysql2`, `:sqlite3` or `:pg`. Thanks to [M. Saiqul Haq](https://github.com/saiqulhaq) for contributing this feature.
-
-## 0.2.0
+## 1.6.0 (2025-??-??)
+
+* Remove dead code
+* Implementing `searchable: false` tests
+* Improve objects shape
+* Fix Rubocop offenses
+* Make gem smaller
+* Drop support of Rails 6.0
+* Drop support of Rails 6.1
+* Drop support of Rails 7.0
+* Drop support of Ruby 2.7
+* Drop support of Ruby 3.0
+* Add support for Rails 7.2
+* Add support for Rails 8.0
+* Add support for Ruby 3.4
+
+## 1.5.0 (2024-04-08)
+
+* Add support for grouped results (merge: [#419](https://github.com/jbox-web/ajax-datatables-rails/pull/419))
+* Fix server-side out of order ajax responses (merge: [#418](https://github.com/jbox-web/ajax-datatables-rails/pull/418))
+* Add support for postgis adapter (merge: [#417](https://github.com/jbox-web/ajax-datatables-rails/pull/417))
+* Add support for trilogy adapter (merge: [#423](https://github.com/jbox-web/ajax-datatables-rails/pull/423))
+* Drop support of Rails 5.2
+* Add support for Rails 7.1
+* Add support for Ruby 3.2
+* Add support for Ruby 3.3
+
+This is the last version to support Rails 6.0.x and Ruby 2.7.x.
+
+## 1.4.0 (2022-12-18)
+
+* Improve tests
+* Add tests on custom_field feature
+* Drop support of Ruby 2.5
+* Drop support of Ruby 2.6
+* Add support of Ruby 3.1
+* Add support of Rails 7.0
+* Fix: prevent establishing ActiveRecord connection on startup
+
+## 1.3.1 (2021-02-09)
+
+* Fix rare case error `uninitialized constant AjaxDatatablesRails::ActiveRecord::Base` (merge: [#379](https://github.com/jbox-web/ajax-datatables-rails/pull/379))
+
+## 1.3.0 (2021-01-04)
+
+* Drop support of Rails 5.0.x and 5.1.x
+* Drop support of Ruby 2.4
+* Add support of Rails 6.1
+* Add support of Ruby 3.0
+* Switch from Travis to Github Actions
+* Improve specs
+* Fix lib loading with JRuby (fixes [#371](https://github.com/jbox-web/ajax-datatables-rails/issues/371))
+* Raise an error when column's `cond:` setting is unknown
+* Make global search and column search work together (merge: [#350](https://github.com/jbox-web/ajax-datatables-rails/pull/350), fixes: [#258](https://github.com/jbox-web/ajax-datatables-rails/issues/258))
+* Fix: date_range doesn't support searching by a date greater than today (merge: [#351](https://github.com/jbox-web/ajax-datatables-rails/pull/351))
+* Fix: undefined method `fetch' for nil:NilClass (fix: [#307](https://github.com/jbox-web/ajax-datatables-rails/issues/307))
+* Add support for json params (merge: [#355](https://github.com/jbox-web/ajax-datatables-rails/pull/355))
+
+* `AjaxDatatablesRails.config` is removed with no replacement. The gem is now configless :)
+* `AjaxDatatablesRails.config.db_adapter=` is removed and is configured per datatable class now. It defaults to Rails DB adapter. (fixes [#364](https://github.com/jbox-web/ajax-datatables-rails/issues/364))
+* `AjaxDatatablesRails.config.nulls_last=` is removed and is configured per datatable class now (or by column). It defaults to false.
+
+To mitigate this 3 changes see the [migration doc](/doc/migrate.md).
+
+## 1.2.0 (2020-04-19)
+
+* Drop support of Rails 4.x
+* Drop support of Ruby 2.3
+* Use [zeitwerk](https://github.com/fxn/zeitwerk) to load gem files
+* Add binstubs to ease development
+
+This is the last version to support Rails 5.0.x, Rails 5.1.x and Ruby 2.4.x.
+
+## 1.1.0 (2019-12-12)
+
+* Add rudimentary support for Microsoft SQL Server
+* Fixes errors when options[param] is nil [PR 315](https://github.com/jbox-web/ajax-datatables-rails/pull/315) (thanks @allard)
+* Improve query performance when nulls_last option is enabled [PR 317](https://github.com/jbox-web/ajax-datatables-rails/pull/317) (thanks @natebird)
+* Add :string_in cond [PR 323](https://github.com/jbox-web/ajax-datatables-rails/pull/323) (thanks @donnguyen)
+* Rename `sanitize` private method [PR 326](https://github.com/jbox-web/ajax-datatables-rails/pull/326) (thanks @epipheus)
+* Update documentation
+* Test with latest Rails (6.x) and Ruby versions (2.6)
+
+This is the last version to support Rails 4.x and Ruby 2.3.x.
+
+## 1.0.0 (2018-08-28)
+
+* Breaking change: Remove dependency on view_context [Issue #288](https://github.com/jbox-web/ajax-datatables-rails/issues/288)
+* Breaking change: Replace `config.orm = :active_record` by a class : `AjaxDatatablesRails::ActiveRecord` [Fix #228](https://github.com/jbox-web/ajax-datatables-rails/issues/228)
+
+To mitigate this 2 changes see the [migration doc](/doc/migrate.md).
+
+## 0.4.3 (2018-06-05)
+
+* Add: Add `:string_eq` condition on columns filter [Issue #291](https://github.com/jbox-web/ajax-datatables-rails/issues/291)
+
+**Note :** This is the last version to support Rails 4.0.x and Rails 4.1.x
+
+## 0.4.2 (2018-05-15)
+
+* Fix: Integer out of range [PR #289](https://github.com/jbox-web/ajax-datatables-rails/pull/289) from [PR #284](https://github.com/jbox-web/ajax-datatables-rails/pull/284)
+
+## 0.4.1 (2018-05-06)
+
+* Fix: Restore behavior of #filter method [Comment](https://github.com/jbox-web/ajax-datatables-rails/commit/07795fd26849ff1b3b567f4ce967f722907a45be#comments)
+* Fix: Fix erroneous offset/start behavior [PR #264](https://github.com/jbox-web/ajax-datatables-rails/pull/264)
+* Fix: "orderable" option has no effect [Issue #245](https://github.com/jbox-web/ajax-datatables-rails/issues/245)
+* Fix: Fix undefined method #and [PR #235](https://github.com/jbox-web/ajax-datatables-rails/pull/235)
+* Add: Add "order nulls last" option [PR #79](https://github.com/jbox-web/ajax-datatables-rails/pull/279)
+* Change: Rename `additional_datas` method as `additional_data` [PR #251](https://github.com/jbox-web/ajax-datatables-rails/pull/251)
+* Change: Added timezone support for daterange [PR #261](https://github.com/jbox-web/ajax-datatables-rails/pull/261)
+* Change: Add # frozen_string_literal: true pragma
+* Various improvements in internal API
+
+## 0.4.0 (2017-05-21)
+
+**Warning:** this version is a **major break** from v0.3. The core has been rewriten to remove dependency on Kaminari (or WillPaginate).
+
+It also brings a new (more natural) way of defining columns, based on hash definitions (and not arrays) and add some filtering options for column search. Take a look at the [README](https://github.com/jbox-web/ajax-datatables-rails#customize-the-generated-datatables-class) for more infos.
+
+## 0.3.1 (2015-07-13)
+* Adds `:oracle` as supported `db_adapter`. Thanks to [lutechspa](https://github.com/lutechspa) for this contribution.
+
+## 0.3.0 (2015-01-30)
+* Changes to the `sortable_columns` and `searchable_columns` syntax as it
+ required us to do unnecessary guessing. New syntax is `ModelName.column_name`
+ or `Namespace::ModelName.column_name`. Old syntax of `table_name.column_name`
+ is still available to use, but prints a deprecation warning. Thanks to
+ [M. Saiqul Haq](https://github.com/saiqulhaq) for pointing this.
+* Adds support to discover from received params if a column should be really
+ considered for sorting purposes. Thanks to [Zachariah Clay](https://github.com/mebezac)
+ for this contribution.
+* Moves paginator settings to configuration initializer.
+
+## 0.2.1 (2014-11-26)
+* Fix count method to work with select statements under Rails 4.1. Thanks to
+[Jason Mitchell](https://github.com/mitchej123) for the contribution.
+* Edits to `README` documentation about the `options` hash. Thanks to
+[Jonathan E Hogue](https://github.com/hoguej) for pointing out that previous
+documentation was confusing and didn't address its usage properly.
+* Edits to `README` documentation on complex model queries inside the
+`get_raw_records` method. A round of applause to [Zoltan Paulovics](https://github.com/zpaulovics)
+for contributing this awesome piece of documentation. :smile:
+* Adds typecast step to `search_condition` method, so now we support having
+non-text columns inside the `searchable_columns` array.
+* Adds support for multi-column sorting and multi-term search. Thanks to
+[Zoltan Paulovics](https://github.com/zpaulovics) for contributing this feature.
+* Adds optional config initializer, so we can have a base to typecast non
+text-based columns and perform searches depending on the use of `:mysql2`,
+`:sqlite3` or `:pg`. Thanks to [M. Saiqul Haq](https://github.com/saiqulhaq)
+for contributing this feature.
+
+## 0.2.0 (2014-06-19)
* This version works with jQuery dataTables ver. 1.10 and it's new API syntax.
* Added `legacy` branch to repo. If your project is working with jQuery
dataTables ver. 1.9, this is the branch you need to pull, or use the last
`0.1.x` version of this gem.
-## 0.1.2
+## 0.1.2 (2014-06-18)
* Fixes `where` clause being built even when search term is an empty string.
Thanks to [e-fisher](https://github.com/e-fisher) for spotting and fixing this.
-## 0.1.1
+## 0.1.1 (2014-06-13)
* Fixes problem on `searchable_columns` where the corresponding model is
a composite model name, e.g. `UserData`, `BillingAddress`.
Thanks to [iruca3](https://github.com/iruca3) for the fix.
-## 0.1.0
+## 0.1.0 (2014-05-21)
* A fresh start. Sets base class name to: `AjaxDatatablesRails::Base`.
* Extracts pagination functions to mixable modules.
* A user would have the option to stick to the base
`AjaxDatatablesRails::Extensions::SimplePaginator` or replace it with
`AjaxDatatablesRails::Extensions::Kaminari` or
- `AjaxDatatablesRails::Extensions::WillPaginate`, depending on what he/she is using to handle record pagination.
+ `AjaxDatatablesRails::Extensions::WillPaginate`, depending on what he/she
+ is using to handle record pagination.
* Removes dependency to pass in a model name to the generator. This way,
- the developer has more flexibility to implement whatever datatable feature is required.
+ the developer has more flexibility to implement whatever datatable feature is
+ required.
* Datatable constructor accepts an optional `options` hash to provide
more flexibility.
- See [README](https://github.com/antillas21/ajax-datatables-rails/blob/master/README.mds#options) for examples.
+ See [README](https://github.com/antillas21/ajax-datatables-rails/blob/master/README.mds#options)
+ for examples.
* Sets generator inside the `Rails` namespace. To generate an
`AjaxDatatablesRails` child class, just execute the
generator like this: `$ rails generate datatable NAME`.
+
+## 0.0.1 (2012-09-10)
+
+First release!
diff --git a/Gemfile b/Gemfile
index a9c2c10c..cc821440 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,4 +1,29 @@
+# frozen_string_literal: true
+
source 'https://rubygems.org/'
-# Specify your gem's dependencies in ajax-datatables-rails.gemspec
gemspec
+
+# Dev libs
+gem 'appraisal', git: 'https://github.com/thoughtbot/appraisal.git'
+gem 'combustion'
+gem 'database_cleaner'
+gem 'factory_bot'
+gem 'faker'
+gem 'generator_spec'
+gem 'puma'
+gem 'rake'
+gem 'rspec'
+gem 'rspec-rebound'
+gem 'simplecov'
+
+# Fallback to pg in dev/local environment
+gem 'pg'
+
+# Dev tools / linter
+gem 'guard-rspec', require: false
+gem 'rubocop', require: false
+gem 'rubocop-factory_bot', require: false
+gem 'rubocop-performance', require: false
+gem 'rubocop-rake', require: false
+gem 'rubocop-rspec', require: false
diff --git a/Guardfile b/Guardfile
new file mode 100644
index 00000000..5a44087b
--- /dev/null
+++ b/Guardfile
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+guard :rspec, cmd: 'bin/rspec' do
+ require 'guard/rspec/dsl'
+ dsl = Guard::RSpec::Dsl.new(self)
+
+ # RSpec files
+ rspec = dsl.rspec
+ watch(rspec.spec_helper) { rspec.spec_dir }
+ watch(rspec.spec_support) { rspec.spec_dir }
+ watch(rspec.spec_files)
+
+ # Ruby files
+ ruby = dsl.ruby
+ dsl.watch_spec_files_for(ruby.lib_files)
+end
diff --git a/LICENSE b/LICENSE
index 5311bd32..80771f38 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,22 +1,21 @@
-Copyright (c) 2012 Joel Quenneville
+The MIT License (MIT)
-MIT License
+Copyright (c) 2012 Joel Quenneville
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
index b1e48167..62fd2b29 100644
--- a/README.md
+++ b/README.md
@@ -1,147 +1,244 @@
# ajax-datatables-rails
-[](https://travis-ci.org/antillas21/ajax-datatables-rails)
-[](http://badge.fury.io/rb/ajax-datatables-rails)
-[](https://codeclimate.com/github/antillas21/ajax-datatables-rails)
+[](https://github.com/jbox-web/ajax-datatables-rails/blob/master/LICENSE)
+[](https://rubygems.org/gems/ajax-datatables-rails)
+[](https://rubygems.org/gems/ajax-datatables-rails)
+[](https://github.com/jbox-web/ajax-datatables-rails/actions)
+[](https://codeclimate.com/github/jbox-web/ajax-datatables-rails)
+[](https://codeclimate.com/github/jbox-web/ajax-datatables-rails/coverage)
-### Versions
+**Important : This gem is targeted at DataTables version 1.10.x.**
-[Datatables](http://datatables.net) recently released version 1.10 and deprecated version 1.9 which includes a new API and features.
+It's tested against :
-If you have dataTables 1.9 in your project and want to keep using it, please use this gem's version `0.1.x` in your `Gemfile`:
+* Rails: 7.1 / 7.2 / 8.0
+* Ruby: 3.1 / 3.2 / 3.3 / 3.4
+* Databases: MySQL 8 / SQLite3 / Postgresql 16 / Oracle XE 11.2 (thanks to [travis-oracle](https://github.com/cbandy/travis-oracle))
+* Adapters: sqlite / mysql2 / trilogy / postgres / postgis / oracle
-```ruby
-# specific version number
-gem 'ajax-datatables-rails', '0.1.2'
+## Description
+
+> [DataTables](https://datatables.net/) is a nifty jQuery plugin that adds the ability to paginate, sort, and search your html tables.
+> When dealing with large tables (more than a couple of hundred rows) however, we run into performance issues.
+> These can be fixed by using server-side pagination, but this breaks some DataTables functionality.
+>
+> `ajax-datatables-rails` is a wrapper around DataTables ajax methods that allow synchronization with server-side pagination in a Rails app.
+> It was inspired by this [Railscast](http://railscasts.com/episodes/340-datatables).
+> I needed to implement a similar solution in a couple projects I was working on, so I extracted a solution into a gem.
+>
+> Joel Quenneville (original author)
+>
+> I needed a good gem to manage a lot of DataTables so I chose this one :)
+>
+> Nicolas Rodriguez (current maintainer)
+
+The final goal of this gem is to **generate a JSON** content that will be given to jQuery DataTables.
+All the datatable customizations (header, tr, td, css classes, width, height, buttons, etc...) **must** take place in the [javascript definition](#5-wire-up-the-javascript) of the datatable.
+jQuery DataTables is a very powerful tool with a lot of customizations available. Take the time to [read the doc](https://datatables.net/reference/option/).
+
+You'll find a sample project here : https://ajax-datatables-rails.herokuapp.com
+
+Its real world examples. The code is here : https://github.com/jbox-web/ajax-datatables-rails-sample-project
+
+
+## Installation
+
+Add these lines to your application's Gemfile:
-# or, support on datatables 1.9
-gem 'ajax-datatables-rails', git: 'git://github.com/antillas21/ajax-datatables-rails.git', branch: 'legacy'
+```ruby
+gem 'ajax-datatables-rails'
```
-If you have dataTables 1.10 in your project, then use the gem's latest version, or point to the `master` branch.
+And then execute:
+```sh
+$ bundle install
+```
+We assume here that you have already installed [jQuery DataTables](https://datatables.net/).
-## Description
+You can install jQuery DataTables :
-Datatables is a nifty jquery plugin that adds the ability to paginate, sort, and search your html tables. When dealing with large tables (more than a couple hundred rows) however, we run into performance issues. These can be fixed by using server-side pagination, but this breaks some datatables functionality.
+* with the [`jquery-datatables`](https://github.com/mkhairi/jquery-datatables) gem
+* by adding the assets manually (in `vendor/assets`)
+* with [Rails webpacker gem](https://github.com/rails/webpacker) (see [here](/doc/webpack.md) for more infos)
-`ajax-datatables-rails` is a wrapper around datatable's ajax methods that allow synchronization with server-side pagination in a rails app. It was inspired by this [Railscast](http://railscasts.com/episodes/340-datatables). I needed to implement a similar solution in a couple projects I was working on so I extracted it out into a gem.
-## ORM support
+## Note
Currently `AjaxDatatablesRails` only supports `ActiveRecord` as ORM for performing database queries.
-Adding support for `Sequel`, `Mongoid` and `MongoMapper` is a planned feature for this gem. If you'd be interested in contributing to speed development, please [open an issue](https://github.com/antillas21/ajax-datatables-rails/issues/new) and get in touch.
+Adding support for `Sequel`, `Mongoid` and `MongoMapper` is (more or less) a planned feature for this gem.
-## Installation
+If you'd be interested in contributing to speed development, please [open an issue](https://github.com/antillas21/ajax-datatables-rails/issues/new) and get in touch.
-Add these lines to your application's Gemfile:
- gem 'jquery-datatables-rails'
- gem 'ajax-datatables-rails'
+## Quick start (in 5 steps)
-And then execute:
+The following examples assume that we are setting up `ajax-datatables-rails` for an index page of users from a `User` model,
+and that we are using Postgresql as our db, because you **should be using it**. (It also works with other DB, [see above](#change-the-db-adapter-for-a-datatable-class))
- $ bundle
+The goal is to render a users table and display : `id`, `first name`, `last name`, `email`, and `bio` for each user.
-The `jquery-datatables-rails` gem is listed as a convenience, to ease adding
-jQuery dataTables to your Rails project. You can always add the plugin assets
-manually via the assets pipeline. If you decide to use the `jquery-datatables-rails` gem, please refer to its installation instructions [here](https://github.com/rweng/jquery-datatables-rails).
+Something like this:
-## Usage
-*The following examples assume that we are setting up ajax-datatables-rails for an index of users from a `User` model, and that we are using postgresql as
-our db, because you __should be using it__, if not, please refer to the [Searching on non text-based columns](#searching-on-non-text-based-columns) entry in the Additional Notes section.*
+|ID |First Name|Last Name|Email |Brief Bio|
+|---|----------|---------|----------------------|---------|
+| 1 |John |Doe |john.doe@example.net |Is your default user everywhere|
+| 2 |Jane |Doe |jane.doe@example.net |Is John's wife|
+| 3 |James |Doe |james.doe@example.net |Is John's brother and best friend|
-### Generate
-Run the following command:
+Here the steps we're going through :
+
+1. [Generate the datatable class](#1-generate-the-datatable-class)
+2. [Build the View](#2-build-the-view)
+3. [Customize the generated Datatables class](#3-customize-the-generated-datatables-class)
+4. [Setup the Controller action](#4-setup-the-controller-action)
+5. [Wire up the Javascript](#5-wire-up-the-javascript)
- $ rails generate datatable User
+### 1) Generate the datatable class
+Run the following command:
+
+```sh
+$ rails generate datatable User
+```
-This will generate a file named `user_datatable.rb` in `app/datatables`. Open the file and customize in the functions as directed by the comments.
+This will generate a file named `user_datatable.rb` in `app/datatables`.
+Open the file and customize in the functions as directed by the comments.
Take a look [here](#generator-syntax) for an explanation about the generator syntax.
-### Customize
-```ruby
-# uncomment the appropriate paginator module,
-# depending on gems available in your project.
-# include AjaxDatatablesRails::Extensions::Kaminari
-# include AjaxDatatablesRails::Extensions::WillPaginate
-# include AjaxDatatablesRails::Extensions::SimplePaginator
-
-def sortable_columns
- # list columns inside the Array in string dot notation.
- # Example: 'users.email'
- @sortable_columns ||= []
-end
-def searchable_columns
- # list columns inside the Array in string dot notation.
- # Example: 'users.email'
- @searchable_columns ||= []
-end
+### 2) Build the View
+
+You should always start by the single source of truth, which is your html view.
+
+* Set up an html `
` with a `` and ``
+* Add in your table headers if desired
+* Don't add any rows to the body of the table, DataTables does this automatically
+* Add a data attribute to the `` tag with the url of the JSON feed, in our case is the `users_path` as we're pointing to the `UsersController#index` action
+
+
+```html
+
+
+
+ | ID |
+ First Name |
+ Last Name |
+ Email |
+ Brief Bio |
+
+
+
+
+
```
-* For `paginator options`, just uncomment the paginator you would like to use, given
-the gems bundled in your project. For example, if your models are using `Kaminari`, uncomment `AjaxDatatablesRails::Extensions::Kaminari`. You may remove all commented lines.
- * `SimplePaginator` is the most basic of them all, it falls back to passing `offset` and `limit` at the database level (through `ActiveRecord` of course, as that is the only ORM supported for the time being).
-* For `sortable_columns`, assign an array of the database columns that correspond to the columns in our view table. For example `[users.f_name, users.l_name, users.bio]`. This array is used for sorting by various columns.
+### 3) Customize the generated Datatables class
-* For `searchable_columns`, assign an array of the database columns that you want searchable by datatables. For example `[users.f_name, users.l_name]`
+#### a. Declare columns mapping
+
+First we need to declare in `view_columns` the list of the model(s) columns mapped to the data we need to present.
+In this case: `id`, `first_name`, `last_name`, `email` and `bio`.
This gives us:
```ruby
-include AjaxDatatablesRails::Extensions::Kaminari
+def view_columns
+ @view_columns ||= {
+ id: { source: "User.id" },
+ first_name: { source: "User.first_name", cond: :like, searchable: true, orderable: true },
+ last_name: { source: "User.last_name", cond: :like, nulls_last: true },
+ email: { source: "User.email" },
+ bio: { source: "User.bio" },
+ }
+end
+```
+
+**Notes :** by default `orderable` and `searchable` are true and `cond` is `:like`.
+
+`cond` can be :
+
+* `:like`, `:start_with`, `:end_with`, `:string_eq`, `:string_in` for string or full text search
+* `:eq`, `:not_eq`, `:lt`, `:gt`, `:lteq`, `:gteq`, `:in` for numeric
+* `:date_range` for date range
+* `:null_value` for nil field
+* `Proc` for whatever (see [here](https://github.com/jbox-web/ajax-datatables-rails-sample-project/blob/master/app/datatables/city_datatable.rb) for real example)
+
+The `nulls_last` param allows for nulls to be ordered last. You can configure it by column, like above, or by datatable class :
+
+```ruby
+class MyDatatable < AjaxDatatablesRails::ActiveRecord
+ self.nulls_last = true
-def sortable_columns
- @sortable_columns ||= ['users.f_name', 'users.l_name', 'users.bio']
+ # ... other methods (view_columns, data...)
end
+```
-def searchable_columns
- @searchable_columns ||= ['users.f_name', 'users.l_name']
+See [here](#columns-syntax) to get more details about columns definitions and how to play with associated models.
+
+You can customize or sanitize the search value passed to the DB by using the `:formatter` option with a lambda :
+
+```ruby
+def view_columns
+ @view_columns ||= {
+ id: { source: "User.id" },
+ first_name: { source: "User.first_name" },
+ last_name: { source: "User.last_name" },
+ email: { source: "User.email", formatter: -> (o) { o.upcase } },
+ bio: { source: "User.bio" },
+ }
end
```
-[See here](#searching-on-non-text-based-columns) for notes regarding database config (if using something different from `postgre`).
+The object passed to the lambda is the search value.
+
+#### b. Map data
+
+Then we need to map the records retrieved by the `get_raw_records` method to the real values we want to display :
-### Map data
```ruby
def data
records.map do |record|
- [
- # comma separated list of the values for each cell of a table row
- # example: record.attribute,
- ]
+ {
+ id: record.id,
+ first_name: record.first_name,
+ last_name: record.last_name,
+ email: record.email,
+ bio: record.bio,
+ DT_RowId: record.id, # This will automagically set the id attribute on the corresponding in the datatable
+ }
end
end
```
-This method builds a 2d array that is used by datatables to construct the html table. Insert the values you want on each column.
+**Deprecated:** You can either use the v0.3 Array style for your columns :
+
+This method builds a 2d array that is used by datatables to construct the html
+table. Insert the values you want on each column.
```ruby
def data
records.map do |record|
[
- record.f_name,
- record.l_name,
+ record.id,
+ record.first_name,
+ record.last_name,
+ record.email,
record.bio
]
end
end
```
-[See here](#using-view-helpers) if you need to use view helpers in the returned 2d array, like `link_to`, `mail_to`, `resource_path`, etc.
+The drawback of this method is that you can't pass the `DT_RowId` so it's tricky to set the id attribute on the corresponding `
` in the datatable (need to be done on JS side).
-#### Get Raw Records
-```ruby
-def get_raw_records
- # insert query here
-end
-```
+[See here](#using-view-helpers) if you need to use view helpers like `link_to`, `mail_to`, etc...
+
+#### c. Get Raw Records
This is where your query goes.
@@ -151,256 +248,407 @@ def get_raw_records
end
```
-Obviously, you can construct your query as required for the use case the datatable is used. Example: `User.active.with_recent_messages`.
-__IMPORTANT:__ Make sure to return an `ActiveRecord::Relation` object as the end product of this method. Why? Because the result from
-this method, will be chained (for now) to `ActiveRecord` methods for sorting, filtering and pagination.
+Obviously, you can construct your query as required for the use case the datatable is used.
-#### Associated and nested models
-The previous example has only one single model. But what about if you have some associated nested models and in a report you want to show fields from these tables.
+Example:
-Take an example that has an `Event, Course, Coursetype, Allocation, Teacher, Contact, Competency and CompetencyType` models. We want to have a datatables report which has the following column:
```ruby
- 'coursetypes.name',
- 'courses.name',
- 'events.title',
- 'events.event_start',
- 'events.event_end',
- 'contacts.full_name',
- 'competency_types.name',
- 'events.status'
-```
-We want to sort and search on all columns of the list. The related definition would be:
-```ruby
-
- def sortable_columns
- @sortable_columns ||= [
- 'coursetypes.name',
- 'courses.name',
- 'events.title',
- 'events.event_start',
- 'events.event_end',
- 'contacts.last_name',
- 'competency_types.name',
- 'events.status'
- ]
- end
-
- def searchable_columns
- @searchable_columns ||= [
- 'coursetypes.name',
- 'courses.name',
- 'events.title',
- 'events.event_start',
- 'events.event_end',
- 'contacts.last_name',
- 'competency_types.name',
- 'events.status'
- ]
- end
-
- def get_raw_records
- Event.joins(
- { course: :coursetype },
- { allocations: {
- teacher: [:contact, {competencies: :competency_type}]
- }
- }).distinct
- end
+def get_raw_records
+ User.active.with_recent_messages
+end
```
-__Some comments for the above code:__
+You can put any logic in `get_raw_records` [based on any parameters you inject](#pass-options-to-the-datatable-class) in the `Datatable` object.
-1. In the list we show `full_name`, but in `sortable_columns` and `searchable_columns` we use `last_name` from the `Contact` model. The reason is we can use only database columns as sort or search fields and the full_name is not a database field.
+**IMPORTANT :** Because the result of this method will be chained to `ActiveRecord` methods for sorting, filtering and pagination,
+make sure to return an `ActiveRecord::Relation` object.
-2. In the `get_raw_records` method we have quite a complex query having one to many and may to many associations using the joins ActiveRecord method. The joins will generate INNER JOIN relations in the SQL query. In this case we do not include all event in the report if we have events which is not associated with any model record from the relation.
+#### d. Additional data
-3. To have all event records in the list we should use the `.includes` method, which generate LEFT OUTER JOIN relation of the SQL query. __IMPORTANT:__ Make sure to append `.references(:related_model)` with any associated model. That forces the eager loading of all the associated models by one SQL query, and the search condition for any column works fine. Otherwise the `:recordsFiltered => filter_records(get_raw_records).count(:all)` will generate 2 SQL queries (one for the Event model, and then another for the associated tables). The `:recordsFiltered => filter_records(get_raw_records).count(:all)` will use only the first one to return from the ActiveRecord::Relation object in `get_raw_records` and you will get an error message of __Unknown column 'yourtable.yourfield' in 'where clause'__ in case the search field value is not empty.
-
-So the query using the `.includes()` method is:
+You can inject other key/value pairs in the rendered JSON by defining the `#additional_data` method :
```ruby
- def get_raw_records
- Event.includes(
- { course: :coursetype },
- { allocations: {
- teacher: [:contact, { competencies: :competency_type }]
- }
- }
- ).references(:course).distinct
- end
+def additional_data
+ {
+ foo: 'bar'
+ }
+end
```
-### Controller
-Set up the controller to respond to JSON
+Very useful with [datatables-factory](https://github.com/jbox-web/datatables-factory) (or [yadcf](https://github.com/vedmack/yadcf)) to provide values for dropdown filters.
+
+
+### 4) Setup the Controller action
+
+Set the controller to respond to JSON
```ruby
def index
respond_to do |format|
format.html
- format.json { render json: UserDatatable.new(view_context) }
+ format.json { render json: UserDatatable.new(params) }
end
end
```
Don't forget to make sure the proper route has been added to `config/routes.rb`.
-### View
-* Set up an html `` with a `` and ``
-* Add in your table headers if desired
-* Don't add any rows to the body of the table, datatables does this automatically
-* Add a data attribute to the `` tag with the url of the JSON feed
+[See here](#pass-options-to-the-datatable-class) if you need to inject params in the `UserDatatable`.
-The resulting view may look like this:
+**Note :** If you have more than **2** datatables in your application, don't forget to read [this](#use-http-post-method-medium).
-```erb
-
-
-
- | First Name |
- Last Name |
- Brief Bio |
-
-
-
-
-
-```
+### 5) Wire up the Javascript
-### Javascript
-Finally, the javascript to tie this all together. In the appropriate `js.coffee` file:
+Finally, the javascript to tie this all together. In the appropriate `coffee` file:
```coffeescript
+# users.coffee
+
$ ->
- $('#users-table').dataTable
+ $('#users-datatable').dataTable
processing: true
serverSide: true
- ajax: $('#users-table').data('source')
+ ajax:
+ url: $('#users-datatable').data('source')
pagingType: 'full_numbers'
- # optional, if you want full pagination controls.
+ columns: [
+ {data: 'id'}
+ {data: 'first_name'}
+ {data: 'last_name'}
+ {data: 'email'}
+ {data: 'bio'}
+ ]
+ # pagingType is optional, if you want full pagination controls.
# Check dataTables documentation to learn more about
# available options.
```
or, if you're using plain javascript:
+
```javascript
// users.js
jQuery(document).ready(function() {
- $('#users-table').dataTable({
+ $('#users-datatable').dataTable({
"processing": true,
"serverSide": true,
- "ajax": $('#users-table').data('source'),
+ "ajax": {
+ "url": $('#users-datatable').data('source')
+ },
"pagingType": "full_numbers",
- // optional, if you want full pagination controls.
+ "columns": [
+ {"data": "id"},
+ {"data": "first_name"},
+ {"data": "last_name"},
+ {"data": "email"},
+ {"data": "bio"}
+ ]
+ // pagingType is optional, if you want full pagination controls.
// Check dataTables documentation to learn more about
// available options.
});
});
```
-### Additional Notes
+## Advanced usage
-#### Searching on non text-based columns
+### Using view helpers
-It always comes the time when you need to add a non-string/non-text based column to the `@searchable_columns` array, so you can perform searches against these column types (example: numeric, date, time).
+Sometimes you'll need to use view helper methods like `link_to`, `mail_to`,
+`edit_user_path`, `check_box_tag` and so on in the returned JSON representation returned by the [`data`](#b-map-data) method.
+
+To have these methods available to be used, this is the way to go:
-We recently added the ability to (automatically) typecast these column types and have this scenario covered. Please note however, if you are using something different from `postgre` (with the `:pg` gem), like `mysql` or `sqlite`, then you need to add an initializer in your application's `config/initializers` directory.
+```ruby
+class UserDatatable < AjaxDatatablesRails::ActiveRecord
+ extend Forwardable
-If you don't perform this step (again, if using something different from `postgre`), your database will complain that it does not understand the default typecast used to enable such searches.
+ # either define them one-by-one
+ def_delegator :@view, :check_box_tag
+ def_delegator :@view, :link_to
+ def_delegator :@view, :mail_to
+ def_delegator :@view, :edit_user_path
-You have two options to create this initializer:
+ # or define them in one pass
+ def_delegators :@view, :check_box_tag, :link_to, :mail_to, :edit_user_path
-* use the provided (and recommended) generator (and then just edit the file);
-* create the file from scratch.
+ # ... other methods (view_columns, get_raw_records...)
-To use the generator, from the terminal execute:
+ def initialize(params, opts = {})
+ @view = opts[:view_context]
+ super
+ end
-```
-$ bundle exec rails generate datatable:config
+ # now, you'll have these methods available to be used anywhere
+ def data
+ records.map do |record|
+ {
+ id: check_box_tag('users[]', record.id),
+ first_name: link_to(record.first_name, edit_user_path(record)),
+ last_name: record.last_name,
+ email: mail_to(record.email),
+ bio: record.bio
+ DT_RowId: record.id,
+ }
+ end
+ end
+end
+
+# and in your controller:
+def index
+ respond_to do |format|
+ format.html
+ format.json { render json: UserDatatable.new(params, view_context: view_context) }
+ end
+end
```
-Doing so, will create the `config/initializers/ajax_datatables_rails.rb` file with the following content:
+### Using view decorators
+
+If you want to keep things tidy in the data mapping method, you could use
+[Draper](https://github.com/drapergem/draper) to define column mappings like below.
+
+**Note :** This is the recommanded way as you don't need to inject the `view_context` in the Datatable object to access helpers methods.
+It also helps in separating view/presentation logic from filtering logic (the only one that really matters in a datatable class).
+
+Example :
```ruby
-AjaxDatatablesRails.configure do |config|
- # available options for db_adapter are: :pg, :mysql2, :sqlite3
- # config.db_adapter = :pg
+class UserDatatable < AjaxDatatablesRails::ActiveRecord
+ ...
+ def data
+ records.map do |record|
+ {
+ id: record.decorate.check_box,
+ first_name: record.decorate.link_to,
+ last_name: record.decorate.last_name
+ email: record.decorate.email,
+ bio: record.decorate.bio
+ DT_RowId: record.id,
+ }
+ end
+ end
+ ...
+end
+
+class UserDecorator < ApplicationDecorator
+ delegate :last_name, :bio
+
+ def check_box
+ h.check_box_tag 'users[]', object.id
+ end
+
+ def link_to
+ h.link_to object.first_name, h.edit_user_path(object)
+ end
+
+ def email
+ h.mail_to object.email
+ end
+
+ # Just an example of a complex method you can add to you decorator
+ # To render it in a datatable just add a column 'dt_actions' in
+ # 'view_columns' and 'data' methods and call record.decorate.dt_actions
+ def dt_actions
+ links = []
+ links << h.link_to 'Edit', h.edit_user_path(object) if h.policy(object).update?
+ links << h.link_to 'Delete', h.user_path(object), method: :delete, remote: true if h.policy(object).destroy?
+ h.safe_join(links, '')
+ end
end
```
-Uncomment the `config.db_adapter` line and set the corresponding value to your
-database and gem. This is all you need.
+### Pass options to the datatable class
+
+An `AjaxDatatablesRails::ActiveRecord` inherited class can accept an options hash at initialization. This provides room for flexibility when required.
+
+Example:
-If you want to make the file from scratch, just copy the above code block into a file inside the `config/initializers` directory.
+```ruby
+# In the controller
+def index
+ respond_to do |format|
+ format.html
+ format.json { render json: UserDatatable.new(params, user: current_user, from: 1.month.ago) }
+ end
+end
+# The datatable class
+class UnrespondedMessagesDatatable < AjaxDatatablesRails::ActiveRecord
-#### Using view helpers
+ # ... other methods (view_columns, data...)
-Sometimes you'll need to use view helper methods like `link_to`, `h`, `mailto`, `edit_resource_path` in the returned JSON representation returned by the `data` method.
+ def user
+ @user ||= options[:user]
+ end
-To have these methods available to be used, this is the way to go:
+ def from
+ @from ||= options[:from].beginning_of_day
+ end
+
+ def to
+ @to ||= Date.today.end_of_day
+ end
+
+ # We can now customize the get_raw_records method
+ # with the options we've injected
+ def get_raw_records
+ user.messages.unresponded.where(received_at: from..to)
+ end
+
+end
+```
+
+### Change the DB adapter for a datatable class
+
+If you have models from different databases you can set the `db_adapter` on the datatable class :
```ruby
-class MyCustomDatatable < AjaxDatatablesRails::Base
- # either define them one-by-one
- def_delegator :@view, :link_to
- def_delegator :@view, :h
- def_delegator :@view, :mail_to
+class MySharedModelDatatable < AjaxDatatablesRails::ActiveRecord
+ self.db_adapter = :oracle_enhanced
- # or define them in one pass
- def_delegators :@view, :link_to, :h, :mailto, :edit_resource_path, :other_method
+ # ... other methods (view_columns, data...)
- # now, you'll have these methods available to be used anywhere
- # example: mapping the 2d jsonified array returned.
- def data
- records.map do |record|
- [
- link_to(record.fname, edit_resource_path(record)),
- mail_to(record.email),
- # other attributes
- ]
+ def get_raw_records
+ AnimalsRecord.connected_to(role: :reading) do
+ Dog.all
end
end
end
```
-#### Options
+### Columns syntax
+
+You can mix several model in the same datatable.
-An `AjaxDatatablesRails::Base` inherited class can accept an options hash at initialization. This provides room for flexibility when required. Example:
+Suppose we have the following models: `User`, `PurchaseOrder`,
+`Purchase::LineItem` and we need to have several columns from those models
+available in our datatable to search and sort by.
```ruby
-class UnrespondedMessagesDatatable < AjaxDatatablesRails::Base
- # customized methods here
+# we use the ModelName.column_name notation to declare our columns
+
+def view_columns
+ @view_columns ||= {
+ first_name: { source: 'User.first_name' },
+ last_name: { source: 'User.last_name' },
+ order_number: { source: 'PurchaseOrder.number' },
+ order_created_at: { source: 'PurchaseOrder.created_at' },
+ quantity: { source: 'Purchase::LineItem.quantity' },
+ unit_price: { source: 'Purchase::LineItem.unit_price' },
+ item_total: { source: 'Purchase::LineItem.item_total }'
+ }
end
+```
+
+### Associated and nested models
+
+The previous example has only one single model. But what about if you have
+some associated nested models and in a report you want to show fields from
+these tables.
+
+Take an example that has an `Event, Course, CourseType, Allocation, Teacher,
+Contact, Competency and CompetencyType` models. We want to have a datatables
+report which has the following column:
-datatable = UnrespondedMessagesDatatable.new(view_context,
- { :foo => { :bar => Baz.new }, :from => 1.month.ago }
-)
+```ruby
+'course_types.name'
+'courses.name'
+'contacts.full_name'
+'competency_types.name'
+'events.title'
+'events.event_start'
+'events.event_end'
+'events.status'
```
-So, now inside your class code, you can use those options like this:
+We want to sort and search on all columns of the list.
+The related definition would be :
```ruby
-# let's see an example
-def from
- @from ||= options[:from].beginning_of_day
+def view_columns
+ @view_columns ||= {
+ course_type: { source: 'CourseType.name' },
+ course_name: { source: 'Course.name' },
+ contact_name: { source: 'Contact.full_name' },
+ competency_type: { source: 'CompetencyType.name' },
+ event_title: { source: 'Event.title' },
+ event_start: { source: 'Event.event_start' },
+ event_end: { source: 'Event.event_end' },
+ event_status: { source: 'Event.status' },
+ }
end
-def to
- @to ||= Date.today.end_of_day
+def get_raw_records
+ Event.joins(
+ { course: :course_type },
+ { allocations: {
+ teacher: [:contact, { competencies: :competency_type }]
+ }
+ }).distinct
end
+```
+
+**Some comments for the above code :**
+
+1. In the `get_raw_records` method we have quite a complex query having one to
+many and many to many associations using the joins ActiveRecord method.
+The joins will generate INNER JOIN relations in the SQL query. In this case,
+we do not include all event in the report if we have events which is not
+associated with any model record from the relation.
+
+2. To have all event records in the list we should use the `.includes` method,
+which generate LEFT OUTER JOIN relation of the SQL query.
+
+**IMPORTANT :**
+
+Make sure to append `.references(:related_model)` with any
+associated model. That forces the eager loading of all the associated models
+by one SQL query, and the search condition for any column works fine.
+Otherwise the `:recordsFiltered => filter_records(get_raw_records).count(:all)`
+will generate 2 SQL queries (one for the Event model, and then another for the
+associated tables). The `:recordsFiltered => filter_records(get_raw_records).count(:all)`
+will use only the first one to return from the ActiveRecord::Relation object
+in `get_raw_records` and you will get an error message of **Unknown column
+'yourtable.yourfield' in 'where clause'** in case the search field value
+is not empty.
+So the query using the `.includes()` method is:
+
+```ruby
def get_raw_records
- Message.unresponded.where(received_at: from..to)
+ Event.includes(
+ { course: :course_type },
+ { allocations: {
+ teacher: [:contact, { competencies: :competency_type }]
+ }
+ }).references(:course).distinct
end
```
-#### Generator Syntax
+### Default scope
-Also, a class that inherits from `AjaxDatatablesRails::Base` is not tied to an existing model, module, constant or any type of class in your Rails app. You can pass a name to your datatable class like this:
+See [DefaultScope is evil](https://rails-bestpractices.com/posts/2013/06/15/default_scope-is-evil/) and [#223](https://github.com/jbox-web/ajax-datatables-rails/issues/223) and [#233](https://github.com/jbox-web/ajax-datatables-rails/issues/233).
+### DateRange search
-```
+This feature works with [datatables-factory](https://github.com/jbox-web/datatables-factory) (or [yadcf](https://github.com/vedmack/yadcf)).
+
+To enable the date range search, for example `created_at` :
+
+* add a `created_at` `` in your html
+* declare your column in `view_columns` : `created_at: { source: 'Post.created_at', cond: :date_range, delimiter: '-yadcf_delim-' }`
+* add it in `data` : `created_at: record.decorate.created_at`
+* setup yadcf to make `created_at` search field a range
+
+### Generator Syntax
+
+Also, a class that inherits from `AjaxDatatablesRails::ActiveRecord` is not tied to an
+existing model, module, constant or any type of class in your Rails app.
+You can pass a name to your datatable class like this:
+
+
+```sh
$ rails generate datatable users
# returns a users_datatable.rb file with a UsersDatatable class
@@ -411,19 +659,160 @@ $ rails generate datatable UnrespondedMessages
# returns an unresponded_messages_datatable.rb file with an UnrespondedMessagesDatatable class
```
+In the end, it's up to the developer which model(s), scope(s), relationship(s)
+(or else) to employ inside the datatable class to retrieve records from the
+database.
-In the end, it's up to the developer which model(s), scope(s), relationship(s) (or else) to employ inside the datatable class to retrieve records from the database.
+## Tests
-## Tutorial
+Datatables can be tested with Capybara provided you don't use Webrick during integration tests.
+
+Long story short and as a rule of thumb : use the same webserver everywhere (dev, prod, staging, test, etc...).
+
+If you use Puma (the Rails default webserver), use Puma everywhere, even in CI/test environment. The same goes for Thin.
+
+You will avoid the usual story : it works in dev but not in test environment...
+
+If you want to test datatables with a lot of data you might need this kind of tricks : https://robots.thoughtbot.com/automatically-wait-for-ajax-with-capybara. (thanks CharlieIGG)
+
+## ProTips™
+
+### Create a master parent class (Easy)
+
+In the same spirit of Rails `ApplicationController` and `ApplicationRecord`, you can create an `ApplicationDatatable` class (in `app/datatables/application_datatable.rb`)
+that will be inherited from other classes :
+
+```ruby
+class ApplicationDatatable < AjaxDatatablesRails::ActiveRecord
+ # puts commonly used methods here
+end
+
+class PostDatatable < ApplicationDatatable
+end
+```
+
+This way it will be easier to DRY you datatables.
+
+### Speedup JSON rendering (Easy)
+
+Install [yajl-ruby](https://github.com/brianmario/yajl-ruby), basically :
+
+```ruby
+gem 'yajl-ruby', require: 'yajl'
+```
+
+then
+
+```sh
+$ bundle install
+```
+
+That's all :) ([Automatically prefer Yajl or JSON backend over Yaml, if available](https://github.com/rails/rails/commit/63bb955a99eb46e257655c93dd64e86ebbf05651))
+
+### Use HTTP `POST` method (Medium)
+
+Use HTTP `POST` method to avoid `414 Request-URI Too Large` error. See : [#278](https://github.com/jbox-web/ajax-datatables-rails/issues/278) and [#308](https://github.com/jbox-web/ajax-datatables-rails/issues/308#issuecomment-424897335).
+
+You can easily define a route concern in `config/routes.rb` and reuse it when you need it :
+
+```ruby
+Rails.application.routes.draw do
+ concern :with_datatable do
+ post 'datatable', on: :collection
+ end
-Tutorial for Integrating `ajax-datatable-rails`, on Rails 4 .
+ resources :posts, concerns: [:with_datatable]
+ resources :users, concerns: [:with_datatable]
+end
+```
+
+then in your controllers :
+
+```ruby
+# PostsController
+ def index
+ end
+
+ def datatable
+ render json: PostDatatable.new(params)
+ end
+
+# UsersController
+ def index
+ end
+
+ def datatable
+ render json: UserDatatable.new(params)
+ end
+```
+
+then in your views :
+
+```html
+# posts/index.html.erb
+
+
+# users/index.html.erb
+
+```
+
+then in your Coffee/JS :
+
+```coffee
+# send params in form data
+$ ->
+ $('#posts-datatable').dataTable
+ ajax:
+ url: $('#posts-datatable').data('source')
+ type: 'POST'
+ # ...others options, see [here](#5-wire-up-the-javascript)
+
+# send params as json data
+$ ->
+ $('#users-datatable').dataTable
+ ajax:
+ url: $('#users-datatable').data('source')
+ contentType: 'application/json'
+ type: 'POST'
+ data: (d) ->
+ JSON.stringify d
+ # ...others options, see [here](#5-wire-up-the-javascript)
+```
+
+### Create indices for Postgresql (Expert)
-[Part-1 The-Installation](https://github.com/antillas21/ajax-datatables-rails/wiki/Part-1----The-Installation)
+In order to speed up the `ILIKE` queries that are executed when using the default configuration, you might want to consider adding some indices.
+For postgresql, you are advised to use the [gin/gist index type](http://www.postgresql.org/docs/current/interactive/pgtrgm.html).
+This makes it necessary to enable the postgrsql extension `pg_trgm`. Double check that you have this extension installed before trying to enable it.
+A migration for enabling the extension and creating the indices could look like this:
+
+```ruby
+def change
+ enable_extension :pg_trgm
+ TEXT_SEARCH_ATTRIBUTES = ['your', 'attributes']
+ TABLE = 'your_table'
+
+ TEXT_SEARCH_ATTRIBUTES.each do |attr|
+ reversible do |dir|
+ dir.up do
+ execute "CREATE INDEX #{TABLE}_#{attr}_gin ON #{TABLE} USING gin(#{attr} gin_trgm_ops)"
+ end
+
+ dir.down do
+ remove_index TABLE.to_sym, name: "#{TABLE}_#{attr}_gin"
+ end
+ end
+ end
+end
+```
+
+## Tutorial
-[Part 2 The Datatables with ajax functionality](https://github.com/antillas21/ajax-datatables-rails/wiki/Part-2-The-Datatables-with-ajax-functionality)
+Filtering by JSONB column values : [#277](https://github.com/jbox-web/ajax-datatables-rails/issues/277#issuecomment-366526373)
-The complete project code for this tutorial series is available on [github](https://github.com/trkrameshkumar/simple_app).
+Use [has_scope](https://github.com/plataformatec/has_scope) gem with `ajax-datatables-rails` : [#280](https://github.com/jbox-web/ajax-datatables-rails/issues/280)
+Use [Datatable orthogonal data](https://datatables.net/manual/data/orthogonal-data) : see [#269](https://github.com/jbox-web/ajax-datatables-rails/issues/269#issuecomment-387940478)
## Contributing
diff --git a/Rakefile b/Rakefile
index a819872b..ad26ae7d 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,14 +1,7 @@
-#!/usr/bin/env rake
+# frozen_string_literal: true
+
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)
-task :default => :spec
-
-task :console do
- require 'pry'
- require 'rails'
- require 'ajax-datatables-rails'
- ARGV.clear
- Pry.start
-end
+task default: :spec
diff --git a/ajax-datatables-rails.gemspec b/ajax-datatables-rails.gemspec
index 26cf8b82..69e22ea8 100644
--- a/ajax-datatables-rails.gemspec
+++ b/ajax-datatables-rails.gemspec
@@ -1,33 +1,29 @@
-# -*- encoding: utf-8 -*-
-lib = File.expand_path('../lib', __FILE__)
-$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
-require 'ajax-datatables-rails/version'
+# frozen_string_literal: true
-Gem::Specification.new do |gem|
- gem.name = "ajax-datatables-rails"
- gem.version = AjaxDatatablesRails::VERSION
- gem.authors = ["Joel Quenneville"]
- gem.email = ["joel.quenneville@collegeplus.org"]
- gem.description = %q{A gem that simplifies using datatables and hundreds of records via ajax}
- gem.summary = %q{A wrapper around datatable's ajax methods that allow synchronization with server-side pagination in a rails app}
- gem.homepage = ""
- gem.required_ruby_version = Gem::Requirement.new(">= 1.9.2")
+require_relative 'lib/ajax-datatables-rails/version'
- gem.files = Dir["{lib,spec}/**/*", "[A-Z]*"] - ["Gemfile.lock"]
- gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) }
- gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
- gem.require_path = "lib"
+Gem::Specification.new do |s|
+ s.name = 'ajax-datatables-rails'
+ s.version = AjaxDatatablesRails::VERSION::STRING
+ s.platform = Gem::Platform::RUBY
+ s.authors = ['Joel Quenneville', 'Antonio Antillon']
+ s.email = ['joel.quenneville@collegeplus.org', 'antillas21@gmail.com']
+ s.homepage = 'https://github.com/jbox-web/ajax-datatables-rails'
+ s.summary = 'A gem that simplifies using datatables and hundreds of records via ajax'
+ s.description = "A wrapper around datatable's ajax methods that allow synchronization with server-side pagination in a rails app"
+ s.license = 'MIT'
+ s.metadata = {
+ 'homepage_uri' => 'https://github.com/jbox-web/ajax-datatables-rails',
+ 'changelog_uri' => 'https://github.com/jbox-web/ajax-datatables-rails/blob/master/CHANGELOG.md',
+ 'source_code_uri' => 'https://github.com/jbox-web/ajax-datatables-rails',
+ 'bug_tracker_uri' => 'https://github.com/jbox-web/ajax-datatables-rails/issues',
+ 'rubygems_mfa_required' => 'true',
+ }
- gem.add_dependency 'railties', '>= 3.1'
-
- gem.add_development_dependency "rspec"
- gem.add_development_dependency "generator_spec"
- gem.add_development_dependency "pry"
- gem.add_development_dependency "rake"
-
- if RUBY_VERSION == '1.9.2'
- gem.add_development_dependency "rails", "3.1.0"
- else
- gem.add_development_dependency "rails", ">= 3.1.0"
- end
+ s.required_ruby_version = '>= 3.1.0'
+
+ s.files = Dir['README.md', 'CHANGELOG.md', 'LICENSE', 'lib/**/*.rb', 'lib/**/*.erb']
+
+ s.add_dependency 'rails', '>= 7.1'
+ s.add_dependency 'zeitwerk'
end
diff --git a/bin/_guard-core b/bin/_guard-core
new file mode 100755
index 00000000..9105b28b
--- /dev/null
+++ b/bin/_guard-core
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application '_guard-core' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("guard", "_guard-core")
diff --git a/bin/appraisal b/bin/appraisal
new file mode 100755
index 00000000..5038ce52
--- /dev/null
+++ b/bin/appraisal
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'appraisal' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("appraisal", "appraisal")
diff --git a/bin/bundle b/bin/bundle
new file mode 100755
index 00000000..50da5fdf
--- /dev/null
+++ b/bin/bundle
@@ -0,0 +1,109 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'bundle' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+require "rubygems"
+
+m = Module.new do
+ module_function
+
+ def invoked_as_script?
+ File.expand_path($0) == File.expand_path(__FILE__)
+ end
+
+ def env_var_version
+ ENV["BUNDLER_VERSION"]
+ end
+
+ def cli_arg_version
+ return unless invoked_as_script? # don't want to hijack other binstubs
+ return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
+ bundler_version = nil
+ update_index = nil
+ ARGV.each_with_index do |a, i|
+ if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN)
+ bundler_version = a
+ end
+ next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
+ bundler_version = $1
+ update_index = i
+ end
+ bundler_version
+ end
+
+ def gemfile
+ gemfile = ENV["BUNDLE_GEMFILE"]
+ return gemfile if gemfile && !gemfile.empty?
+
+ File.expand_path("../Gemfile", __dir__)
+ end
+
+ def lockfile
+ lockfile =
+ case File.basename(gemfile)
+ when "gems.rb" then gemfile.sub(/\.rb$/, ".locked")
+ else "#{gemfile}.lock"
+ end
+ File.expand_path(lockfile)
+ end
+
+ def lockfile_version
+ return unless File.file?(lockfile)
+ lockfile_contents = File.read(lockfile)
+ return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
+ Regexp.last_match(1)
+ end
+
+ def bundler_requirement
+ @bundler_requirement ||=
+ env_var_version ||
+ cli_arg_version ||
+ bundler_requirement_for(lockfile_version)
+ end
+
+ def bundler_requirement_for(version)
+ return "#{Gem::Requirement.default}.a" unless version
+
+ bundler_gem_version = Gem::Version.new(version)
+
+ bundler_gem_version.approximate_recommendation
+ end
+
+ def load_bundler!
+ ENV["BUNDLE_GEMFILE"] ||= gemfile
+
+ activate_bundler
+ end
+
+ def activate_bundler
+ gem_error = activation_error_handling do
+ gem "bundler", bundler_requirement
+ end
+ return if gem_error.nil?
+ require_error = activation_error_handling do
+ require "bundler/version"
+ end
+ return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
+ warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
+ exit 42
+ end
+
+ def activation_error_handling
+ yield
+ nil
+ rescue StandardError, LoadError => e
+ e
+ end
+end
+
+m.load_bundler!
+
+if m.invoked_as_script?
+ load Gem.bin_path("bundler", "bundle")
+end
diff --git a/bin/guard b/bin/guard
new file mode 100755
index 00000000..ff444e0c
--- /dev/null
+++ b/bin/guard
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'guard' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("guard", "guard")
diff --git a/bin/rackup b/bin/rackup
new file mode 100755
index 00000000..6408c791
--- /dev/null
+++ b/bin/rackup
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'rackup' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("rackup", "rackup")
diff --git a/bin/rake b/bin/rake
new file mode 100755
index 00000000..4eb7d7bf
--- /dev/null
+++ b/bin/rake
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'rake' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("rake", "rake")
diff --git a/bin/rspec b/bin/rspec
new file mode 100755
index 00000000..cb53ebe5
--- /dev/null
+++ b/bin/rspec
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'rspec' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("rspec-core", "rspec")
diff --git a/bin/rubocop b/bin/rubocop
new file mode 100755
index 00000000..369a05be
--- /dev/null
+++ b/bin/rubocop
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'rubocop' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("rubocop", "rubocop")
diff --git a/ci/network/admin/tnsnames.ora b/ci/network/admin/tnsnames.ora
new file mode 100644
index 00000000..d1ba8183
--- /dev/null
+++ b/ci/network/admin/tnsnames.ora
@@ -0,0 +1,15 @@
+FREEPDB1 =
+ (DESCRIPTION =
+ (ADDRESS = (PROTOCOL = TCP)(HOST = 127.0.0.1)(PORT = 1521))
+ (CONNECT_DATA =
+ (SERVICE_NAME = FREEPDB1)
+ )
+ )
+
+XE =
+ (DESCRIPTION =
+ (ADDRESS = (PROTOCOL = TCP)(HOST = 127.0.0.1)(PORT = 1521))
+ (CONNECT_DATA =
+ (SERVICE_NAME = XE)
+ )
+ )
diff --git a/ci/setup_accounts.sh b/ci/setup_accounts.sh
new file mode 100755
index 00000000..3ed4d2ff
--- /dev/null
+++ b/ci/setup_accounts.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+set -ev
+
+sqlplus sys/${DATABASE_SYS_PASSWORD}@${DATABASE_NAME} as sysdba< 7.1.0"
+gem "mysql2"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.1_with_oracle_enhanced.gemfile b/gemfiles/rails_7.1_with_oracle_enhanced.gemfile
new file mode 100644
index 00000000..20e36feb
--- /dev/null
+++ b/gemfiles/rails_7.1_with_oracle_enhanced.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.1.0"
+gem "activerecord-oracle_enhanced-adapter", "~> 7.1.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.1_with_postgis.gemfile b/gemfiles/rails_7.1_with_postgis.gemfile
new file mode 100644
index 00000000..699d0c5e
--- /dev/null
+++ b/gemfiles/rails_7.1_with_postgis.gemfile
@@ -0,0 +1,26 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "pg"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.1.0"
+gem "activerecord-postgis-adapter", "~> 9.0.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.1_with_postgresql.gemfile b/gemfiles/rails_7.1_with_postgresql.gemfile
new file mode 100644
index 00000000..6dbde4a8
--- /dev/null
+++ b/gemfiles/rails_7.1_with_postgresql.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "pg"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.1.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.1_with_sqlite3.gemfile b/gemfiles/rails_7.1_with_sqlite3.gemfile
new file mode 100644
index 00000000..7641ede3
--- /dev/null
+++ b/gemfiles/rails_7.1_with_sqlite3.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.1.0"
+gem "sqlite3", "~> 1.5.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.1_with_trilogy.gemfile b/gemfiles/rails_7.1_with_trilogy.gemfile
new file mode 100644
index 00000000..a62a7c41
--- /dev/null
+++ b/gemfiles/rails_7.1_with_trilogy.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.1.0"
+gem "activerecord-trilogy-adapter"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.2_with_mysql2.gemfile b/gemfiles/rails_7.2_with_mysql2.gemfile
new file mode 100644
index 00000000..6b4fa44d
--- /dev/null
+++ b/gemfiles/rails_7.2_with_mysql2.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.2.0"
+gem "mysql2"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.2_with_oracle_enhanced.gemfile b/gemfiles/rails_7.2_with_oracle_enhanced.gemfile
new file mode 100644
index 00000000..c498dab6
--- /dev/null
+++ b/gemfiles/rails_7.2_with_oracle_enhanced.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.2.0"
+gem "activerecord-oracle_enhanced-adapter", "~> 7.2.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.2_with_postgis.gemfile b/gemfiles/rails_7.2_with_postgis.gemfile
new file mode 100644
index 00000000..6a62fe31
--- /dev/null
+++ b/gemfiles/rails_7.2_with_postgis.gemfile
@@ -0,0 +1,26 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "pg"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.2.0"
+gem "activerecord-postgis-adapter", "~> 10.0.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.2_with_postgresql.gemfile b/gemfiles/rails_7.2_with_postgresql.gemfile
new file mode 100644
index 00000000..6c118407
--- /dev/null
+++ b/gemfiles/rails_7.2_with_postgresql.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "pg"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.2.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.2_with_sqlite3.gemfile b/gemfiles/rails_7.2_with_sqlite3.gemfile
new file mode 100644
index 00000000..8ee69bab
--- /dev/null
+++ b/gemfiles/rails_7.2_with_sqlite3.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.2.0"
+gem "sqlite3", "~> 1.5.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.2_with_trilogy.gemfile b/gemfiles/rails_7.2_with_trilogy.gemfile
new file mode 100644
index 00000000..cb3631c2
--- /dev/null
+++ b/gemfiles/rails_7.2_with_trilogy.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 7.2.0"
+gem "activerecord-trilogy-adapter"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8.0_with_mysql2.gemfile b/gemfiles/rails_8.0_with_mysql2.gemfile
new file mode 100644
index 00000000..765f4597
--- /dev/null
+++ b/gemfiles/rails_8.0_with_mysql2.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 8.0.0"
+gem "mysql2"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8.0_with_oracle_enhanced.gemfile b/gemfiles/rails_8.0_with_oracle_enhanced.gemfile
new file mode 100644
index 00000000..e7e3681d
--- /dev/null
+++ b/gemfiles/rails_8.0_with_oracle_enhanced.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 8.0.0"
+gem "activerecord-oracle_enhanced-adapter", "~> 8.0.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8.0_with_postgis.gemfile b/gemfiles/rails_8.0_with_postgis.gemfile
new file mode 100644
index 00000000..e0371b93
--- /dev/null
+++ b/gemfiles/rails_8.0_with_postgis.gemfile
@@ -0,0 +1,26 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "pg"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 8.0.0"
+gem "activerecord-postgis-adapter", "~> 11.0.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8.0_with_postgresql.gemfile b/gemfiles/rails_8.0_with_postgresql.gemfile
new file mode 100644
index 00000000..37cf0343
--- /dev/null
+++ b/gemfiles/rails_8.0_with_postgresql.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "pg"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 8.0.0"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8.0_with_sqlite3.gemfile b/gemfiles/rails_8.0_with_sqlite3.gemfile
new file mode 100644
index 00000000..cdc0a53b
--- /dev/null
+++ b/gemfiles/rails_8.0_with_sqlite3.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 8.0.0"
+gem "sqlite3"
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8.0_with_trilogy.gemfile b/gemfiles/rails_8.0_with_trilogy.gemfile
new file mode 100644
index 00000000..c114794e
--- /dev/null
+++ b/gemfiles/rails_8.0_with_trilogy.gemfile
@@ -0,0 +1,25 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org/"
+
+gem "appraisal", git: "https://github.com/thoughtbot/appraisal.git"
+gem "combustion"
+gem "database_cleaner"
+gem "factory_bot"
+gem "faker"
+gem "generator_spec"
+gem "puma"
+gem "rake"
+gem "rspec"
+gem "rspec-rebound"
+gem "simplecov"
+gem "guard-rspec", require: false
+gem "rubocop", require: false
+gem "rubocop-factory_bot", require: false
+gem "rubocop-performance", require: false
+gem "rubocop-rake", require: false
+gem "rubocop-rspec", require: false
+gem "rails", "~> 8.0.0"
+gem "activerecord-trilogy-adapter"
+
+gemspec path: "../"
diff --git a/lib/ajax-datatables-rails.rb b/lib/ajax-datatables-rails.rb
index f3e90e9a..cb9f515f 100644
--- a/lib/ajax-datatables-rails.rb
+++ b/lib/ajax-datatables-rails.rb
@@ -1,9 +1,17 @@
-require 'ajax-datatables-rails/version'
-require 'ajax-datatables-rails/config'
-require 'ajax-datatables-rails/base'
-require 'ajax-datatables-rails/extensions/simple_paginator'
-require 'ajax-datatables-rails/extensions/kaminari'
-require 'ajax-datatables-rails/extensions/will_paginate'
+# frozen_string_literal: true
+
+# require external dependencies
+require 'zeitwerk'
+
+# load zeitwerk
+Zeitwerk::Loader.for_gem.tap do |loader|
+ loader.ignore("#{__dir__}/generators")
+ loader.inflector.inflect(
+ 'orm' => 'ORM',
+ 'ajax-datatables-rails' => 'AjaxDatatablesRails'
+ )
+ loader.setup
+end
module AjaxDatatablesRails
end
diff --git a/lib/ajax-datatables-rails/active_record.rb b/lib/ajax-datatables-rails/active_record.rb
new file mode 100644
index 00000000..2f062e07
--- /dev/null
+++ b/lib/ajax-datatables-rails/active_record.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ class ActiveRecord < AjaxDatatablesRails::Base
+ include AjaxDatatablesRails::ORM::ActiveRecord
+ end
+end
diff --git a/lib/ajax-datatables-rails/base.rb b/lib/ajax-datatables-rails/base.rb
index 147d17a2..4d52feee 100644
--- a/lib/ajax-datatables-rails/base.rb
+++ b/lib/ajax-datatables-rails/base.rb
@@ -1,150 +1,161 @@
-module AjaxDatatablesRails
- class Base
- extend Forwardable
- class MethodNotImplementedError < StandardError; end
+# frozen_string_literal: true
- attr_reader :view, :options, :sortable_columns, :searchable_columns
- def_delegator :@view, :params, :params
+module AjaxDatatablesRails
+ class Base # rubocop:disable Metrics/ClassLength
- def initialize(view, options = {})
- @view = view
- @options = options
+ class << self
+ def default_db_adapter
+ ::ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).first.adapter.downcase.to_sym
+ end
end
- def config
- @config ||= AjaxDatatablesRails.config
- end
+ class_attribute :db_adapter, default: default_db_adapter
+ class_attribute :nulls_last, default: false
- def sortable_columns
- @sortable_columns ||= []
- end
+ attr_reader :params, :options, :datatable
- def searchable_columns
- @searchable_columns ||= []
- end
+ GLOBAL_SEARCH_DELIMITER = ' '
- def data
- fail(
- MethodNotImplementedError,
- 'Please implement this method in your class.'
- )
+ def initialize(params, options = {})
+ @params = params
+ @options = options
+ @datatable = Datatable::Datatable.new(self)
+
+ @view_columns = nil
+ @connected_columns = nil
+ @searchable_columns = nil
+ @search_columns = nil
+ @records = nil
+ @build_conditions = nil
end
- def get_raw_records
- fail(
- MethodNotImplementedError,
- 'Please implement this method in your class.'
- )
+ # User defined methods
+ def view_columns
+ raise(NotImplementedError, <<~ERROR)
+
+ You should implement this method in your class and return an array
+ of database columns based on the columns displayed in the HTML view.
+ These columns should be represented in the ModelName.column_name,
+ or aliased_join_table.column_name notation.
+ ERROR
end
- def as_json(options = {})
- {
- :draw => params[:draw].to_i,
- :recordsTotal => get_raw_records.count(:all),
- :recordsFiltered => filter_records(get_raw_records).count(:all),
- :data => data
- }
+ def get_raw_records # rubocop:disable Naming/AccessorMethodName
+ raise(NotImplementedError, <<~ERROR)
+
+ You should implement this method in your class and specify
+ how records are going to be retrieved from the database.
+ ERROR
end
- private
+ def data
+ raise(NotImplementedError, <<~ERROR)
- def records
- @records ||= fetch_records
+ You should implement this method in your class and return an array
+ of arrays, or an array of hashes, as defined in the jQuery.dataTables
+ plugin documentation.
+ ERROR
end
+ # ORM defined methods
def fetch_records
- records = get_raw_records
- records = sort_records(records) if params[:order].present?
- records = filter_records(records) if params[:search].present?
- records = paginate_records(records) unless params[:length].present? && params[:length] == '-1'
- records
+ get_raw_records
+ end
+
+ def filter_records(records)
+ raise(NotImplementedError)
end
def sort_records(records)
- sort_by = []
- params[:order].each_value do |item|
- sort_by << "#{sort_column(item)} #{sort_direction(item)}"
- end
- records.order(sort_by.join(", "))
+ raise(NotImplementedError)
end
def paginate_records(records)
- fail(
- MethodNotImplementedError,
- 'Please mixin a pagination extension.'
- )
+ raise(NotImplementedError)
end
- def filter_records(records)
- records = simple_search(records)
- records = composite_search(records)
- records
+ # User overides
+ def additional_data
+ {}
end
- def simple_search(records)
- return records unless (params[:search].present? && params[:search][:value].present?)
- conditions = build_conditions_for(params[:search][:value])
- records = records.where(conditions) if conditions
- records
+ # JSON structure sent to jQuery DataTables
+ def as_json(*)
+ {
+ recordsTotal: records_total_count,
+ recordsFiltered: records_filtered_count,
+ data: sanitize_data(data),
+ }.merge(draw_id).merge(additional_data)
end
- def composite_search(records)
- conditions = aggregate_query
- records = records.where(conditions) if conditions
- records
+ # User helper methods
+ def column_id(name)
+ view_columns.keys.index(name.to_sym)
end
- def build_conditions_for(query)
- search_for = query.split(' ')
- criteria = search_for.inject([]) do |criteria, atom|
- criteria << searchable_columns.map { |col| search_condition(col, atom) }.reduce(:or)
- end.reduce(:and)
- criteria
+ def column_data(column)
+ id = column_id(column)
+ params.dig('columns', id.to_s, 'search', 'value')
end
- def search_condition(column, value)
- model, column = column.split('.')
- model = model.singularize.titleize.gsub( / /, '' ).constantize
+ private
- casted_column = ::Arel::Nodes::NamedFunction.new('CAST', [model.arel_table[column.to_sym].as(typecast)])
- casted_column.matches("%#{value}%")
+ # helper methods
+ def connected_columns
+ @connected_columns ||= view_columns.keys.filter_map { |field_name| datatable.column_by(:data, field_name.to_s) }
end
- def aggregate_query
- conditions = searchable_columns.each_with_index.map do |column, index|
- value = params[:columns]["#{index}"][:search][:value] if params[:columns]
- search_condition(column, value) unless value.blank?
- end
- conditions.compact.reduce(:and)
+ def searchable_columns
+ @searchable_columns ||= connected_columns.select(&:searchable?)
end
- def typecast
- case config.db_adapter
- when :pg then 'VARCHAR'
- when :mysql2 then 'CHAR'
- when :sqlite3 then 'TEXT'
+ def search_columns
+ @search_columns ||= searchable_columns.select(&:searched?)
+ end
+
+ def sanitize_data(data)
+ data.map do |record|
+ if record.is_a?(Array)
+ record.map { |td| ERB::Util.html_escape(td) }
+ else
+ record.update(record) { |_, v| ERB::Util.html_escape(v) }
+ end
end
end
- def offset
- (page - 1) * per_page
+ # called from within #data
+ def records
+ @records ||= retrieve_records
end
- def page
- (params[:start].to_i / per_page) + 1
+ def retrieve_records
+ records = fetch_records
+ records = filter_records(records)
+ records = sort_records(records) if datatable.orderable?
+ records = paginate_records(records) if datatable.paginate?
+ records
end
- def per_page
- params.fetch(:length, 10).to_i
+ def records_total_count
+ numeric_count fetch_records.count(:all)
end
- def sort_column(item)
- sortable_columns[item['column'].to_i]
+ def records_filtered_count
+ numeric_count filter_records(fetch_records).count(:all)
end
- def sort_direction(item)
- options = %w(desc asc)
- options.include?(item['dir']) ? item['dir'].upcase : 'ASC'
+ def numeric_count(count)
+ count.is_a?(Hash) ? count.values.size : count
end
+
+ def global_search_delimiter
+ GLOBAL_SEARCH_DELIMITER
+ end
+
+ # See: https://datatables.net/manual/server-side#Returned-data
+ def draw_id
+ params[:draw].present? ? { draw: params[:draw].to_i } : {}
+ end
+
end
end
diff --git a/lib/ajax-datatables-rails/config.rb b/lib/ajax-datatables-rails/config.rb
deleted file mode 100644
index 8891d59a..00000000
--- a/lib/ajax-datatables-rails/config.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-require 'active_support/configurable'
-
-module AjaxDatatablesRails
-
- # configure AjaxDatatablesRails global settings
- # AjaxDatatablesRails.configure do |config|
- # config.db_adapter = :pg
- # end
- def self.configure &block
- yield @config ||= AjaxDatatablesRails::Configuration.new
- end
-
- # AjaxDatatablesRails global settings
- def self.config
- @config ||= AjaxDatatablesRails::Configuration.new
- end
-
- class Configuration
- include ActiveSupport::Configurable
-
- # default db_adapter is pg (postgresql)
- config_accessor(:db_adapter) { :pg }
- end
-end
diff --git a/lib/ajax-datatables-rails/datatable.rb b/lib/ajax-datatables-rails/datatable.rb
new file mode 100644
index 00000000..739e07b0
--- /dev/null
+++ b/lib/ajax-datatables-rails/datatable.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module Datatable
+ end
+end
diff --git a/lib/ajax-datatables-rails/datatable/column.rb b/lib/ajax-datatables-rails/datatable/column.rb
new file mode 100644
index 00000000..2179a4da
--- /dev/null
+++ b/lib/ajax-datatables-rails/datatable/column.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module Datatable
+ class Column
+
+ include Search
+ include Order
+ include DateFilter
+
+ attr_reader :datatable, :index, :options, :column_name
+ attr_writer :search
+
+ def initialize(datatable, index, options) # rubocop:disable Metrics/MethodLength
+ @datatable = datatable
+ @index = index
+ @options = options
+ @column_name = options[:data]&.to_sym
+ @view_column = datatable.view_columns[@column_name]
+
+ @model = nil
+ @field = nil
+ @type_cast = nil
+ @casted_column = nil
+ @search = nil
+ @delimiter = nil
+ @range_start = nil
+ @range_end = nil
+
+ validate_settings!
+ end
+
+ def data
+ options[:data].presence || options[:name]
+ end
+
+ def source
+ @view_column[:source]
+ end
+
+ def table
+ model.respond_to?(:arel_table) ? model.arel_table : model
+ end
+
+ def model
+ @model ||= custom_field? ? source : source.split('.').first.constantize
+ end
+
+ def field
+ @field ||= custom_field? ? source : source.split('.').last.to_sym
+ end
+
+ def custom_field?
+ !source.include?('.')
+ end
+
+ # Add formatter option to allow modification of the value
+ # before passing it to the database
+ def formatter
+ @view_column[:formatter]
+ end
+
+ def formatted_value
+ formatter ? formatter.call(search.value) : search.value
+ end
+
+ private
+
+ TYPE_CAST_DEFAULT = 'VARCHAR'
+ TYPE_CAST_MYSQL = 'CHAR'
+ TYPE_CAST_SQLITE = 'TEXT'
+ TYPE_CAST_ORACLE = 'VARCHAR2(4000)'
+ TYPE_CAST_SQLSERVER = 'VARCHAR(4000)'
+
+ DB_ADAPTER_TYPE_CAST = {
+ mysql: TYPE_CAST_MYSQL,
+ mysql2: TYPE_CAST_MYSQL,
+ trilogy: TYPE_CAST_MYSQL,
+ sqlite: TYPE_CAST_SQLITE,
+ sqlite3: TYPE_CAST_SQLITE,
+ oracle: TYPE_CAST_ORACLE,
+ oracleenhanced: TYPE_CAST_ORACLE,
+ oracle_enhanced: TYPE_CAST_ORACLE,
+ sqlserver: TYPE_CAST_SQLSERVER,
+ }.freeze
+
+ private_constant :TYPE_CAST_DEFAULT
+ private_constant :TYPE_CAST_MYSQL
+ private_constant :TYPE_CAST_SQLITE
+ private_constant :TYPE_CAST_ORACLE
+ private_constant :TYPE_CAST_SQLSERVER
+ private_constant :DB_ADAPTER_TYPE_CAST
+
+ def type_cast
+ @type_cast ||= DB_ADAPTER_TYPE_CAST.fetch(datatable.db_adapter, TYPE_CAST_DEFAULT)
+ end
+
+ def casted_column
+ @casted_column ||= ::Arel::Nodes::NamedFunction.new('CAST', [table[field].as(type_cast)])
+ end
+
+ # rubocop:disable Layout/LineLength
+ def validate_settings!
+ raise AjaxDatatablesRails::Error::InvalidSearchColumn, 'Unknown column. Check that `data` field is filled on JS side with the column name' if column_name.empty?
+ raise AjaxDatatablesRails::Error::InvalidSearchColumn, "Check that column '#{column_name}' exists in view_columns" unless valid_search_column?(column_name)
+ raise AjaxDatatablesRails::Error::InvalidSearchCondition, cond unless valid_search_condition?(cond)
+ end
+ # rubocop:enable Layout/LineLength
+
+ def valid_search_column?(column_name)
+ !datatable.view_columns[column_name].nil?
+ end
+
+ VALID_SEARCH_CONDITIONS = [
+ # String condition
+ :start_with, :end_with, :like, :string_eq, :string_in, :null_value,
+ # Numeric condition
+ :eq, :not_eq, :lt, :gt, :lteq, :gteq, :in,
+ # Date condition
+ :date_range
+ ].freeze
+
+ private_constant :VALID_SEARCH_CONDITIONS
+
+ def valid_search_condition?(cond)
+ return true if cond.is_a?(Proc)
+
+ VALID_SEARCH_CONDITIONS.include?(cond)
+ end
+
+ end
+ end
+end
diff --git a/lib/ajax-datatables-rails/datatable/column/date_filter.rb b/lib/ajax-datatables-rails/datatable/column/date_filter.rb
new file mode 100644
index 00000000..c680f15b
--- /dev/null
+++ b/lib/ajax-datatables-rails/datatable/column/date_filter.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module Datatable
+ class Column
+ module DateFilter
+
+ RANGE_DELIMITER = '-'
+
+ class DateRange
+ attr_reader :begin, :end
+
+ def initialize(date_start, date_end)
+ @begin = date_start
+ @end = date_end
+ end
+
+ def exclude_end?
+ false
+ end
+ end
+
+ # Add delimiter option to handle range search
+ def delimiter
+ @delimiter ||= @view_column.fetch(:delimiter, RANGE_DELIMITER)
+ end
+
+ # A range value is in form ''
+ # This returns
+ def range_start
+ @range_start ||= formatted_value.split(delimiter)[0]
+ end
+
+ # A range value is in form ''
+ # This returns
+ def range_end
+ @range_end ||= formatted_value.split(delimiter)[1]
+ end
+
+ def empty_range_search?
+ (formatted_value == delimiter) || (range_start.blank? && range_end.blank?)
+ end
+
+ # Do a range search
+ def date_range_search
+ return nil if empty_range_search?
+
+ table[field].between(DateRange.new(range_start_casted, range_end_casted))
+ end
+
+ private
+
+ def range_start_casted
+ range_start.blank? ? parse_date('01/01/1970') : parse_date(range_start)
+ end
+
+ def range_end_casted
+ range_end.blank? ? parse_date('9999-12-31 23:59:59') : parse_date("#{range_end} 23:59:59")
+ end
+
+ def parse_date(date)
+ Time.zone ? Time.zone.parse(date) : Time.parse(date)
+ end
+
+ end
+ end
+ end
+end
diff --git a/lib/ajax-datatables-rails/datatable/column/order.rb b/lib/ajax-datatables-rails/datatable/column/order.rb
new file mode 100644
index 00000000..3806e494
--- /dev/null
+++ b/lib/ajax-datatables-rails/datatable/column/order.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module Datatable
+ class Column
+ module Order
+
+ def orderable?
+ @view_column.fetch(:orderable, true)
+ end
+
+ # Add sort_field option to allow overriding of sort field
+ def sort_field
+ @view_column.fetch(:sort_field, field)
+ end
+
+ def sort_query
+ custom_field? ? source : "#{table.name}.#{sort_field}"
+ end
+
+ # Add option to sort null values last
+ def nulls_last?
+ @view_column.fetch(:nulls_last, false)
+ end
+
+ end
+ end
+ end
+end
diff --git a/lib/ajax-datatables-rails/datatable/column/search.rb b/lib/ajax-datatables-rails/datatable/column/search.rb
new file mode 100644
index 00000000..581165fb
--- /dev/null
+++ b/lib/ajax-datatables-rails/datatable/column/search.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module Datatable
+ class Column
+ module Search
+
+ SMALLEST_PQ_INTEGER = -2_147_483_648
+ LARGEST_PQ_INTEGER = 2_147_483_647
+ NOT_NULL_VALUE = '!NULL'
+ EMPTY_VALUE = ''
+
+ def searchable?
+ @view_column.fetch(:searchable, true)
+ end
+
+ def cond
+ @view_column.fetch(:cond, :like)
+ end
+
+ def filter
+ @view_column[:cond].call(self, formatted_value)
+ end
+
+ def search
+ @search ||= SimpleSearch.new(options[:search])
+ end
+
+ def searched?
+ search.value.present?
+ end
+
+ def search_query
+ search.regexp? ? regex_search : non_regex_search
+ end
+
+ # Add use_regex option to allow bypassing of regex search
+ def use_regex?
+ @view_column.fetch(:use_regex, true)
+ end
+
+ private
+
+ # Using multi-select filters in JQuery Datatable auto-enables regex_search.
+ # Unfortunately regex_search doesn't work when filtering on primary keys with integer.
+ # It generates this kind of query : AND ("regions"."id" ~ '2|3') which throws an error :
+ # operator doesn't exist : integer ~ unknown
+ # The solution is to bypass regex_search and use non_regex_search with :in operator
+ def regex_search
+ if use_regex?
+ ::Arel::Nodes::Regexp.new((custom_field? ? field : table[field]), ::Arel::Nodes.build_quoted(formatted_value))
+ else
+ non_regex_search
+ end
+ end
+
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
+ def non_regex_search
+ case cond
+ when Proc
+ filter
+ when :eq, :not_eq, :lt, :gt, :lteq, :gteq, :in
+ searchable_integer? ? raw_search(cond) : empty_search
+ when :start_with
+ text_search("#{formatted_value}%")
+ when :end_with
+ text_search("%#{formatted_value}")
+ when :like
+ text_search("%#{formatted_value}%")
+ when :string_eq
+ raw_search(:eq)
+ when :string_in
+ raw_search(:in)
+ when :null_value
+ null_value_search
+ when :date_range
+ date_range_search
+ end
+ end
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
+
+ def null_value_search
+ if formatted_value == NOT_NULL_VALUE
+ table[field].not_eq(nil)
+ else
+ table[field].eq(nil)
+ end
+ end
+
+ def raw_search(cond)
+ table[field].send(cond, formatted_value) unless custom_field?
+ end
+
+ def text_search(value)
+ casted_column.matches(value) unless custom_field?
+ end
+
+ def empty_search
+ casted_column.matches(EMPTY_VALUE)
+ end
+
+ def searchable_integer?
+ if formatted_value.is_a?(Array)
+ valids = formatted_value.map { |v| integer?(v) && !out_of_range?(v) }
+ !valids.include?(false)
+ else
+ integer?(formatted_value) && !out_of_range?(formatted_value)
+ end
+ end
+
+ def out_of_range?(search_value)
+ Integer(search_value) > LARGEST_PQ_INTEGER || Integer(search_value) < SMALLEST_PQ_INTEGER
+ end
+
+ def integer?(string)
+ Integer(string)
+ true
+ rescue ArgumentError
+ false
+ end
+
+ end
+ end
+ end
+end
diff --git a/lib/ajax-datatables-rails/datatable/datatable.rb b/lib/ajax-datatables-rails/datatable/datatable.rb
new file mode 100644
index 00000000..0d4e4d6f
--- /dev/null
+++ b/lib/ajax-datatables-rails/datatable/datatable.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module Datatable
+ class Datatable
+ attr_reader :options
+
+ def initialize(datatable)
+ @datatable = datatable
+ @options = datatable.params
+
+ @orders = nil
+ @search = nil
+ @columns = nil
+ end
+
+ # ----------------- ORDER METHODS --------------------
+
+ def orderable?
+ options[:order].present?
+ end
+
+ def orders
+ @orders ||= get_param(:order).map do |_, order_options|
+ SimpleOrder.new(self, order_options)
+ end
+ end
+
+ def order_by(how, what)
+ orders.find { |simple_order| simple_order.send(how) == what }
+ end
+
+ # ----------------- SEARCH METHODS --------------------
+
+ def searchable?
+ options[:search].present? && options[:search][:value].present?
+ end
+
+ def search
+ @search ||= SimpleSearch.new(options[:search])
+ end
+
+ # ----------------- COLUMN METHODS --------------------
+
+ def columns
+ @columns ||= get_param(:columns).map do |index, column_options|
+ Column.new(@datatable, index, column_options)
+ end
+ end
+
+ def column_by(how, what)
+ columns.find { |simple_column| simple_column.send(how) == what }
+ end
+
+ # ----------------- OPTIONS METHODS --------------------
+
+ def paginate?
+ per_page != -1
+ end
+
+ def per_page
+ options.fetch(:length, 10).to_i
+ end
+
+ def offset
+ options.fetch(:start, 0).to_i
+ end
+
+ def page
+ (offset / per_page) + 1
+ end
+
+ def get_param(param)
+ return {} if options[param].nil?
+
+ if options[param].is_a? Array
+ hash = {}
+ options[param].each_with_index { |value, index| hash[index] = value }
+ hash
+ else
+ options[param].to_unsafe_h.with_indifferent_access
+ end
+ end
+
+ def db_adapter
+ @datatable.db_adapter
+ end
+
+ def nulls_last
+ @datatable.nulls_last
+ end
+
+ end
+ end
+end
diff --git a/lib/ajax-datatables-rails/datatable/simple_order.rb b/lib/ajax-datatables-rails/datatable/simple_order.rb
new file mode 100644
index 00000000..b817a586
--- /dev/null
+++ b/lib/ajax-datatables-rails/datatable/simple_order.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module Datatable
+ class SimpleOrder
+
+ DIRECTION_ASC = 'ASC'
+ DIRECTION_DESC = 'DESC'
+ DIRECTIONS = [DIRECTION_ASC, DIRECTION_DESC].freeze
+
+ def initialize(datatable, options = {})
+ @datatable = datatable
+ @options = options
+ @adapter = datatable.db_adapter
+ @nulls_last = datatable.nulls_last
+ end
+
+ def query(sort_column)
+ [sort_column, direction, nulls_last_sql].compact.join(' ')
+ end
+
+ def column
+ @datatable.column_by(:index, column_index)
+ end
+
+ def direction
+ DIRECTIONS.find { |dir| dir == column_direction } || DIRECTION_ASC
+ end
+
+ private
+
+ def column_index
+ @options[:column]
+ end
+
+ def column_direction
+ @options[:dir].upcase
+ end
+
+ def sort_nulls_last?
+ column.nulls_last? || @nulls_last == true
+ end
+
+ PG_NULL_STYLE = 'NULLS LAST'
+ MYSQL_NULL_STYLE = 'IS NULL'
+ private_constant :PG_NULL_STYLE
+ private_constant :MYSQL_NULL_STYLE
+
+ NULL_STYLE_MAP = {
+ pg: PG_NULL_STYLE,
+ postgresql: PG_NULL_STYLE,
+ postgres: PG_NULL_STYLE,
+ postgis: PG_NULL_STYLE,
+ oracle: PG_NULL_STYLE,
+ mysql: MYSQL_NULL_STYLE,
+ mysql2: MYSQL_NULL_STYLE,
+ trilogy: MYSQL_NULL_STYLE,
+ sqlite: MYSQL_NULL_STYLE,
+ sqlite3: MYSQL_NULL_STYLE,
+ }.freeze
+ private_constant :NULL_STYLE_MAP
+
+ def nulls_last_sql
+ return unless sort_nulls_last?
+
+ NULL_STYLE_MAP[@adapter] || raise("unsupported database adapter: #{@adapter}")
+ end
+
+ end
+ end
+end
diff --git a/lib/ajax-datatables-rails/datatable/simple_search.rb b/lib/ajax-datatables-rails/datatable/simple_search.rb
new file mode 100644
index 00000000..70bee016
--- /dev/null
+++ b/lib/ajax-datatables-rails/datatable/simple_search.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module Datatable
+ class SimpleSearch
+
+ TRUE_VALUE = 'true'
+
+ def initialize(options = {})
+ @options = options
+ end
+
+ def value
+ @options[:value]
+ end
+
+ def regexp?
+ @options[:regex] == TRUE_VALUE
+ end
+
+ end
+ end
+end
diff --git a/lib/ajax-datatables-rails/error.rb b/lib/ajax-datatables-rails/error.rb
new file mode 100644
index 00000000..20fe0d26
--- /dev/null
+++ b/lib/ajax-datatables-rails/error.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module Error
+ class BaseError < StandardError; end
+ class InvalidSearchColumn < BaseError; end
+ class InvalidSearchCondition < BaseError; end
+ end
+end
diff --git a/lib/ajax-datatables-rails/extensions/kaminari.rb b/lib/ajax-datatables-rails/extensions/kaminari.rb
deleted file mode 100644
index 0dd1a654..00000000
--- a/lib/ajax-datatables-rails/extensions/kaminari.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-module AjaxDatatablesRails
- module Extensions
- module Kaminari
-
- private
-
- def paginate_records(records)
- records.page(page).per(per_page)
- end
- end
- end
-end
\ No newline at end of file
diff --git a/lib/ajax-datatables-rails/extensions/simple_paginator.rb b/lib/ajax-datatables-rails/extensions/simple_paginator.rb
deleted file mode 100644
index 399d3452..00000000
--- a/lib/ajax-datatables-rails/extensions/simple_paginator.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-module AjaxDatatablesRails
- module Extensions
- module SimplePaginator
-
- private
-
- def paginate_records(records)
- records.offset(offset).limit(per_page)
- end
- end
- end
-end
\ No newline at end of file
diff --git a/lib/ajax-datatables-rails/extensions/will_paginate.rb b/lib/ajax-datatables-rails/extensions/will_paginate.rb
deleted file mode 100644
index d1f6cfbe..00000000
--- a/lib/ajax-datatables-rails/extensions/will_paginate.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-module AjaxDatatablesRails
- module Extensions
- module WillPaginate
-
- private
-
- def paginate_records(records)
- records.paginate(:page => page, :per_page => per_page)
- end
- end
- end
-end
\ No newline at end of file
diff --git a/lib/ajax-datatables-rails/orm.rb b/lib/ajax-datatables-rails/orm.rb
new file mode 100644
index 00000000..9334b847
--- /dev/null
+++ b/lib/ajax-datatables-rails/orm.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module ORM
+ end
+end
diff --git a/lib/ajax-datatables-rails/orm/active_record.rb b/lib/ajax-datatables-rails/orm/active_record.rb
new file mode 100644
index 00000000..8da0895d
--- /dev/null
+++ b/lib/ajax-datatables-rails/orm/active_record.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module AjaxDatatablesRails
+ module ORM
+ module ActiveRecord
+
+ def filter_records(records)
+ records.where(build_conditions)
+ end
+
+ # rubocop:disable Style/EachWithObject, Style/SafeNavigation
+ def sort_records(records)
+ sort_by = datatable.orders.inject([]) do |queries, order|
+ column = order.column
+ queries << order.query(column.sort_query) if column && column.orderable?
+ queries
+ end
+ records.order(Arel.sql(sort_by.join(', ')))
+ end
+ # rubocop:enable Style/EachWithObject, Style/SafeNavigation
+
+ def paginate_records(records)
+ records.offset(datatable.offset).limit(datatable.per_page)
+ end
+
+ # ----------------- SEARCH HELPER METHODS --------------------
+
+ def build_conditions
+ @build_conditions ||= begin
+ criteria = [build_conditions_for_selected_columns]
+ criteria << build_conditions_for_datatable if datatable.searchable?
+ criteria.compact.reduce(:and)
+ end
+ end
+
+ def build_conditions_for_datatable
+ columns = searchable_columns.reject(&:searched?)
+ search_for.inject([]) do |crit, atom|
+ crit << columns.filter_map do |simple_column|
+ simple_column.search = Datatable::SimpleSearch.new(value: atom, regex: datatable.search.regexp?)
+ simple_column.search_query
+ end.reduce(:or)
+ end.compact.reduce(:and)
+ end
+
+ def build_conditions_for_selected_columns
+ search_columns.filter_map(&:search_query).reduce(:and)
+ end
+
+ def search_for
+ datatable.search.value.split(global_search_delimiter)
+ end
+
+ end
+ end
+end
diff --git a/lib/ajax-datatables-rails/version.rb b/lib/ajax-datatables-rails/version.rb
index 2c2a6dd7..3e9a1320 100644
--- a/lib/ajax-datatables-rails/version.rb
+++ b/lib/ajax-datatables-rails/version.rb
@@ -1,3 +1,17 @@
+# frozen_string_literal: true
+
module AjaxDatatablesRails
- VERSION = '0.2.1'
+
+ def self.gem_version
+ Gem::Version.new VERSION::STRING
+ end
+
+ module VERSION
+ MAJOR = 1
+ MINOR = 5
+ TINY = 0
+ PRE = nil
+
+ STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.')
+ end
end
diff --git a/lib/generators/datatable/config_generator.rb b/lib/generators/datatable/config_generator.rb
deleted file mode 100644
index ea5ff476..00000000
--- a/lib/generators/datatable/config_generator.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-require 'rails/generators'
-
-module Datatable
- module Generators
- class ConfigGenerator < ::Rails::Generators::Base
- source_root File.expand_path(File.join(File.dirname(__FILE__), 'templates'))
- desc < :string
+ source_root File.expand_path('templates', __dir__)
+ argument :name, type: :string
def generate_datatable
- file_prefix = set_filename(name)
- @datatable_name = set_datatable_name(name)
- template 'datatable.rb', File.join(
- 'app/datatables', "#{file_prefix}_datatable.rb"
- )
+ template 'datatable.rb.erb', File.join('app', 'datatables', "#{datatable_path}.rb")
end
- private
-
- def set_filename(name)
- name.include?('_') ? name : name.to_s.underscore
+ def datatable_name
+ datatable_path.classify
end
- def set_datatable_name(name)
- name.include?('_') ? build_name(name) : capitalize(name)
- end
+ private
- def build_name(name)
- pieces = name.split('_')
- pieces.map(&:titleize).join
+ def datatable_path
+ "#{name.underscore}_datatable"
end
- def capitalize(name)
- return name if name[0] == name[0].upcase
- name.capitalize
- end
end
end
-end
\ No newline at end of file
+end
diff --git a/lib/generators/rails/templates/datatable.rb b/lib/generators/rails/templates/datatable.rb
deleted file mode 100644
index 6ae5145d..00000000
--- a/lib/generators/rails/templates/datatable.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-class <%= @datatable_name %>Datatable < AjaxDatatablesRails::Base
- # uncomment the appropriate paginator module,
- # depending on gems available in your project.
- # include AjaxDatatablesRails::Extensions::Kaminari
- # include AjaxDatatablesRails::Extensions::WillPaginate
- # include AjaxDatatablesRails::Extensions::SimplePaginator
-
- def sortable_columns
- # list columns inside the Array in string dot notation.
- # Example: 'users.email'
- @sortable_columns ||= []
- end
-
- def searchable_columns
- # list columns inside the Array in string dot notation.
- # Example: 'users.email'
- @searchable_columns ||= []
- end
-
- private
-
- def data
- records.map do |record|
- [
- # comma separated list of the values for each cell of a table row
- # example: record.attribute,
- ]
- end
- end
-
- def get_raw_records
- # insert query here
- end
-
- # ==== Insert 'presenter'-like methods below if necessary
-end
diff --git a/lib/generators/rails/templates/datatable.rb.erb b/lib/generators/rails/templates/datatable.rb.erb
new file mode 100644
index 00000000..a6b0c922
--- /dev/null
+++ b/lib/generators/rails/templates/datatable.rb.erb
@@ -0,0 +1,27 @@
+class <%= datatable_name %> < AjaxDatatablesRails::ActiveRecord
+
+ def view_columns
+ # Declare strings in this format: ModelName.column_name
+ # or in aliased_join_table.column_name format
+ @view_columns ||= {
+ # id: { source: "User.id", cond: :eq },
+ # name: { source: "User.name", cond: :like }
+ }
+ end
+
+ def data
+ records.map do |record|
+ {
+ # example:
+ # id: record.id,
+ # name: record.name
+ }
+ end
+ end
+
+ def get_raw_records
+ # insert query here
+ # User.all
+ end
+
+end
diff --git a/spec/ajax-datatables-rails/ajax_datatables_rails_spec.rb b/spec/ajax-datatables-rails/ajax_datatables_rails_spec.rb
deleted file mode 100644
index 7e773675..00000000
--- a/spec/ajax-datatables-rails/ajax_datatables_rails_spec.rb
+++ /dev/null
@@ -1,301 +0,0 @@
-require 'spec_helper'
-
-describe AjaxDatatablesRails::Base do
- class Column
- def matches(query)
- []
- end
- end
-
- class User
- def self.arel_table
- { :foo => Column.new }
- end
- end
-
- class UserData
- def self.arel_table
- { :bar => Column.new }
- end
- end
-
- params = {
- :draw => '5',
- :columns => {
- "0" => {
- :data => '0',
- :name => '',
- :searchable => true,
- :orderable => true,
- :search => { :value => '', :regex => false }
- },
- "1" => {
- :data => '1',
- :name => '',
- :searchable => true,
- :orderable => true,
- :search => { :value => '', :regex => false }
- }
- },
- :order => { "0" => { :column => '1', :dir => 'desc' } },
- :start => '0',
- :length => '10',
- :search => { :value => '', :regex => false },
- '_' => '1403141483098'
- }
- let(:view) { double('view', :params => params) }
-
- describe 'an instance' do
- it 'requires a view_context' do
- expect { AjaxDatatablesRails::Base.new }.to raise_error
- end
-
- it 'accepts an options hash' do
- datatable = AjaxDatatablesRails::Base.new(view, :foo => 'bar')
- expect(datatable.options).to eq(:foo => 'bar')
- end
- end
-
- describe 'helper methods' do
- describe '#offset' do
- it 'defaults to 0' do
- default_view = double('view', :params => {})
- datatable = AjaxDatatablesRails::Base.new(default_view)
- expect(datatable.send(:offset)).to eq(0)
- end
-
- it 'matches the value on view params[:start] minus 1' do
- paginated_view = double('view', :params => { :start => '11' })
- datatable = AjaxDatatablesRails::Base.new(paginated_view)
- expect(datatable.send(:offset)).to eq(10)
- end
- end
-
- describe '#page' do
- it 'calculates page number from params[:start] and #per_page' do
- paginated_view = double('view', :params => { :start => '11' })
- datatable = AjaxDatatablesRails::Base.new(paginated_view)
- expect(datatable.send(:page)).to eq(2)
- end
- end
-
- describe '#per_page' do
- it 'defaults to 10' do
- datatable = AjaxDatatablesRails::Base.new(view)
- expect(datatable.send(:per_page)).to eq(10)
- end
-
- it 'matches the value on view params[:length]' do
- other_view = double('view', :params => { :length => 20 })
- datatable = AjaxDatatablesRails::Base.new(other_view)
- expect(datatable.send(:per_page)).to eq(20)
- end
- end
-
- describe '#sort_column' do
- it 'returns a column name from the #sorting_columns array' do
- sort_view = double(
- 'view',
- :params => {
- :order => {
- '0' => { :column => '1' }
- }
- }
- )
- datatable = AjaxDatatablesRails::Base.new(sort_view)
- datatable.stub(:sortable_columns) { ['foo', 'bar', 'baz'] }
-
- expect(datatable.send(:sort_column)).to eq('bar')
- end
- end
-
- describe '#sort_direction' do
- it 'matches value of params[:sSortDir_0]' do
- sorting_view = double(
- 'view',
- :params => {
- :order => {
- '0' => { :column => '1', :dir => 'desc' }
- }
- }
- )
- datatable = AjaxDatatablesRails::Base.new(sorting_view)
- expect(datatable.send(:sort_direction)).to eq('DESC')
- end
-
- it 'can only be one option from ASC or DESC' do
- sorting_view = double(
- 'view',
- :params => {
- :order => {
- '0' => { :column => '1', :dir => 'foo' }
- }
- }
- )
- datatable = AjaxDatatablesRails::Base.new(sorting_view)
- expect(datatable.send(:sort_direction)).to eq('ASC')
- end
- end
-
- describe '#sortable_columns' do
- it 'returns an array representing database columns' do
- datatable = AjaxDatatablesRails::Base.new(view)
- expect(datatable.sortable_columns).to eq([])
- end
- end
-
- describe '#searchable_columns' do
- it 'returns an array representing database columns' do
- datatable = AjaxDatatablesRails::Base.new(view)
- expect(datatable.searchable_columns).to eq([])
- end
- end
- end
-
- describe 'perform' do
- let(:results) { double('Collection', :offset => [], :limit => []) }
- let(:view) { double('view', :params => params) }
- let(:datatable) { AjaxDatatablesRails::Base.new(view) }
-
- describe '#paginate_records' do
- it 'raises a MethodNotImplementedError' do
- expect { datatable.send(:paginate_records, []) }.to raise_error(
- AjaxDatatablesRails::Base::MethodNotImplementedError
- )
- end
- end
-
- describe '#sort_records' do
- it 'calls #order on a collection' do
- results.should_receive(:order)
- datatable.send(:sort_records, results)
- end
- end
-
- describe '#filter_records' do
- let(:records) { double('User', :where => []) }
- let(:search_view) { double('view', :params => params) }
-
- it 'applies search like functionality on a collection' do
- datatable = AjaxDatatablesRails::Base.new(search_view)
- datatable.stub(:searchable_columns) { ['users.foo'] }
-
- records.should_receive(:where)
- datatable.send(:filter_records, records)
- end
- end
-
- describe '#filter_records with multi word model' do
- let(:records) { double('UserData', :where => []) }
- let(:search_view) { double('view', :params => params) }
-
- it 'applies search like functionality on a collection' do
- datatable = AjaxDatatablesRails::Base.new(search_view)
- datatable.stub(:searchable_columns) { ['user_datas.bar'] }
-
- records.should_receive(:where)
- datatable.send(:filter_records, records)
- end
- end
- end
-
- describe 'hook methods' do
- let(:datatable) { AjaxDatatablesRails::Base.new(view) }
-
- describe '#data' do
- it 'raises a MethodNotImplementedError' do
- expect { datatable.data }.to raise_error(
- AjaxDatatablesRails::Base::MethodNotImplementedError,
- 'Please implement this method in your class.'
- )
- end
- end
-
- describe '#get_raw_records' do
- it 'raises a MethodNotImplementedError' do
- expect { datatable.get_raw_records }.to raise_error(
- AjaxDatatablesRails::Base::MethodNotImplementedError,
- 'Please implement this method in your class.'
- )
- end
- end
- end
-end
-
-
-describe AjaxDatatablesRails::Configuration do
- let(:config) { AjaxDatatablesRails::Configuration.new }
-
- describe "default config" do
- it "default db_adapter should :pg (postgresql)" do
- expect(config.db_adapter).to eq(:pg)
- end
- end
-
- describe "custom config" do
- it 'should accept db_adapter custom value' do
- config.db_adapter = :mysql2
- expect(config.db_adapter).to eq(:mysql2)
- end
- end
-
- describe '#typecast' do
- params = {
- :draw => '5',
- :columns => {
- "0" => {
- :data => '0',
- :name => '',
- :searchable => true,
- :orderable => true,
- :search => { :value => '', :regex => false }
- },
- "1" => {
- :data => '1',
- :name => '',
- :searchable => true,
- :orderable => true,
- :search => { :value => '', :regex => false }
- }
- },
- :order => { "0" => { :column => '1', :dir => 'desc' } },
- :start => '0',
- :length => '10',
- :search => { :value => '', :regex => false },
- '_' => '1403141483098'
- }
- let(:view) { double('view', :params => params) }
- let(:datatable) { AjaxDatatablesRails::Base.new(view) }
-
- it 'returns VARCHAR if :db_adapter is :pg' do
- expect(datatable.send(:typecast)).to eq('VARCHAR')
- end
-
- it 'returns CHAR if :db_adapter is :mysql2' do
- allow_any_instance_of(AjaxDatatablesRails::Configuration).to receive(:db_adapter) { :mysql2 }
- expect(datatable.send(:typecast)).to eq('CHAR')
- end
-
- it 'returns TEXT if :db_adapter is :sqlite3' do
- allow_any_instance_of(AjaxDatatablesRails::Configuration).to receive(:db_adapter) { :sqlite3 }
- expect(datatable.send(:typecast)).to eq('TEXT')
- end
- end
-end
-
-describe AjaxDatatablesRails do
- describe "configurations" do
- context "configurable from outside" do
- before(:each) do
- AjaxDatatablesRails.configure do |config|
- config.db_adapter = :mysql2
- end
- end
-
- it "should have custom value" do
- expect(AjaxDatatablesRails.config.db_adapter).to eq(:mysql2)
- end
- end
-
- end
-end
diff --git a/spec/ajax-datatables-rails/kaminari_spec.rb b/spec/ajax-datatables-rails/kaminari_spec.rb
deleted file mode 100644
index 8ce6b3b3..00000000
--- a/spec/ajax-datatables-rails/kaminari_spec.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-require 'spec_helper'
-
-class KaminariDatatable < AjaxDatatablesRails::Base
- include AjaxDatatablesRails::Extensions::Kaminari
-end
-
-describe KaminariDatatable do
- describe '#paginate_records' do
- let(:users_database) do
- double('User',
- :all => double('RecordCollection',
- :page => double('Array', :per => [])
- )
- )
- end
-
- let(:datatable) { KaminariDatatable.new(double('view', :params => {})) }
- let(:records) { users_database.all }
-
- it 'calls #page on passed record collection' do
- records.should_receive(:page)
- datatable.send(:paginate_records, records)
- end
-
- it 'calls #per_page on passed record collection' do
- arry = double('Array', :per => [])
- records.stub(:page).and_return(arry)
- arry.should_receive(:per)
- datatable.send(:paginate_records, records)
- end
- end
-end
\ No newline at end of file
diff --git a/spec/ajax-datatables-rails/simple_paginator_spec.rb b/spec/ajax-datatables-rails/simple_paginator_spec.rb
deleted file mode 100644
index f79a27f5..00000000
--- a/spec/ajax-datatables-rails/simple_paginator_spec.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-require 'spec_helper'
-
-class SimplePaginateDatatable < AjaxDatatablesRails::Base
- include AjaxDatatablesRails::Extensions::SimplePaginator
-end
-
-describe SimplePaginateDatatable do
- describe '#paginate_records' do
- let(:users_database) do
- double('User',
- :all => double('RecordCollection',
- :offset => double('Array', :limit => [])
- )
- )
- end
-
- let(:datatable) { SimplePaginateDatatable.new(double('view', :params => {})) }
- let(:records) { users_database.all }
-
- it 'calls #offset on passed record collection' do
- records.should_receive(:offset)
- datatable.send(:paginate_records, records)
- end
-
- it 'calls #limit on passed record collection' do
- arry = double('Array', :limit => [])
- records.stub(:offset).and_return(arry)
- arry.should_receive(:limit)
- datatable.send(:paginate_records, records)
- end
- end
-end
\ No newline at end of file
diff --git a/spec/ajax-datatables-rails/will_paginate_spec.rb b/spec/ajax-datatables-rails/will_paginate_spec.rb
deleted file mode 100644
index 80591e78..00000000
--- a/spec/ajax-datatables-rails/will_paginate_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-require 'spec_helper'
-
-class WillPaginateDatatable < AjaxDatatablesRails::Base
- include AjaxDatatablesRails::Extensions::WillPaginate
-end
-
-describe WillPaginateDatatable do
- describe '#paginate_records' do
- let(:users_database) do
- double('User',
- :all => double('RecordCollection',
- :paginate => double('Array', :per_page => [])
- )
- )
- end
-
- let(:datatable) { WillPaginateDatatable.new(double('view', :params => {})) }
- let(:records) { users_database.all }
-
- it 'calls #page and #per_page on passed record collection' do
- records.should_receive(:paginate).with(:page=>1, :per_page=>10)
- datatable.send(:paginate_records, records)
- end
- end
-end
\ No newline at end of file
diff --git a/spec/ajax_datatables_rails/base_spec.rb b/spec/ajax_datatables_rails/base_spec.rb
new file mode 100644
index 00000000..28915792
--- /dev/null
+++ b/spec/ajax_datatables_rails/base_spec.rb
@@ -0,0 +1,242 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AjaxDatatablesRails::Base do
+
+ describe 'an instance' do
+ it 'requires a hash of params' do
+ expect { described_class.new }.to raise_error ArgumentError
+ end
+
+ it 'accepts an options hash' do
+ datatable = described_class.new(sample_params, foo: 'bar')
+ expect(datatable.options).to eq(foo: 'bar')
+ end
+ end
+
+ describe 'User API' do
+ describe '#view_columns' do
+ context 'when method is not defined by the user' do
+ it 'raises an error' do
+ datatable = described_class.new(sample_params)
+ expect { datatable.view_columns }.to raise_error(NotImplementedError).with_message(<<~ERROR)
+
+ You should implement this method in your class and return an array
+ of database columns based on the columns displayed in the HTML view.
+ These columns should be represented in the ModelName.column_name,
+ or aliased_join_table.column_name notation.
+ ERROR
+ end
+ end
+
+ context 'when child class implements view_columns' do
+ it 'expects a hash based defining columns' do
+ datatable = ComplexDatatable.new(sample_params)
+ expect(datatable.view_columns).to be_a(Hash)
+ end
+ end
+ end
+
+ describe '#get_raw_records' do
+ context 'when method is not defined by the user' do
+ it 'raises an error' do
+ datatable = described_class.new(sample_params)
+ expect { datatable.get_raw_records }.to raise_error(NotImplementedError).with_message(<<~ERROR)
+
+ You should implement this method in your class and specify
+ how records are going to be retrieved from the database.
+ ERROR
+ end
+ end
+ end
+
+ describe '#data' do
+ context 'when method is not defined by the user' do
+ it 'raises an error' do
+ datatable = described_class.new(sample_params)
+ expect { datatable.data }.to raise_error(NotImplementedError).with_message(<<~ERROR)
+
+ You should implement this method in your class and return an array
+ of arrays, or an array of hashes, as defined in the jQuery.dataTables
+ plugin documentation.
+ ERROR
+ end
+ end
+
+ context 'when data is defined as a hash' do
+ let(:datatable) { ComplexDatatable.new(sample_params) }
+
+ it 'returns an array of hashes' do
+ create_list(:user, 5)
+ expect(datatable.data).to be_a(Array)
+ expect(datatable.data.size).to eq 5
+ item = datatable.data.first
+ expect(item).to be_a(Hash)
+ end
+
+ it 'htmls escape data' do
+ create(:user, first_name: 'Name "> ', last_name: 'Name "> ')
+ data = datatable.send(:sanitize_data, datatable.data)
+ item = data.first
+ expect(item[:first_name]).to eq 'Name "><img src=x onerror=alert("first_name")>'
+ expect(item[:last_name]).to eq 'Name "><img src=x onerror=alert("last_name")>'
+ end
+ end
+
+ context 'when data is defined as a array' do
+ let(:datatable) { ComplexDatatableArray.new(sample_params) }
+
+ it 'returns an array of arrays' do
+ create_list(:user, 5)
+ expect(datatable.data).to be_a(Array)
+ expect(datatable.data.size).to eq 5
+ item = datatable.data.first
+ expect(item).to be_a(Array)
+ end
+
+ it 'htmls escape data' do
+ create(:user, first_name: 'Name "> ', last_name: 'Name "> ')
+ data = datatable.send(:sanitize_data, datatable.data)
+ item = data.first
+ expect(item[2]).to eq 'Name "><img src=x onerror=alert("first_name")>'
+ expect(item[3]).to eq 'Name "><img src=x onerror=alert("last_name")>'
+ end
+ end
+ end
+ end
+
+ describe 'ORM API' do
+ context 'when ORM is not implemented' do
+ let(:datatable) { described_class.new(sample_params) }
+
+ describe '#fetch_records' do
+ it 'raises an error if it does not include an ORM module' do
+ expect { datatable.fetch_records }.to raise_error NotImplementedError
+ end
+ end
+
+ describe '#filter_records' do
+ it 'raises an error if it does not include an ORM module' do
+ expect { datatable.filter_records([]) }.to raise_error NotImplementedError
+ end
+ end
+
+ describe '#sort_records' do
+ it 'raises an error if it does not include an ORM module' do
+ expect { datatable.sort_records([]) }.to raise_error NotImplementedError
+ end
+ end
+
+ describe '#paginate_records' do
+ it 'raises an error if it does not include an ORM module' do
+ expect { datatable.paginate_records([]) }.to raise_error NotImplementedError
+ end
+ end
+ end
+
+ context 'when ORM is implemented' do
+ describe 'it allows method override' do
+ let(:datatable) do
+ datatable = Class.new(ComplexDatatable) do
+ def filter_records(_records)
+ raise NotImplementedError, 'FOO'
+ end
+
+ def sort_records(_records)
+ raise NotImplementedError, 'FOO'
+ end
+
+ def paginate_records(_records)
+ raise NotImplementedError, 'FOO'
+ end
+ end
+ datatable.new(sample_params)
+ end
+
+ describe '#fetch_records' do
+ it 'calls #get_raw_records' do
+ allow(datatable).to receive(:get_raw_records) { User.all }
+ datatable.fetch_records
+ expect(datatable).to have_received(:get_raw_records)
+ end
+
+ it 'returns a collection of records' do
+ allow(datatable).to receive(:get_raw_records) { User.all }
+ expect(datatable.fetch_records).to be_a(ActiveRecord::Relation)
+ end
+ end
+
+ describe '#filter_records' do
+ it {
+ expect { datatable.filter_records([]) }.to raise_error(NotImplementedError).with_message('FOO')
+ }
+ end
+
+ describe '#sort_records' do
+ it {
+ expect { datatable.sort_records([]) }.to raise_error(NotImplementedError).with_message('FOO')
+ }
+ end
+
+ describe '#paginate_records' do
+ it {
+ expect { datatable.paginate_records([]) }.to raise_error(NotImplementedError).with_message('FOO')
+ }
+ end
+ end
+ end
+ end
+
+ describe 'JSON format' do
+ describe '#as_json' do
+ let(:datatable) { ComplexDatatable.new(sample_params) }
+
+ it 'returns a hash' do
+ create_list(:user, 5)
+ data = datatable.as_json
+ expect(data[:recordsTotal]).to eq 5
+ expect(data[:recordsFiltered]).to eq 5
+ expect(data[:draw]).to eq 1
+ expect(data[:data]).to be_a(Array)
+ expect(data[:data].size).to eq 5
+ end
+
+ context 'with additional_data' do
+ it 'returns a hash' do
+ create_list(:user, 5)
+ allow(datatable).to receive(:additional_data).and_return({ foo: 'bar' })
+ data = datatable.as_json
+ expect(data[:recordsTotal]).to eq 5
+ expect(data[:recordsFiltered]).to eq 5
+ expect(data[:draw]).to eq 1
+ expect(data[:data]).to be_a(Array)
+ expect(data[:data].size).to eq 5
+ expect(data[:foo]).to eq 'bar'
+ end
+ end
+ end
+ end
+
+ describe 'User helper methods' do
+ describe '#column_id' do
+ let(:datatable) { ComplexDatatable.new(sample_params) }
+
+ it 'returns column id from view_columns hash' do
+ expect(datatable.column_id(:username)).to eq(0)
+ expect(datatable.column_id('username')).to eq(0)
+ end
+ end
+
+ describe '#column_data' do
+ before { datatable.params[:columns]['0'][:search][:value] = 'doe' }
+
+ let(:datatable) { ComplexDatatable.new(sample_params) }
+
+ it 'returns column data from params' do
+ expect(datatable.column_data(:username)).to eq('doe')
+ expect(datatable.column_data('username')).to eq('doe')
+ end
+ end
+ end
+end
diff --git a/spec/ajax_datatables_rails/datatable/column_spec.rb b/spec/ajax_datatables_rails/datatable/column_spec.rb
new file mode 100644
index 00000000..21dc66f0
--- /dev/null
+++ b/spec/ajax_datatables_rails/datatable/column_spec.rb
@@ -0,0 +1,239 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AjaxDatatablesRails::Datatable::Column do
+
+ let(:datatable) { ComplexDatatable.new(sample_params) }
+
+ describe 'username column' do
+
+ let(:column) { datatable.datatable.columns.first }
+
+ before { datatable.params[:columns]['0'][:search][:value] = 'searchvalue' }
+
+ it 'is orderable' do
+ expect(column.orderable?).to be(true)
+ end
+
+ it 'sorts nulls last' do
+ expect(column.nulls_last?).to be(false)
+ end
+
+ it 'is searchable' do
+ expect(column.searchable?).to be(true)
+ end
+
+ it 'is searched' do
+ expect(column.searched?).to be(true)
+ end
+
+ it 'has connected to id column' do
+ expect(column.data).to eq('username')
+ end
+
+ describe '#data' do
+ it 'returns the data from params' do
+ expect(column.data).to eq 'username'
+ end
+ end
+
+ describe '#source' do
+ it 'returns the data source from view_column' do
+ expect(column.source).to eq 'User.username'
+ end
+ end
+
+ describe '#table' do
+ context 'with ActiveRecord ORM' do
+ it 'returns the corresponding AR table' do
+ expect(column.table).to eq User.arel_table
+ end
+ end
+
+ context 'with other ORM' do
+ it 'returns the corresponding model' do
+ allow(User).to receive(:respond_to?).with(:arel_table).and_return(false)
+ expect(column.table).to eq User
+ end
+ end
+ end
+
+ describe '#model' do
+ it 'returns the corresponding AR model' do
+ expect(column.model).to eq User
+ end
+ end
+
+ describe '#field' do
+ it 'returns the corresponding field in DB' do
+ expect(column.field).to eq :username
+ end
+ end
+
+ describe '#custom_field?' do
+ it 'returns false if field is bound to an AR field' do
+ expect(column.custom_field?).to be false
+ end
+ end
+
+ describe '#search' do
+ it 'child class' do
+ expect(column.search).to be_a(AjaxDatatablesRails::Datatable::SimpleSearch)
+ end
+
+ it 'has search value' do
+ expect(column.search.value).to eq('searchvalue')
+ end
+
+ it 'does not regex' do
+ expect(column.search.regexp?).to be false
+ end
+ end
+
+ describe '#cond' do
+ it 'is :like by default' do
+ expect(column.cond).to eq(:like)
+ end
+ end
+
+ describe '#search_query' do
+ it 'bulds search query' do
+ expect(column.search_query.to_sql).to include('%searchvalue%')
+ end
+ end
+
+ describe '#sort_query' do
+ it 'builds sort query' do
+ expect(column.sort_query).to eq('users.username')
+ end
+ end
+
+ describe '#use_regex?' do
+ it 'is true by default' do
+ expect(column.use_regex?).to be true
+ end
+ end
+
+ describe '#delimiter' do
+ it 'is - by default' do
+ expect(column.delimiter).to eq('-')
+ end
+ end
+ end
+
+ describe 'unsearchable column' do
+ let(:column) { datatable.datatable.columns.find { |c| c.data == 'email_hash' } }
+
+ it 'is not searchable' do
+ expect(column.searchable?).to be(false)
+ end
+ end
+
+ describe '#formatter' do
+ let(:datatable) { DatatableWithFormater.new(sample_params) }
+ let(:column) { datatable.datatable.columns.find { |c| c.data == 'last_name' } }
+
+ it 'is a proc' do
+ expect(column.formatter).to be_a(Proc)
+ end
+ end
+
+ describe '#filter' do
+ let(:datatable) { DatatableCondProc.new(sample_params) }
+ let(:column) { datatable.datatable.columns.find { |c| c.data == 'username' } }
+
+ it 'is a proc' do
+ config = column.instance_variable_get(:@view_column)
+ filter = config[:cond]
+ expect(filter).to be_a(Proc)
+ allow(filter).to receive(:call).with(column, column.formatted_value)
+ column.filter
+ expect(filter).to have_received(:call).with(column, column.formatted_value)
+ end
+ end
+
+ describe '#type_cast' do
+ let(:column) { datatable.datatable.columns.first }
+
+ it 'returns VARCHAR if :db_adapter is :pg' do
+ allow(datatable).to receive(:db_adapter).and_return(:pg)
+ expect(column.send(:type_cast)).to eq('VARCHAR')
+ end
+
+ it 'returns VARCHAR if :db_adapter is :postgre' do
+ allow(datatable).to receive(:db_adapter).and_return(:postgre)
+ expect(column.send(:type_cast)).to eq('VARCHAR')
+ end
+
+ it 'returns VARCHAR if :db_adapter is :postgresql' do
+ allow(datatable).to receive(:db_adapter).and_return(:postgresql)
+ expect(column.send(:type_cast)).to eq('VARCHAR')
+ end
+
+ it 'returns VARCHAR if :db_adapter is :postgis' do
+ allow(datatable).to receive(:db_adapter).and_return(:postgis)
+ expect(column.send(:type_cast)).to eq('VARCHAR')
+ end
+
+ it 'returns VARCHAR2(4000) if :db_adapter is :oracle' do
+ allow(datatable).to receive(:db_adapter).and_return(:oracle)
+ expect(column.send(:type_cast)).to eq('VARCHAR2(4000)')
+ end
+
+ it 'returns VARCHAR2(4000) if :db_adapter is :oracleenhanced' do
+ allow(datatable).to receive(:db_adapter).and_return(:oracleenhanced)
+ expect(column.send(:type_cast)).to eq('VARCHAR2(4000)')
+ end
+
+ it 'returns CHAR if :db_adapter is :mysql2' do
+ allow(datatable).to receive(:db_adapter).and_return(:mysql2)
+ expect(column.send(:type_cast)).to eq('CHAR')
+ end
+
+ it 'returns CHAR if :db_adapter is :trilogy' do
+ allow(datatable).to receive(:db_adapter).and_return(:trilogy)
+ expect(column.send(:type_cast)).to eq('CHAR')
+ end
+
+ it 'returns CHAR if :db_adapter is :mysql' do
+ allow(datatable).to receive(:db_adapter).and_return(:mysql)
+ expect(column.send(:type_cast)).to eq('CHAR')
+ end
+
+ it 'returns TEXT if :db_adapter is :sqlite' do
+ allow(datatable).to receive(:db_adapter).and_return(:sqlite)
+ expect(column.send(:type_cast)).to eq('TEXT')
+ end
+
+ it 'returns TEXT if :db_adapter is :sqlite3' do
+ allow(datatable).to receive(:db_adapter).and_return(:sqlite3)
+ expect(column.send(:type_cast)).to eq('TEXT')
+ end
+
+ it 'returns VARCHAR(4000) if :db_adapter is :sqlserver' do
+ allow(datatable).to receive(:db_adapter).and_return(:sqlserver)
+ expect(column.send(:type_cast)).to eq('VARCHAR(4000)')
+ end
+ end
+
+ describe 'when empty column' do
+ before { datatable.params[:columns]['0'][:data] = '' }
+
+ let(:message) { 'Unknown column. Check that `data` field is filled on JS side with the column name' }
+
+ it 'raises error' do
+ expect { datatable.to_json }.to raise_error(AjaxDatatablesRails::Error::InvalidSearchColumn).with_message(message)
+ end
+ end
+
+ describe 'when unknown column' do
+ before { datatable.params[:columns]['0'][:data] = 'foo' }
+
+ let(:message) { "Check that column 'foo' exists in view_columns" }
+
+ it 'raises error' do
+ expect { datatable.to_json }.to raise_error(AjaxDatatablesRails::Error::InvalidSearchColumn).with_message(message)
+ end
+ end
+end
diff --git a/spec/ajax_datatables_rails/datatable/datatable_spec.rb b/spec/ajax_datatables_rails/datatable/datatable_spec.rb
new file mode 100644
index 00000000..fa12005d
--- /dev/null
+++ b/spec/ajax_datatables_rails/datatable/datatable_spec.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AjaxDatatablesRails::Datatable::Datatable do
+
+ let(:datatable) { ComplexDatatable.new(sample_params).datatable }
+ let(:datatable_json) { ComplexDatatable.new(sample_params_json).datatable }
+ let(:order_option) { { '0' => { 'column' => '0', 'dir' => 'asc' }, '1' => { 'column' => '1', 'dir' => 'desc' } } }
+ let(:order_option_json) { [{ 'column' => '0', 'dir' => 'asc' }, { 'column' => '1', 'dir' => 'desc' }] }
+
+ shared_examples 'order methods' do
+ it 'is orderable' do
+ expect(datatable.orderable?).to be(true)
+ end
+
+ it 'is not orderable' do
+ datatable.options[:order] = nil
+ expect(datatable.orderable?).to be(false)
+ end
+
+ it 'has 2 orderable columns' do
+ datatable.options[:order] = order_option
+ expect(datatable.orders.count).to eq(2)
+ end
+
+ it 'first column ordered by ASC' do
+ datatable.options[:order] = order_option
+ expect(datatable.orders.first.direction).to eq('ASC')
+ end
+
+ it 'first column ordered by DESC' do
+ datatable.options[:order] = order_option
+ expect(datatable.orders.last.direction).to eq('DESC')
+ end
+
+ it 'child class' do
+ expect(datatable.orders.first).to be_a(AjaxDatatablesRails::Datatable::SimpleOrder)
+ end
+ end
+
+ shared_examples 'columns methods' do
+ it 'has 8 columns' do
+ expect(datatable.columns.count).to eq(8)
+ end
+
+ it 'child class' do
+ expect(datatable.columns.first).to be_a(AjaxDatatablesRails::Datatable::Column)
+ end
+ end
+
+ describe 'with query params' do
+ it_behaves_like 'order methods'
+ it_behaves_like 'columns methods'
+ end
+
+ describe 'with json params' do
+ let(:order_option) { order_option_json }
+ let(:datatable) { datatable_json }
+
+ it_behaves_like 'order methods'
+ it_behaves_like 'columns methods'
+ end
+
+ describe 'search methods' do
+ it 'is searchable' do
+ datatable.options[:search][:value] = 'atom'
+ expect(datatable.searchable?).to be(true)
+ end
+
+ it 'is not searchable' do
+ datatable.options[:search][:value] = nil
+ expect(datatable.searchable?).to be(false)
+ end
+
+ it 'child class' do
+ expect(datatable.search).to be_a(AjaxDatatablesRails::Datatable::SimpleSearch)
+ end
+ end
+
+ describe 'option methods' do
+ describe '#paginate?' do
+ it {
+ expect(datatable.paginate?).to be(true)
+ }
+ end
+
+ describe '#per_page' do
+ context 'when params[:length] is missing' do
+ it 'defaults to 10' do
+ expect(datatable.per_page).to eq(10)
+ end
+ end
+
+ context 'when params[:length] is passed' do
+ let(:datatable) { ComplexDatatable.new({ length: '20' }).datatable }
+
+ it 'matches the value on view params[:length]' do
+ expect(datatable.per_page).to eq(20)
+ end
+ end
+ end
+
+ describe '#offset' do
+ context 'when params[:start] is missing' do
+ it 'defaults to 0' do
+ expect(datatable.offset).to eq(0)
+ end
+ end
+
+ context 'when params[:start] is passed' do
+ let(:datatable) { ComplexDatatable.new({ start: '11' }).datatable }
+
+ it 'matches the value on view params[:start]' do
+ expect(datatable.offset).to eq(11)
+ end
+ end
+ end
+
+ describe '#page' do
+ let(:datatable) { ComplexDatatable.new({ start: '11' }).datatable }
+
+ it 'calculates page number from params[:start] and #per_page' do
+ expect(datatable.page).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/ajax_datatables_rails/datatable/simple_order_spec.rb b/spec/ajax_datatables_rails/datatable/simple_order_spec.rb
new file mode 100644
index 00000000..36d2260f
--- /dev/null
+++ b/spec/ajax_datatables_rails/datatable/simple_order_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AjaxDatatablesRails::Datatable::SimpleOrder do
+
+ let(:parent) { ComplexDatatable.new(sample_params) }
+ let(:datatable) { parent.datatable }
+ let(:options) { ActiveSupport::HashWithIndifferentAccess.new({ 'column' => '1', 'dir' => 'desc' }) }
+ let(:simple_order) { described_class.new(datatable, options) }
+
+ describe 'option methods' do
+ it 'sql query' do
+ expect(simple_order.query('firstname')).to eq('firstname DESC')
+ end
+ end
+
+ describe 'option methods with nulls last' do
+ describe 'using class option' do
+ before { parent.nulls_last = true }
+ after { parent.nulls_last = false }
+
+ it 'sql query' do
+ skip('unsupported database adapter') if RunningSpec.oracle?
+
+ expect(simple_order.query('email')).to eq(
+ "email DESC #{nulls_last_sql(parent)}"
+ )
+ end
+ end
+
+ describe 'using column option' do
+ let(:parent) { DatatableOrderNullsLast.new(sample_params) }
+ let(:sorted_datatable) { parent.datatable }
+ let(:nulls_last_order) { described_class.new(sorted_datatable, options) }
+
+ context 'with postgres database adapter' do
+ before { parent.db_adapter = :pg }
+
+ it 'sql query' do
+ expect(nulls_last_order.query('email')).to eq('email DESC NULLS LAST')
+ end
+ end
+
+ context 'with postgis database adapter' do
+ before { parent.db_adapter = :postgis }
+
+ it 'sql query' do
+ expect(nulls_last_order.query('email')).to eq('email DESC NULLS LAST')
+ end
+ end
+
+ context 'with sqlite database adapter' do
+ before { parent.db_adapter = :sqlite }
+
+ it 'sql query' do
+ expect(nulls_last_order.query('email')).to eq('email DESC IS NULL')
+ end
+ end
+
+ context 'with mysql database adapter' do
+ before { parent.db_adapter = :mysql }
+
+ it 'sql query' do
+ expect(nulls_last_order.query('email')).to eq('email DESC IS NULL')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/ajax_datatables_rails/datatable/simple_search_spec.rb b/spec/ajax_datatables_rails/datatable/simple_search_spec.rb
new file mode 100644
index 00000000..a13bed72
--- /dev/null
+++ b/spec/ajax_datatables_rails/datatable/simple_search_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AjaxDatatablesRails::Datatable::SimpleSearch do
+
+ let(:options) { ActiveSupport::HashWithIndifferentAccess.new({ 'value' => 'search value', 'regex' => 'true' }) }
+ let(:simple_search) { described_class.new(options) }
+
+ describe 'option methods' do
+ it 'regexp?' do
+ expect(simple_search.regexp?).to be(true)
+ end
+
+ it 'value' do
+ expect(simple_search.value).to eq('search value')
+ end
+ end
+end
diff --git a/spec/ajax_datatables_rails/orm/active_record_count_records_spec.rb b/spec/ajax_datatables_rails/orm/active_record_count_records_spec.rb
new file mode 100644
index 00000000..70c4de4c
--- /dev/null
+++ b/spec/ajax_datatables_rails/orm/active_record_count_records_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AjaxDatatablesRails::ORM::ActiveRecord do
+
+ let(:datatable) { ComplexDatatable.new(sample_params) }
+ let(:records) { User.all }
+
+ describe '#records_total_count' do
+ context 'when ungrouped results' do
+ it 'returns the count' do
+ expect(datatable.send(:records_total_count)).to eq records.count
+ end
+ end
+
+ context 'when grouped results' do
+ let(:datatable) { GroupedDatatable.new(sample_params) }
+
+ it 'returns the count' do
+ expect(datatable.send(:records_total_count)).to eq records.count
+ end
+ end
+ end
+
+ describe '#records_filtered_count' do
+ context 'when ungrouped results' do
+ it 'returns the count' do
+ expect(datatable.send(:records_filtered_count)).to eq records.count
+ end
+ end
+
+ context 'when grouped results' do
+ let(:datatable) { GroupedDatatable.new(sample_params) }
+
+ it 'returns the count' do
+ expect(datatable.send(:records_filtered_count)).to eq records.count
+ end
+ end
+ end
+end
diff --git a/spec/ajax_datatables_rails/orm/active_record_filter_records_spec.rb b/spec/ajax_datatables_rails/orm/active_record_filter_records_spec.rb
new file mode 100644
index 00000000..4bcbd541
--- /dev/null
+++ b/spec/ajax_datatables_rails/orm/active_record_filter_records_spec.rb
@@ -0,0 +1,662 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AjaxDatatablesRails::ORM::ActiveRecord do
+
+ let(:datatable) { ComplexDatatable.new(sample_params) }
+ let(:records) { User.all }
+
+ describe '#filter_records' do
+ it 'requires a records collection as argument' do
+ expect { datatable.filter_records }.to raise_error(ArgumentError)
+ end
+
+ context 'with simple search' do
+ before do
+ datatable.params[:search] = { value: 'msmith' }
+ end
+
+ it 'performs a simple search first' do
+ allow(datatable).to receive(:build_conditions_for_datatable)
+ datatable.filter_records(records)
+ expect(datatable).to have_received(:build_conditions_for_datatable)
+ end
+
+ it 'does not search unsearchable fields' do
+ criteria = datatable.filter_records(records)
+ expect(criteria.to_sql).to_not include('email_hash')
+ end
+ end
+
+ it 'performs a composite search second' do
+ datatable.params[:search] = { value: '' }
+ allow(datatable).to receive(:build_conditions_for_selected_columns)
+ datatable.filter_records(records)
+ expect(datatable).to have_received(:build_conditions_for_selected_columns)
+ end
+ end
+
+ describe '#build_conditions' do
+ before do
+ create(:user, username: 'johndoe', email: 'johndoe@example.com')
+ create(:user, username: 'msmith', email: 'mary.smith@example.com')
+ create(:user, username: 'hsmith', email: 'henry.smith@example.net')
+ end
+
+ context 'with column and global search' do
+ before do
+ datatable.params[:search] = { value: 'example.com', regex: 'false' }
+ datatable.params[:columns]['0'][:search][:value] = 'smith'
+ end
+
+ it 'return a filtered set of records' do
+ query = datatable.build_conditions
+ results = records.where(query).map(&:username)
+ expect(results).to include('msmith')
+ expect(results).to_not include('johndoe')
+ expect(results).to_not include('hsmith')
+ end
+ end
+ end
+
+ describe '#build_conditions_for_datatable' do
+ before do
+ create(:user, username: 'johndoe', email: 'johndoe@example.com')
+ create(:user, username: 'msmith', email: 'mary.smith@example.com')
+ end
+
+ it 'returns an Arel object' do
+ datatable.params[:search] = { value: 'msmith' }
+ result = datatable.build_conditions_for_datatable
+ expect(result).to be_a(Arel::Nodes::Grouping)
+ end
+
+ context 'when no search query' do
+ it 'returns empty query' do
+ datatable.params[:search] = { value: '' }
+ expect(datatable.build_conditions_for_datatable).to be_blank
+ end
+ end
+
+ context 'when none of columns are connected' do
+ before do
+ allow(datatable).to receive(:searchable_columns).and_return([])
+ end
+
+ context 'when search value is a string' do
+ before do
+ datatable.params[:search] = { value: 'msmith' }
+ end
+
+ it 'returns empty query result' do
+ expect(datatable.build_conditions_for_datatable).to be_blank
+ end
+
+ it 'returns filtered results' do
+ query = datatable.build_conditions_for_datatable
+ results = records.where(query).map(&:username)
+ expect(results).to eq %w[johndoe msmith]
+ end
+ end
+
+ context 'when search value is space-separated string' do
+ before do
+ datatable.params[:search] = { value: 'foo bar' }
+ end
+
+ it 'returns empty query result' do
+ expect(datatable.build_conditions_for_datatable).to be_blank
+ end
+
+ it 'returns filtered results' do
+ query = datatable.build_conditions_for_datatable
+ results = records.where(query).map(&:username)
+ expect(results).to eq %w[johndoe msmith]
+ end
+ end
+ end
+
+ context 'with search query' do
+ context 'when search value is a string' do
+ before do
+ datatable.params[:search] = { value: 'john', regex: 'false' }
+ end
+
+ it 'returns a filtering query' do
+ query = datatable.build_conditions_for_datatable
+ results = records.where(query).map(&:username)
+ expect(results).to include('johndoe')
+ expect(results).to_not include('msmith')
+ end
+ end
+
+ context 'when search value is space-separated string' do
+ before do
+ datatable.params[:search] = { value: 'john doe', regex: 'false' }
+ end
+
+ it 'returns a filtering query' do
+ query = datatable.build_conditions_for_datatable
+ results = records.where(query).map(&:username)
+ expect(results).to eq ['johndoe']
+ expect(results).to_not include('msmith')
+ end
+ end
+
+ # TODO: improve (or delete?) this test
+ context 'when column.search_query returns nil' do
+ let(:datatable) { DatatableCondUnknown.new(sample_params) }
+
+ before do
+ datatable.params[:search] = { value: 'john doe', regex: 'false' }
+ end
+
+ it 'does not raise error' do
+ allow_any_instance_of(AjaxDatatablesRails::Datatable::Column).to receive(:valid_search_condition?).and_return(true) # rubocop:disable RSpec/AnyInstance
+
+ expect {
+ datatable.data.size
+ }.to_not raise_error
+ end
+ end
+ end
+ end
+
+ describe '#build_conditions_for_selected_columns' do
+ before do
+ create(:user, username: 'johndoe', email: 'johndoe@example.com')
+ create(:user, username: 'msmith', email: 'mary.smith@example.com')
+ end
+
+ context 'when columns include search query' do
+ before do
+ datatable.params[:columns]['0'][:search][:value] = 'doe'
+ datatable.params[:columns]['1'][:search][:value] = 'example'
+ end
+
+ it 'returns an Arel object' do
+ result = datatable.build_conditions_for_selected_columns
+ expect(result).to be_a(Arel::Nodes::And)
+ end
+
+ if RunningSpec.postgresql?
+ context 'when db_adapter is postgresql' do
+ it 'can call #to_sql on returned object' do
+ result = datatable.build_conditions_for_selected_columns
+ expect(result).to respond_to(:to_sql)
+ expect(result.to_sql).to eq(
+ "CAST(\"users\".\"username\" AS VARCHAR) ILIKE '%doe%' AND CAST(\"users\".\"email\" AS VARCHAR) ILIKE '%example%'"
+ )
+ end
+ end
+ end
+
+ if RunningSpec.oracle?
+ context 'when db_adapter is oracle' do
+ it 'can call #to_sql on returned object' do
+ result = datatable.build_conditions_for_selected_columns
+ expect(result).to respond_to(:to_sql)
+ expect(result.to_sql).to eq(
+ "UPPER(CAST(\"USERS\".\"USERNAME\" AS VARCHAR2(4000))) LIKE UPPER('%doe%') AND UPPER(CAST(\"USERS\".\"EMAIL\" AS VARCHAR2(4000))) LIKE UPPER('%example%')" # rubocop:disable Layout/LineLength
+ )
+ end
+ end
+ end
+
+ if RunningSpec.mysql?
+ context 'when db_adapter is mysql2' do # rubocop:disable RSpec/RepeatedExampleGroupBody
+ it 'can call #to_sql on returned object' do
+ result = datatable.build_conditions_for_selected_columns
+ expect(result).to respond_to(:to_sql)
+ expect(result.to_sql).to eq(
+ "CAST(`users`.`username` AS CHAR) LIKE '%doe%' AND CAST(`users`.`email` AS CHAR) LIKE '%example%'"
+ )
+ end
+ end
+
+ context 'when db_adapter is trilogy' do # rubocop:disable RSpec/RepeatedExampleGroupBody
+ it 'can call #to_sql on returned object' do
+ result = datatable.build_conditions_for_selected_columns
+ expect(result).to respond_to(:to_sql)
+ expect(result.to_sql).to eq(
+ "CAST(`users`.`username` AS CHAR) LIKE '%doe%' AND CAST(`users`.`email` AS CHAR) LIKE '%example%'"
+ )
+ end
+ end
+ end
+ end
+
+ it 'calls #build_conditions_for_selected_columns' do
+ allow(datatable).to receive(:build_conditions_for_selected_columns)
+ datatable.build_conditions
+ expect(datatable).to have_received(:build_conditions_for_selected_columns)
+ end
+
+ context 'with search values in columns' do
+ before do
+ datatable.params[:columns]['0'][:search][:value] = 'doe'
+ end
+
+ it 'returns a filtered set of records' do
+ query = datatable.build_conditions_for_selected_columns
+ results = records.where(query).map(&:username)
+ expect(results).to include('johndoe')
+ expect(results).to_not include('msmith')
+ end
+ end
+ end
+
+ describe 'filter conditions' do
+ context 'with date condition' do
+ describe 'it can filter records with condition :date_range' do
+ let(:datatable) { DatatableCondDate.new(sample_params) }
+
+ before do
+ create(:user, username: 'johndoe', email: 'johndoe@example.com', last_name: 'Doe', created_at: '01/01/2000')
+ create(:user, username: 'msmith', email: 'mary.smith@example.com', last_name: 'Smith', created_at: '01/02/2000')
+ end
+
+ context 'when range is empty' do
+ it 'does not filter records' do
+ datatable.params[:columns]['7'][:search][:value] = '-'
+ expect(datatable.data.size).to eq 2
+ item = datatable.data.first
+ expect(item[:last_name]).to eq 'Doe'
+ end
+ end
+
+ context 'when start date is filled' do
+ it 'filters records created after this date' do
+ datatable.params[:columns]['7'][:search][:value] = '31/12/1999-'
+ expect(datatable.data.size).to eq 2
+ end
+ end
+
+ context 'when end date is filled' do
+ it 'filters records created before this date' do
+ datatable.params[:columns]['7'][:search][:value] = '-31/12/1999'
+ expect(datatable.data.size).to eq 0
+ end
+ end
+
+ context 'when both date are filled' do
+ it 'filters records created between the range' do
+ datatable.params[:columns]['7'][:search][:value] = '01/12/1999-15/01/2000'
+ expect(datatable.data.size).to eq 1
+ end
+ end
+
+ context 'when another filter is active' do
+ context 'when range is empty' do
+ it 'filters records' do
+ datatable.params[:columns]['0'][:search][:value] = 'doe'
+ datatable.params[:columns]['7'][:search][:value] = '-'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:last_name]).to eq 'Doe'
+ end
+ end
+
+ context 'when start date is filled' do
+ it 'filters records' do
+ datatable.params[:columns]['0'][:search][:value] = 'doe'
+ datatable.params[:columns]['7'][:search][:value] = '01/12/1999-'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:last_name]).to eq 'Doe'
+ end
+ end
+
+ context 'when end date is filled' do
+ it 'filters records' do
+ datatable.params[:columns]['0'][:search][:value] = 'doe'
+ datatable.params[:columns]['7'][:search][:value] = '-15/01/2000'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:last_name]).to eq 'Doe'
+ end
+ end
+
+ context 'when both date are filled' do
+ it 'filters records' do
+ datatable.params[:columns]['0'][:search][:value] = 'doe'
+ datatable.params[:columns]['7'][:search][:value] = '01/12/1999-15/01/2000'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:last_name]).to eq 'Doe'
+ end
+ end
+ end
+ end
+ end
+
+ context 'with numeric condition' do
+ before do
+ create(:user, first_name: 'john', post_id: 1)
+ create(:user, first_name: 'mary', post_id: 2)
+ end
+
+ describe 'it can filter records with condition :eq' do
+ let(:datatable) { DatatableCondEq.new(sample_params) }
+
+ it 'filters records matching' do
+ datatable.params[:columns]['5'][:search][:value] = 1
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:first_name]).to eq 'john'
+ end
+ end
+
+ describe 'it can filter records with condition :not_eq' do
+ let(:datatable) { DatatableCondNotEq.new(sample_params) }
+
+ it 'filters records matching' do
+ datatable.params[:columns]['5'][:search][:value] = 1
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:first_name]).to eq 'mary'
+ end
+ end
+
+ describe 'it can filter records with condition :lt' do
+ let(:datatable) { DatatableCondLt.new(sample_params) }
+
+ it 'filters records matching' do
+ datatable.params[:columns]['5'][:search][:value] = 2
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:first_name]).to eq 'john'
+ end
+ end
+
+ describe 'it can filter records with condition :gt' do
+ let(:datatable) { DatatableCondGt.new(sample_params) }
+
+ it 'filters records matching' do
+ datatable.params[:columns]['5'][:search][:value] = 1
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:first_name]).to eq 'mary'
+ end
+ end
+
+ describe 'it can filter records with condition :lteq' do
+ let(:datatable) { DatatableCondLteq.new(sample_params) }
+
+ it 'filters records matching' do
+ datatable.params[:columns]['5'][:search][:value] = 2
+ expect(datatable.data.size).to eq 2
+ end
+ end
+
+ describe 'it can filter records with condition :gteq' do
+ let(:datatable) { DatatableCondGteq.new(sample_params) }
+
+ it 'filters records matching' do
+ datatable.params[:columns]['5'][:search][:value] = 1
+ expect(datatable.data.size).to eq 2
+ end
+ end
+
+ describe 'it can filter records with condition :in' do
+ let(:datatable) { DatatableCondIn.new(sample_params) }
+
+ it 'filters records matching' do
+ datatable.params[:columns]['5'][:search][:value] = [1]
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:first_name]).to eq 'john'
+ end
+ end
+
+ describe 'it can filter records with condition :in with regex' do
+ let(:datatable) { DatatableCondInWithRegex.new(sample_params) }
+
+ it 'filters records matching' do
+ datatable.params[:columns]['5'][:search][:value] = '1|2'
+ datatable.params[:order]['0'] = { column: '5', dir: 'asc' }
+ expect(datatable.data.size).to eq 2
+ item = datatable.data.first
+ expect(item[:first_name]).to eq 'john'
+ end
+ end
+
+ describe 'Integer overflows' do
+ let(:datatable) { DatatableCondEq.new(sample_params) }
+ let(:largest_postgresql_integer_value) { 2_147_483_647 }
+ let(:smallest_postgresql_integer_value) { -2_147_483_648 }
+
+ before do
+ create(:user, first_name: 'john', post_id: 1)
+ create(:user, first_name: 'mary', post_id: 2)
+ create(:user, first_name: 'phil', post_id: largest_postgresql_integer_value)
+ end
+
+ it 'returns an empty result if input value is too large' do
+ datatable.params[:columns]['5'][:search][:value] = largest_postgresql_integer_value + 1
+ expect(datatable.data.size).to eq 0
+ end
+
+ it 'returns an empty result if input value is too small' do
+ datatable.params[:columns]['5'][:search][:value] = smallest_postgresql_integer_value - 1
+ expect(datatable.data.size).to eq 0
+ end
+
+ it 'returns the matching user' do
+ datatable.params[:columns]['5'][:search][:value] = largest_postgresql_integer_value
+ expect(datatable.data.size).to eq 1
+ end
+ end
+ end
+
+ context 'with proc condition' do
+ describe 'it can filter records with lambda/proc condition' do
+ let(:datatable) { DatatableCondProc.new(sample_params) }
+
+ before do
+ create(:user, username: 'johndoe', email: 'johndoe@example.com')
+ create(:user, username: 'johndie', email: 'johndie@example.com')
+ create(:user, username: 'msmith', email: 'mary.smith@example.com')
+ end
+
+ it 'filters records matching' do
+ datatable.params[:columns]['0'][:search][:value] = 'john'
+ expect(datatable.data.size).to eq 2
+ item = datatable.data.first
+ expect(item[:username]).to eq 'johndie'
+ end
+ end
+ end
+
+ context 'with string condition' do
+ describe 'it can filter records with condition :start_with' do
+ let(:datatable) { DatatableCondStartWith.new(sample_params) }
+
+ before do
+ create(:user, first_name: 'John')
+ create(:user, first_name: 'Mary')
+ end
+
+ it 'filters records matching' do
+ datatable.params[:columns]['2'][:search][:value] = 'Jo'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:first_name]).to eq 'John'
+ end
+ end
+
+ describe 'it can filter records with condition :end_with' do
+ let(:datatable) { DatatableCondEndWith.new(sample_params) }
+
+ before do
+ create(:user, last_name: 'JOHN')
+ create(:user, last_name: 'MARY')
+ end
+
+ if RunningSpec.oracle?
+ context 'when db_adapter is oracleenhanced' do
+ it 'filters records matching' do
+ datatable.params[:columns]['3'][:search][:value] = 'RY'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:last_name]).to eq 'MARY'
+ end
+ end
+ else
+ it 'filters records matching' do
+ datatable.params[:columns]['3'][:search][:value] = 'ry'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:last_name]).to eq 'MARY'
+ end
+ end
+ end
+
+ describe 'it can filter records with condition :like' do
+ let(:datatable) { DatatableCondLike.new(sample_params) }
+
+ before do
+ create(:user, email: 'john@foo.com')
+ create(:user, email: 'mary@bar.com')
+ end
+
+ it 'filters records matching' do
+ datatable.params[:columns]['1'][:search][:value] = 'foo'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:email]).to eq 'john@foo.com'
+ end
+ end
+
+ describe 'it can filter records with condition :string_eq' do
+ let(:datatable) { DatatableCondStringEq.new(sample_params) }
+
+ before do
+ create(:user, email: 'john@foo.com')
+ create(:user, email: 'mary@bar.com')
+ end
+
+ it 'filters records matching' do
+ datatable.params[:columns]['1'][:search][:value] = 'john@foo.com'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:email]).to eq 'john@foo.com'
+ end
+ end
+
+ describe 'it can filter records with condition :string_in' do
+ let(:datatable) { DatatableCondStringIn.new(sample_params) }
+
+ before do
+ create(:user, email: 'john@foo.com')
+ create(:user, email: 'mary@bar.com')
+ create(:user, email: 'henry@baz.com')
+ end
+
+ it 'filters records matching' do
+ datatable.params[:columns]['1'][:search][:value] = 'john@foo.com'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:email]).to eq 'john@foo.com'
+ end
+
+ it 'filters records matching with multiple' do
+ datatable.params[:columns]['1'][:search][:value] = 'john@foo.com|henry@baz.com'
+ expect(datatable.data.size).to eq 2
+ items = datatable.data.sort_by { |h| h[:email] }
+ item_first = items.first
+ item_last = items.last
+ expect(item_first[:email]).to eq 'henry@baz.com'
+ expect(item_last[:email]).to eq 'john@foo.com'
+ end
+
+ it 'filters records matching with multiple contains not found' do
+ datatable.params[:columns]['1'][:search][:value] = 'john@foo.com|henry_not@baz.com'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:email]).to eq 'john@foo.com'
+ end
+ end
+
+ describe 'it can filter records with condition :null_value' do
+ let(:datatable) { DatatableCondNullValue.new(sample_params) }
+
+ before do
+ create(:user, first_name: 'john', email: 'foo@bar.com')
+ create(:user, first_name: 'mary', email: nil)
+ end
+
+ context 'when condition is NULL' do
+ it 'filters records matching' do
+ datatable.params[:columns]['1'][:search][:value] = 'NULL'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:first_name]).to eq 'mary'
+ end
+ end
+
+ context 'when condition is !NULL' do
+ it 'filters records matching' do
+ datatable.params[:columns]['1'][:search][:value] = '!NULL'
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:first_name]).to eq 'john'
+ end
+ end
+ end
+ end
+
+ context 'with unknown condition' do
+ let(:datatable) { DatatableCondUnknown.new(sample_params) }
+
+ before do
+ datatable.params[:search] = { value: 'john doe', regex: 'false' }
+ end
+
+ it 'raises error' do
+ expect {
+ datatable.data.size
+ }.to raise_error(AjaxDatatablesRails::Error::InvalidSearchCondition).with_message('foo')
+ end
+ end
+
+ context 'with custom column' do
+ describe 'it can filter records with custom column' do
+ let(:datatable) { DatatableCustomColumn.new(sample_params) }
+
+ before do
+ create(:user, username: 'msmith', email: 'mary.smith@example.com', first_name: 'Mary', last_name: 'Smith')
+ create(:user, username: 'jsmith', email: 'john.smith@example.com', first_name: 'John', last_name: 'Smith')
+ create(:user, username: 'johndoe', email: 'johndoe@example.com', first_name: 'John', last_name: 'Doe')
+ end
+
+ it 'filters records' do
+ skip('unsupported database adapter') if RunningSpec.oracle? || RunningSpec.sqlite?
+
+ datatable.params[:columns]['4'][:search][:value] = 'John'
+ datatable.params[:order]['0'][:column] = '4'
+ expect(datatable.data.size).to eq 2
+ item = datatable.data.first
+ expect(item[:full_name]).to eq 'John Doe'
+ end
+ end
+ end
+ end
+
+ describe 'formatter option' do
+ let(:datatable) { DatatableWithFormater.new(sample_params) }
+
+ before do
+ create(:user, username: 'johndoe', email: 'johndoe@example.com', last_name: 'DOE')
+ create(:user, username: 'msmith', email: 'mary.smith@example.com', last_name: 'SMITH')
+ datatable.params[:columns]['3'][:search][:value] = 'doe'
+ end
+
+ it 'can transform search value before asking the database' do
+ expect(datatable.data.size).to eq 1
+ item = datatable.data.first
+ expect(item[:last_name]).to eq 'DOE'
+ end
+ end
+end
diff --git a/spec/ajax_datatables_rails/orm/active_record_paginate_records_spec.rb b/spec/ajax_datatables_rails/orm/active_record_paginate_records_spec.rb
new file mode 100644
index 00000000..a33c834d
--- /dev/null
+++ b/spec/ajax_datatables_rails/orm/active_record_paginate_records_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AjaxDatatablesRails::ORM::ActiveRecord do
+
+ let(:datatable) { ComplexDatatable.new(sample_params) }
+ let(:records) { User.all }
+
+ before do
+ create(:user, username: 'johndoe', email: 'johndoe@example.com')
+ create(:user, username: 'msmith', email: 'mary.smith@example.com')
+ end
+
+ describe '#paginate_records' do
+ it 'requires a records collection argument' do
+ expect { datatable.paginate_records }.to raise_error(ArgumentError)
+ end
+
+ it 'paginates records properly' do # rubocop:disable RSpec/ExampleLength
+ if RunningSpec.oracle?
+ if Rails.version.in? %w[4.2.11]
+ expect(datatable.paginate_records(records).to_sql).to include(
+ 'rownum <= 10'
+ )
+ else
+ expect(datatable.paginate_records(records).to_sql).to include(
+ 'rownum <= (0 + 10)'
+ )
+ end
+ else
+ expect(datatable.paginate_records(records).to_sql).to include(
+ 'LIMIT 10 OFFSET 0'
+ )
+ end
+
+ datatable.params[:start] = '26'
+ datatable.params[:length] = '25'
+ if RunningSpec.oracle?
+ if Rails.version.in? %w[4.2.11]
+ expect(datatable.paginate_records(records).to_sql).to include(
+ 'rownum <= 51'
+ )
+ else
+ expect(datatable.paginate_records(records).to_sql).to include(
+ 'rownum <= (26 + 25)'
+ )
+ end
+ else
+ expect(datatable.paginate_records(records).to_sql).to include(
+ 'LIMIT 25 OFFSET 26'
+ )
+ end
+ end
+
+ it 'depends on the value of #offset' do
+ allow(datatable.datatable).to receive(:offset)
+ datatable.paginate_records(records)
+ expect(datatable.datatable).to have_received(:offset)
+ end
+
+ it 'depends on the value of #per_page' do
+ allow(datatable.datatable).to receive(:per_page).at_least(:once).and_return(10)
+ datatable.paginate_records(records)
+ expect(datatable.datatable).to have_received(:per_page).at_least(:once)
+ end
+ end
+
+end
diff --git a/spec/ajax_datatables_rails/orm/active_record_sort_records_spec.rb b/spec/ajax_datatables_rails/orm/active_record_sort_records_spec.rb
new file mode 100644
index 00000000..369d859c
--- /dev/null
+++ b/spec/ajax_datatables_rails/orm/active_record_sort_records_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AjaxDatatablesRails::ORM::ActiveRecord do
+
+ let(:datatable) { ComplexDatatable.new(sample_params) }
+ let(:nulls_last_datatable) { DatatableOrderNullsLast.new(sample_params) }
+ let(:records) { User.all }
+
+ before do
+ create(:user, username: 'johndoe', email: 'johndoe@example.com')
+ create(:user, username: 'msmith', email: 'mary.smith@example.com')
+ end
+
+ describe '#sort_records' do
+ it 'returns a records collection sorted by :order params' do
+ # set to order Users by email in descending order
+ datatable.params[:order]['0'] = { column: '1', dir: 'desc' }
+ expect(datatable.sort_records(records).map(&:email)).to match(
+ ['mary.smith@example.com', 'johndoe@example.com']
+ )
+ end
+
+ it 'can handle multiple sorting columns' do
+ # set to order by Users username in ascending order, and
+ # by Users email in descending order
+ datatable.params[:order]['0'] = { column: '0', dir: 'asc' }
+ datatable.params[:order]['1'] = { column: '1', dir: 'desc' }
+ expect(datatable.sort_records(records).to_sql).to include(
+ 'ORDER BY users.username ASC, users.email DESC'
+ )
+ end
+
+ it 'does not sort a column which is not orderable' do
+ datatable.params[:order]['0'] = { column: '0', dir: 'asc' }
+ datatable.params[:order]['1'] = { column: '4', dir: 'desc' }
+
+ expect(datatable.sort_records(records).to_sql).to include(
+ 'ORDER BY users.username ASC'
+ )
+
+ expect(datatable.sort_records(records).to_sql).to_not include(
+ 'users.post_id DESC'
+ )
+ end
+ end
+
+ describe '#sort_records with nulls last using global config' do
+ before { datatable.nulls_last = true }
+ after { datatable.nulls_last = false }
+
+ it 'can handle multiple sorting columns' do
+ skip('unsupported database adapter') if RunningSpec.oracle?
+
+ # set to order by Users username in ascending order, and
+ # by Users email in descending order
+ datatable.params[:order]['0'] = { column: '0', dir: 'asc' }
+ datatable.params[:order]['1'] = { column: '1', dir: 'desc' }
+ expect(datatable.sort_records(records).to_sql).to include(
+ "ORDER BY users.username ASC #{nulls_last_sql(datatable)}, users.email DESC #{nulls_last_sql(datatable)}"
+ )
+ end
+ end
+
+ describe '#sort_records with nulls last using column config' do
+ it 'can handle multiple sorting columns' do
+ skip('unsupported database adapter') if RunningSpec.oracle?
+
+ # set to order by Users username in ascending order, and
+ # by Users email in descending order
+ nulls_last_datatable.params[:order]['0'] = { column: '0', dir: 'asc' }
+ nulls_last_datatable.params[:order]['1'] = { column: '1', dir: 'desc' }
+ expect(nulls_last_datatable.sort_records(records).to_sql).to include(
+ "ORDER BY users.username ASC, users.email DESC #{nulls_last_sql(datatable)}"
+ )
+ end
+ end
+
+end
diff --git a/spec/dummy/app/assets/config/manifest.js b/spec/dummy/app/assets/config/manifest.js
new file mode 100644
index 00000000..e69de29b
diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml
new file mode 100644
index 00000000..a9de41f6
--- /dev/null
+++ b/spec/dummy/config/database.yml
@@ -0,0 +1,25 @@
+<% adapter = ENV.fetch('http://github.com/DB_ADAPTER', 'postgresql') %>
+test:
+ adapter: <%= adapter %>
+ database: ajax_datatables_rails
+ encoding: utf8
+
+<% if adapter == 'postgresql' || adapter == 'postgis' %>
+ host: '127.0.0.1'
+ port: 5432
+ username: 'postgres'
+ password: 'postgres'
+<% elsif adapter == 'mysql2' || adapter == 'trilogy' %>
+ host: '127.0.0.1'
+ port: 3306
+ username: 'root'
+ password: 'root'
+<% elsif adapter == 'oracle_enhanced' %>
+ host: '127.0.0.1'
+ username: 'oracle_enhanced'
+ password: 'oracle_enhanced'
+ database: 'FREEPDB1'
+<% elsif adapter == 'sqlite3' %>
+ # database: ':memory:'
+ database: db/ajax_datatables_rails.sqlite3
+<% end %>
diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb
new file mode 100644
index 00000000..878c8133
--- /dev/null
+++ b/spec/dummy/config/routes.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+Rails.application.routes.draw do
+ # Add your own routes here, or remove this file if you don't have need for it.
+end
diff --git a/spec/dummy/config/storage.yml b/spec/dummy/config/storage.yml
new file mode 100644
index 00000000..5226545b
--- /dev/null
+++ b/spec/dummy/config/storage.yml
@@ -0,0 +1,3 @@
+test:
+ service: Disk
+ root: /tmp/ajax-datatables-rails/tmp/storage
diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb
new file mode 100644
index 00000000..653121e2
--- /dev/null
+++ b/spec/dummy/db/schema.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+ActiveRecord::Schema.define do
+ create_table :users, force: true do |t|
+ t.string :username
+ t.string :email
+ t.string :first_name
+ t.string :last_name
+ t.integer :post_id
+
+ t.timestamps null: false
+ end
+end
diff --git a/spec/dummy/log/.gitignore b/spec/dummy/log/.gitignore
new file mode 100644
index 00000000..397b4a76
--- /dev/null
+++ b/spec/dummy/log/.gitignore
@@ -0,0 +1 @@
+*.log
diff --git a/spec/dummy/public/favicon.ico b/spec/dummy/public/favicon.ico
new file mode 100644
index 00000000..e69de29b
diff --git a/spec/factories/user.rb b/spec/factories/user.rb
new file mode 100644
index 00000000..bcecd98d
--- /dev/null
+++ b/spec/factories/user.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :user do |f|
+ f.username { Faker::Internet.user_name }
+ f.email { Faker::Internet.email }
+ f.first_name { Faker::Name.first_name }
+ f.last_name { Faker::Name.last_name }
+ f.post_id { (1..100).to_a.sample }
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 3478266f..2ffbae70 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,3 +1,90 @@
+# frozen_string_literal: true
+
+require 'combustion'
+
+Combustion.path = 'spec/dummy'
+Combustion.initialize! :active_record, :action_controller
+
+require 'simplecov'
+require 'simplecov_json_formatter'
+require 'rspec'
+require 'rspec/rebound'
+require 'database_cleaner'
+require 'factory_bot'
+require 'faker'
require 'pry'
-require 'rails'
+
+# Start Simplecov
+SimpleCov.start do
+ formatter SimpleCov::Formatter::MultiFormatter.new([SimpleCov::Formatter::HTMLFormatter, SimpleCov::Formatter::JSONFormatter])
+ add_filter 'spec/'
+end
+
+# Configure RSpec
+RSpec.configure do |config|
+ config.include FactoryBot::Syntax::Methods
+
+ config.before(:suite) do
+ FactoryBot.find_definitions
+ end
+
+ config.color = true
+ config.fail_fast = false
+
+ config.order = :random
+ Kernel.srand config.seed
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+
+ config.before(:suite) do
+ DatabaseCleaner.clean_with(:truncation)
+ end
+
+ config.before do
+ DatabaseCleaner.strategy = :transaction
+ end
+
+ config.before do
+ DatabaseCleaner.start
+ end
+
+ config.after do
+ DatabaseCleaner.clean
+ end
+
+ # disable monkey patching
+ # see: https://relishapp.com/rspec/rspec-core/v/3-8/docs/configuration/zero-monkey-patching-mode
+ config.disable_monkey_patching!
+
+ if ENV.key?('GITHUB_ACTIONS')
+ config.around do |ex|
+ ex.run_with_retry retry: 2
+ end
+ end
+end
+
+class RunningSpec
+ def self.sqlite?
+ ENV['DB_ADAPTER'] == 'sqlite3'
+ end
+
+ def self.oracle?
+ ENV['DB_ADAPTER'] == 'oracle_enhanced'
+ end
+
+ def self.mysql?
+ %w[mysql2 trilogy].include?(ENV.fetch('http://github.com/DB_ADAPTER', nil))
+ end
+
+ def self.postgresql?
+ %w[postgresql postgis].include?(ENV.fetch('http://github.com/DB_ADAPTER', nil))
+ end
+end
+
+# Require our gem
require 'ajax-datatables-rails'
+
+# Load test helpers
+Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
diff --git a/spec/support/create_oracle_enhanced_users.sql b/spec/support/create_oracle_enhanced_users.sql
new file mode 100644
index 00000000..65dfb8ba
--- /dev/null
+++ b/spec/support/create_oracle_enhanced_users.sql
@@ -0,0 +1,7 @@
+alter database default tablespace USERS;
+
+CREATE USER oracle_enhanced IDENTIFIED BY oracle_enhanced;
+
+GRANT unlimited tablespace, create session, create table, create sequence,
+create procedure, create trigger, create view, create materialized view,
+create database link, create synonym, create type, ctxapp TO oracle_enhanced;
diff --git a/spec/support/datatables/complex_datatable.rb b/spec/support/datatables/complex_datatable.rb
new file mode 100644
index 00000000..2999c467
--- /dev/null
+++ b/spec/support/datatables/complex_datatable.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class ComplexDatatable < AjaxDatatablesRails::ActiveRecord
+ def view_columns
+ @view_columns ||= {
+ username: { source: 'User.username' },
+ email: { source: 'User.email' },
+ first_name: { source: 'User.first_name' },
+ last_name: { source: 'User.last_name' },
+ full_name: { source: 'full_name' },
+ post_id: { source: 'User.post_id', orderable: false },
+ email_hash: { source: 'email_hash', searchable: false },
+ created_at: { source: 'User.created_at' },
+ }
+ end
+
+ def data # rubocop:disable Metrics/MethodLength
+ records.map do |record|
+ {
+ username: record.username,
+ email: record.email,
+ first_name: record.first_name,
+ last_name: record.last_name,
+ full_name: record.full_name,
+ post_id: record.post_id,
+ email_hash: record.email_hash,
+ created_at: record.created_at,
+ }
+ end
+ end
+
+ def get_raw_records # rubocop:disable Naming/AccessorMethodName
+ User.all
+ end
+end
diff --git a/spec/support/datatables/complex_datatable_array.rb b/spec/support/datatables/complex_datatable_array.rb
new file mode 100644
index 00000000..bbcbf03a
--- /dev/null
+++ b/spec/support/datatables/complex_datatable_array.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class ComplexDatatableArray < ComplexDatatable
+ def data
+ records.map do |record|
+ [
+ record.username,
+ record.email,
+ record.first_name,
+ record.last_name,
+ record.post_id,
+ record.created_at,
+ ]
+ end
+ end
+end
diff --git a/spec/support/datatables/datatable_cond_date.rb b/spec/support/datatables/datatable_cond_date.rb
new file mode 100644
index 00000000..510f66b5
--- /dev/null
+++ b/spec/support/datatables/datatable_cond_date.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class DatatableCondDate < ComplexDatatable
+ def view_columns
+ super.deep_merge(created_at: { cond: :date_range })
+ end
+end
diff --git a/spec/support/datatables/datatable_cond_numeric.rb b/spec/support/datatables/datatable_cond_numeric.rb
new file mode 100644
index 00000000..12b016aa
--- /dev/null
+++ b/spec/support/datatables/datatable_cond_numeric.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class DatatableCondEq < ComplexDatatable
+ def view_columns
+ super.deep_merge(post_id: { cond: :eq })
+ end
+end
+
+class DatatableCondNotEq < ComplexDatatable
+ def view_columns
+ super.deep_merge(post_id: { cond: :not_eq })
+ end
+end
+
+class DatatableCondLt < ComplexDatatable
+ def view_columns
+ super.deep_merge(post_id: { cond: :lt })
+ end
+end
+
+class DatatableCondGt < ComplexDatatable
+ def view_columns
+ super.deep_merge(post_id: { cond: :gt })
+ end
+end
+
+class DatatableCondLteq < ComplexDatatable
+ def view_columns
+ super.deep_merge(post_id: { cond: :lteq })
+ end
+end
+
+class DatatableCondGteq < ComplexDatatable
+ def view_columns
+ super.deep_merge(post_id: { cond: :gteq })
+ end
+end
+
+class DatatableCondIn < ComplexDatatable
+ def view_columns
+ super.deep_merge(post_id: { cond: :in })
+ end
+end
+
+class DatatableCondInWithRegex < DatatableCondIn
+ def view_columns
+ super.deep_merge(post_id: { cond: :in, use_regex: false, orderable: true, formatter: ->(str) { cast_regex_value(str) } })
+ end
+
+ def cast_regex_value(value)
+ value.split('|').map(&:to_i)
+ end
+end
diff --git a/spec/support/datatables/datatable_cond_proc.rb b/spec/support/datatables/datatable_cond_proc.rb
new file mode 100644
index 00000000..3823fd12
--- /dev/null
+++ b/spec/support/datatables/datatable_cond_proc.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class DatatableCondProc < ComplexDatatable
+ def view_columns
+ super.deep_merge(username: { cond: custom_filter })
+ end
+
+ private
+
+ def custom_filter
+ ->(column, value) { ::Arel::Nodes::SqlLiteral.new(column.field.to_s).matches("#{value}%") }
+ end
+end
diff --git a/spec/support/datatables/datatable_cond_string.rb b/spec/support/datatables/datatable_cond_string.rb
new file mode 100644
index 00000000..2cc78c17
--- /dev/null
+++ b/spec/support/datatables/datatable_cond_string.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class DatatableCondStartWith < ComplexDatatable
+ def view_columns
+ super.deep_merge(first_name: { cond: :start_with })
+ end
+end
+
+class DatatableCondEndWith < ComplexDatatable
+ def view_columns
+ super.deep_merge(last_name: { cond: :end_with })
+ end
+end
+
+class DatatableCondLike < ComplexDatatable
+ def view_columns
+ super.deep_merge(email: { cond: :like })
+ end
+end
+
+class DatatableCondStringEq < ComplexDatatable
+ def view_columns
+ super.deep_merge(email: { cond: :string_eq })
+ end
+end
+
+class DatatableCondStringIn < ComplexDatatable
+ def view_columns
+ super.deep_merge(email: { cond: :string_in, formatter: ->(o) { o.split('|') } })
+ end
+end
+
+class DatatableCondNullValue < ComplexDatatable
+ def view_columns
+ super.deep_merge(email: { cond: :null_value })
+ end
+end
+
+class DatatableWithFormater < ComplexDatatable
+ def view_columns
+ super.deep_merge(last_name: { formatter: lambda(&:upcase) })
+ end
+end
diff --git a/spec/support/datatables/datatable_cond_unknown.rb b/spec/support/datatables/datatable_cond_unknown.rb
new file mode 100644
index 00000000..c730b575
--- /dev/null
+++ b/spec/support/datatables/datatable_cond_unknown.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class DatatableCondUnknown < ComplexDatatable
+ def view_columns
+ super.deep_merge(username: { cond: :foo })
+ end
+end
diff --git a/spec/support/datatables/datatable_custom_column.rb b/spec/support/datatables/datatable_custom_column.rb
new file mode 100644
index 00000000..2d8db393
--- /dev/null
+++ b/spec/support/datatables/datatable_custom_column.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class DatatableCustomColumn < ComplexDatatable
+ def view_columns
+ super.deep_merge(full_name: { cond: filter_full_name })
+ end
+
+ def get_raw_records # rubocop:disable Naming/AccessorMethodName
+ User.select("*, CONCAT(first_name, ' ', last_name) as full_name")
+ end
+
+ private
+
+ def filter_full_name
+ ->(_column, value) { ::Arel::Nodes::SqlLiteral.new("CONCAT(first_name, ' ', last_name)").matches("#{value}%") }
+ end
+end
diff --git a/spec/support/datatables/datatable_order_nulls_last.rb b/spec/support/datatables/datatable_order_nulls_last.rb
new file mode 100644
index 00000000..e1b3acdc
--- /dev/null
+++ b/spec/support/datatables/datatable_order_nulls_last.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class DatatableOrderNullsLast < ComplexDatatable
+ def view_columns
+ super.deep_merge(email: { nulls_last: true })
+ end
+end
diff --git a/spec/support/datatables/grouped_datatable_array.rb b/spec/support/datatables/grouped_datatable_array.rb
new file mode 100644
index 00000000..e23e0126
--- /dev/null
+++ b/spec/support/datatables/grouped_datatable_array.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class GroupedDatatable < ComplexDatatable
+
+ def get_raw_records # rubocop:disable Naming/AccessorMethodName
+ User.all.group(:id)
+ end
+end
diff --git a/spec/support/helpers/params.rb b/spec/support/helpers/params.rb
new file mode 100644
index 00000000..0323ab51
--- /dev/null
+++ b/spec/support/helpers/params.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+# rubocop:disable Metrics/MethodLength, Layout/HashAlignment
+def sample_params
+ ActionController::Parameters.new(
+ {
+ 'draw' => '1',
+ 'columns' => {
+ '0' => {
+ 'data' => 'username', 'name' => '', 'searchable' => 'true', 'orderable' => 'true',
+ 'search' => {
+ 'value' => '', 'regex' => 'false'
+ }
+ },
+ '1' => {
+ 'data' => 'email', 'name' => '', 'searchable' => 'true', 'orderable' => 'true',
+ 'search' => {
+ 'value' => '', 'regex' => 'false'
+ }
+ },
+ '2' => {
+ 'data' => 'first_name', 'name' => '', 'searchable' => 'true', 'orderable' => 'false',
+ 'search' => {
+ 'value' => '', 'regex' => 'false'
+ }
+ },
+ '3' => {
+ 'data' => 'last_name', 'name' => '', 'searchable' => 'true', 'orderable' => 'true',
+ 'search' => {
+ 'value' => '', 'regex' => 'false'
+ }
+ },
+ '4' => {
+ 'data' => 'full_name', 'name' => '', 'searchable' => 'true', 'orderable' => 'true',
+ 'search' => {
+ 'value' => '', 'regex' => 'false'
+ }
+ },
+ '5' => {
+ 'data' => 'post_id', 'name' => '', 'searchable' => 'true', 'orderable' => 'true',
+ 'search' => {
+ 'value' => '', 'regex' => 'false'
+ }
+ },
+ '6' => {
+ 'data' => 'email_hash', 'name' => '', 'searchable' => 'false', 'orderable' => 'true',
+ 'search' => {
+ 'value' => '', 'regex' => 'false'
+ }
+ },
+ '7' => {
+ 'data' => 'created_at', 'name' => '', 'searchable' => 'true', 'orderable' => 'true',
+ 'search' => {
+ 'value' => '', 'regex' => 'false'
+ }
+ },
+ },
+ 'order' => {
+ '0' => { 'column' => '0', 'dir' => 'asc' },
+ },
+ 'start' => '0',
+ 'length' => '10',
+ 'search' => {
+ 'value' => '', 'regex' => 'false'
+ },
+ '_' => '1423364387185',
+ }
+ )
+end
+# rubocop:enable Metrics/MethodLength, Layout/HashAlignment
+
+def sample_params_json
+ hash_params = sample_params.to_unsafe_h
+ hash_params['columns'] = hash_params['columns'].values
+ hash_params['order'] = hash_params['order'].values
+ ActionController::Parameters.new(hash_params)
+end
+
+def nulls_last_sql(datatable)
+ case datatable.db_adapter
+ when :pg, :postgresql, :postgres, :oracle, :postgis
+ 'NULLS LAST'
+ when :mysql, :mysql2, :trilogy, :sqlite, :sqlite3
+ 'IS NULL'
+ else
+ raise 'unsupported database adapter'
+ end
+end
diff --git a/spec/support/models/user.rb b/spec/support/models/user.rb
new file mode 100644
index 00000000..34d527e2
--- /dev/null
+++ b/spec/support/models/user.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'digest'
+
+class User < ActiveRecord::Base
+ def full_name
+ "#{first_name} #{last_name}"
+ end
+
+ def email_hash
+ return nil if email.nil?
+
+ Digest::SHA256.hexdigest email
+ end
+end
|