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..3f8009ae
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,85 @@
+---
+require:
+ - 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 05e61ce6..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,9 +0,0 @@
-language: ruby
-rvm:
- - 1.9.3
- - 2.0.0
- - 2.1.0
- - 2.1.1
- - 2.1.2
- - 2.1.3
- - 2.1.5
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 e53bf591..10407924 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,129 @@
# CHANGELOG
-## 0.3.1
+## 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
+## 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`
@@ -14,7 +134,7 @@
for this contribution.
* Moves paginator settings to configuration initializer.
-## 0.2.1
+## 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
@@ -32,22 +152,22 @@ 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
+## 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
@@ -65,3 +185,7 @@ Thanks to [iruca3](https://github.com/iruca3) for the fix.
* 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..47a46474 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-retry'
+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 fee0deb2..62fd2b29 100644
--- a/README.md
+++ b/README.md
@@ -1,604 +1,654 @@
# 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 (which
-includes a new API and features) and deprecated version 1.9.
+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
+
+## 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:
```ruby
-# specific version number
-gem 'ajax-datatables-rails', '0.1.2'
+gem 'ajax-datatables-rails'
+```
-# or, support on datatables 1.9
-gem 'ajax-datatables-rails', git: 'git://github.com/antillas21/ajax-datatables-rails.git', branch: 'legacy'
+And then execute:
+
+```sh
+$ bundle install
```
-If you have dataTables 1.10 in your project, then use the gem's latest version,
-or point to the `master` branch.
+We assume here that you have already installed [jQuery DataTables](https://datatables.net/).
+You can install jQuery DataTables :
-## Description
+* 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)
-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.
-`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 a solution into a gem.
+## Note
-## ORM support
+Currently `AjaxDatatablesRails` only supports `ActiveRecord` as ORM for performing database queries.
-Currently `AjaxDatatablesRails` only supports `ActiveRecord` as ORM for
-performing database queries.
+Adding support for `Sequel`, `Mongoid` and `MongoMapper` is (more or less) a planned feature for this gem.
-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.
+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.
-## Installation
-Add these lines to your application's Gemfile:
+## Quick start (in 5 steps)
- gem 'jquery-datatables-rails'
- gem 'ajax-datatables-rails'
+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))
-And then execute:
+The goal is to render a users table and display : `id`, `first name`, `last name`, `email`, and `bio` for each user.
- $ bundle
+Something like this:
-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).
+|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|
-## Usage (0.3.x)
-*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.*
+Here the steps we're going through :
-### Generate
-Run the following command:
+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)
+
+### 1) Generate the datatable class
- $ rails generate datatable User
+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.
Take a look [here](#generator-syntax) for an explanation about the generator syntax.
-### Customize
-```ruby
-def sortable_columns
- # Declare strings in this format: ModelName.column_name
- @sortable_columns ||= []
-end
-def searchable_columns
- # Declare strings in this format: ModelName.column_name
- @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 `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. The sequence of these 3 columns must mirror the order of
-declarations in the `data` method below. You cannot leave this array empty as of
-0.3.0.
-* For `searchable_columns`, assign an array of the database columns that you
-want searchable by datatables. Suppose we need to sort and search users
-`:first_name`, `last_name` and `bio`.
+### 3) Customize the generated Datatables class
+
+#### 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 ||= %w(User.first_name User.last_name User.bio)
- # this is equal to:
- # @sortable_columns ||= ['User.first_name', 'User.last_name', 'User.bio']
+ # ... other methods (view_columns, data...)
end
+```
+
+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 :
-def searchable_columns
- @searchable_columns ||= %w(User.first_name User.last_name User.bio)
- # this is equal to:
- # @searchable_columns ||= ['User.first_name', 'User.last_name', 'User.bio']
+```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 about the
-`searchable_columns` settings (if using something different from `postgre`).
-* [Read these notes](#searchable-and-sortable-columns-syntax) about
-considerations for the `searchable_columns` and `sortable_columns` methods.
+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
+**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.id,
record.first_name,
record.last_name,
+ record.email,
record.bio
]
end
end
```
-In the example above, we use the same sequence of column declarations as in
-`sortable_columns`. This ordering is important! And as of 0.3.0, the first
-column must be a sortable column. For more, see
-[this issue](https://github.com/antillas21/ajax-datatables-rails/issues/83).
-
-[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).
-#### Automatic addition of ID
-If you want the gem inserts automatically the ID of the record in the `
` element
-as shown in this [DataTable example](http://www.datatables.net/examples/server_side/ids.html),
-you have to perform some modifications in both `some_datatable.rb` file and in your javascript.
-
-Here is an example:
-```ruby
-def data
- records.map do |record|
- {
- '0' => record.first_name,
- '1' => record.last_name,
- '2' => record.email,
- 'DT_RowId' => record.id
- }
- end
-end
-```
+[See here](#using-view-helpers) if you need to use view helpers like `link_to`, `mail_to`, etc...
-and in your javascript file:
-```javascript
-$(function() {
- return $('#table_id').dataTable({
- processing: true,
- serverSide: true,
- ajax: 'ajax_url',
- columns: [
- {data: '0' },
- {data: '1' },
- {data: '2' }
- ]
- });
-});
-```
-
-#### Get Raw Records
-```ruby
-def get_raw_records
- # insert query here
-end
-```
+#### c. Get Raw Records
This is where your query goes.
```ruby
def get_raw_records
- # suppose we need all User records
- # Rails 4+
User.all
- # Rails 3.x
- # User.scoped
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.
-
-#### 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.
+Obviously, you can construct your query as required for the use case the datatable is used.
-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:
+Example:
```ruby
- 'coursetypes.name',
- 'courses.name',
- 'events.title',
- 'events.event_start',
- 'events.event_end',
- 'contacts.full_name',
- 'competency_types.name',
- 'events.status'
+def get_raw_records
+ User.active.with_recent_messages
+end
```
-We want to sort and search on all columns of the list. The related definition
-would be:
+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.
-```ruby
+**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.
- def sortable_columns
- @sortable_columns ||= [
- 'Coursetype.name',
- 'Course.name',
- 'Event.title',
- 'Event.event_start',
- 'Event.event_end',
- 'Contact.last_name',
- 'CompetencyType.name',
- 'Event.status'
- ]
- end
+#### d. Additional data
- def searchable_columns
- @searchable_columns ||= [
- 'Coursetype.name',
- 'Course.name',
- 'Event.title',
- 'Event.event_start',
- 'Event.event_end',
- 'Contact.last_name',
- 'CompetencyType.name',
- 'Event.status'
- ]
- end
+You can inject other key/value pairs in the rendered JSON by defining the `#additional_data` method :
- def get_raw_records
- Event.joins(
- { course: :coursetype },
- { allocations: {
- teacher: [:contact, {competencies: :competency_type}]
- }
- }).distinct
- end
+```ruby
+def additional_data
+ {
+ foo: 'bar'
+ }
+end
```
-__Some comments for the above code:__
-
-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.
+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.
-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.
-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.
+### 4) Setup the Controller action
-So the query using the `.includes()` method is:
-
-```ruby
- def get_raw_records
- Event.includes(
- { course: :coursetype },
- { allocations: {
- teacher: [:contact, { competencies: :competency_type }]
- }
- }
- ).references(:course).distinct
- end
-```
-
-For more examples of 0.3.0 syntax for complex associations (and an example of
-the `data` method), read
-[this](https://github.com/antillas21/ajax-datatables-rails/issues/77).
-
-### Controller
-Set up the controller to respond to JSON
+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`.
+[See here](#pass-options-to-the-datatable-class) if you need to inject params in the `UserDatatable`.
-### 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
-
-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).
-```html
-
-
-
- First Name |
- Last Name |
- Brief Bio |
-
-
-
-
-
-```
+### 5) Wire up the Javascript
-### Javascript
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
-#### Searchable and Sortable columns syntax
+### Using view helpers
-Starting on version `0.3.0`, we are implementing a pseudo code way of declaring
-the array of both `searchable_columns` and `sortable_columns` method.
+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.
-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.
+To have these methods available to be used, this is the way to go:
```ruby
-# we use the ModelName.column_name notation to declare our columns
+class UserDatatable < AjaxDatatablesRails::ActiveRecord
+ extend Forwardable
-def searchable_columns
- @searchable_columns ||= [
- 'User.first_name',
- 'User.last_name',
- 'PurchaseOrder.number',
- 'PurchaseOrder.created_at',
- 'Purchase::LineItem.quantity',
- 'Purchase::LineItem.unit_price',
- 'Purchase::LineItem.item_total'
- ]
-end
+ # 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
-def sortable_columns
- @sortable_columns ||= [
- 'User.first_name',
- 'User.last_name',
- 'PurchaseOrder.number',
- 'PurchaseOrder.created_at'
- ]
-end
-```
+ # or define them in one pass
+ def_delegators :@view, :check_box_tag, :link_to, :mail_to, :edit_user_path
-##### What if the datatable itself is namespaced?
-Example: what if the datatable is namespaced into an `Admin` module?
+ # ... other methods (view_columns, get_raw_records...)
-```ruby
-module Admin
- class PurchasesDatatable < AjaxDatatablesRails::Base
+ def initialize(params, opts = {})
+ @view = opts[:view_context]
+ super
end
-end
-```
-Taking the same models and columns, we would define it like this:
+ # 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
-```ruby
-def searchable_columns
- @searchable_columns ||= [
- '::User.first_name',
- '::User.last_name',
- '::PurchaseOrder.number',
- '::PurchaseOrder.created_at',
- '::Purchase::LineItem.quantity',
- '::Purchase::LineItem.unit_price',
- '::Purchase::LineItem.item_total'
- ]
+# 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
```
-Pretty much like you would do it, if you were inside a namespaced controller.
-
-#### What if I'm using Oracle?
+### Using view decorators
-We have recently merged and released a contribution from [lutechspa](https://github.com/lutechspa) that makes this gem work with Oracle (tested in version 11g). You can [take a look at this sample repo](https://github.com/paoloripamonti/oracle-ajax-datatable) to get an idea on how to set things up.
+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.
-#### Searching on non text-based columns
+**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).
-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).
+Example :
-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 `postgresql` (with the `:pg` gem), like `oracle`,
-`mysql` or `sqlite`, then you need to add an initializer in your application's
-`config/initializers` directory.
+```ruby
+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
-If you don't perform this step (again, if using something different from
-`postgresql`), your database will complain that it does not understand the
-default typecast used to enable such searches.
+class UserDecorator < ApplicationDecorator
+ delegate :last_name, :bio
+ def check_box
+ h.check_box_tag 'users[]', object.id
+ end
-#### Configuration initializer
+ def link_to
+ h.link_to object.first_name, h.edit_user_path(object)
+ end
-You have two options to create this initializer:
+ def email
+ h.mail_to object.email
+ end
-* use the provided (and recommended) generator (and then just edit the file);
-* create the file from scratch.
+ # 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
+```
-To use the generator, from the terminal execute:
+### Pass options to the datatable class
-```
-$ bundle exec rails generate datatable:config
-```
+An `AjaxDatatablesRails::ActiveRecord` inherited class can accept an options hash at initialization. This provides room for flexibility when required.
-Doing so, will create the `config/initializers/ajax_datatables_rails.rb` file
-with the following content:
+Example:
```ruby
-AjaxDatatablesRails.configure do |config|
- # available options for db_adapter are: :oracle, :pg, :mysql2, :sqlite3
- # config.db_adapter = :pg
-
- # available options for paginator are: :simple_paginator, :kaminari, :will_paginate
- # config.paginator = :simple_paginator
+# 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
-```
-Uncomment the `config.db_adapter` line and set the corresponding value to your
-database and gem. This is all you need.
+# The datatable class
+class UnrespondedMessagesDatatable < AjaxDatatablesRails::ActiveRecord
-Uncomment the `config.paginator` line to set `kaminari or will_paginate` if
-included in your project. It defaults to `simple_paginator`, it falls back to
-passing `offset` and `limit` at the database level (through `ActiveRecord`
-of course).
+ # ... other methods (view_columns, data...)
-If you want to make the file from scratch, just copy the above code block into
-a file inside the `config/initializers` directory.
+ def user
+ @user ||= options[:user]
+ end
+ def from
+ @from ||= options[:from].beginning_of_day
+ end
+
+ def to
+ @to ||= Date.today.end_of_day
+ end
-#### Using view helpers
+ # 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
-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.
+end
+```
-To have these methods available to be used, this is the way to go:
+### 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.
-datatable = UnrespondedMessagesDatatable.new(view_context,
- { :foo => { :bar => Baz.new }, :from => 1.month.ago }
-)
+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
+'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
+
+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)).
-Also, a class that inherits from `AjaxDatatablesRails::Base` is not tied to an
+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
@@ -609,21 +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.
-## Tutorial
+## Tests
+
+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))
-Tutorial for Integrating `ajax-datatable-rails`, on Rails 4 .
+### Use HTTP `POST` method (Medium)
-[Part-1 The-Installation](https://github.com/antillas21/ajax-datatables-rails/wiki/Part-1----The-Installation)
+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
+
+ 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)
+
+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 4de8ccf7..69e22ea8 100644
--- a/ajax-datatables-rails.gemspec
+++ b/ajax-datatables-rails.gemspec
@@ -1,30 +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.3")
+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'
+ s.required_ruby_version = '>= 3.1.0'
- gem.add_development_dependency "rspec"
- gem.add_development_dependency "generator_spec"
- gem.add_development_dependency "pry"
- gem.add_development_dependency "rake"
- gem.add_development_dependency "sqlite3"
- gem.add_development_dependency "rails", ">= 3.1.0"
- gem.add_development_dependency "activerecord", ">= 4.1.6"
+ 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..01410313
--- /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-retry"
+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..189ab407
--- /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-retry"
+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..042ce7ac
--- /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-retry"
+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..d23c1582
--- /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-retry"
+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..2500f361
--- /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-retry"
+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..3d8cc0ed
--- /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-retry"
+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..cde0f1c9
--- /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-retry"
+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..7ce4286b
--- /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-retry"
+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..ee0c0c1a
--- /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-retry"
+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..8ce650f8
--- /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-retry"
+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..fcdf669e
--- /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-retry"
+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..023c71d8
--- /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-retry"
+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..a18d301a
--- /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-retry"
+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..3af883bf
--- /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-retry"
+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..164f2e09
--- /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-retry"
+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..62bfdd83
--- /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-retry"
+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..f80123d1
--- /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-retry"
+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 c1b7420d..cb9f515f 100644
--- a/lib/ajax-datatables-rails.rb
+++ b/lib/ajax-datatables-rails.rb
@@ -1,10 +1,17 @@
-require 'ajax-datatables-rails/version'
-require 'ajax-datatables-rails/config'
-require 'ajax-datatables-rails/models'
-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 080fe0c1..4d52feee 100644
--- a/lib/ajax-datatables-rails/base.rb
+++ b/lib/ajax-datatables-rails/base.rb
@@ -1,215 +1,161 @@
-module AjaxDatatablesRails
- class Base
- extend Forwardable
- include ActiveRecord::Sanitization::ClassMethods
- 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
- load_paginator
+ 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.'
- )
- end
+ def initialize(params, options = {})
+ @params = params
+ @options = options
+ @datatable = Datatable::Datatable.new(self)
- def get_raw_records
- fail(
- MethodNotImplementedError,
- 'Please implement this method in your class.'
- )
+ @view_columns = nil
+ @connected_columns = nil
+ @searchable_columns = nil
+ @search_columns = nil
+ @records = nil
+ @build_conditions = nil
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
- }
+ # 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 self.deprecated(message, caller = Kernel.caller[1])
- warning = caller + ": " + message
+ def get_raw_records # rubocop:disable Naming/AccessorMethodName
+ raise(NotImplementedError, <<~ERROR)
- if(respond_to?(:logger) && logger.present?)
- logger.warn(warning)
- else
- warn(warning)
- end
+ 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)
- if column[0] == column.downcase[0]
- ::AjaxDatatablesRails::Base.deprecated '[DEPRECATED] Using table_name.column_name notation is deprecated. Please refer to: https://github.com/antillas21/ajax-datatables-rails#searchable-and-sortable-columns-syntax'
- return deprecated_search_condition(column, value)
- else
- return new_search_condition(column, value)
- end
- end
+ private
- def new_search_condition(column, value)
- model, column = column.split('.')
- model = model.constantize
- casted_column = ::Arel::Nodes::NamedFunction.new('CAST', [model.arel_table[column.to_sym].as(typecast)])
- casted_column.matches("%#{sanitize_sql_like(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 deprecated_search_condition(column, value)
- model, column = column.split('.')
- model = model.singularize.titleize.gsub( / /, '' ).constantize
-
- casted_column = ::Arel::Nodes::NamedFunction.new('CAST', [model.arel_table[column.to_sym].as(typecast)])
- casted_column.matches("%#{sanitize_sql_like(value)}%")
+ def searchable_columns
+ @searchable_columns ||= connected_columns.select(&:searchable?)
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 search_columns
+ @search_columns ||= searchable_columns.select(&:searched?)
end
- def typecast
- case config.db_adapter
- when :oracle then 'VARCHAR2(4000)'
- when :pg then 'VARCHAR'
- when :mysql2 then 'CHAR'
- when :sqlite3 then 'TEXT'
+ 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
- end
-
- def page
- (params[:start].to_i / per_page) + 1
- end
-
- def per_page
- params.fetch(:length, 10).to_i
+ # called from within #data
+ def records
+ @records ||= retrieve_records
end
- def sort_column(item)
- new_sort_column(item)
- rescue
- ::AjaxDatatablesRails::Base.deprecated '[DEPRECATED] Using table_name.column_name notation is deprecated. Please refer to: https://github.com/antillas21/ajax-datatables-rails#searchable-and-sortable-columns-syntax'
- deprecated_sort_column(item)
+ 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 deprecated_sort_column(item)
- sortable_columns[sortable_displayed_columns.index(item[:column])]
+ def records_total_count
+ numeric_count fetch_records.count(:all)
end
- def new_sort_column(item)
- model, column = sortable_columns[sortable_displayed_columns.index(item[:column])].split('.')
- col = [model.constantize.table_name, column].join('.')
+ 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 sortable_displayed_columns
- @sortable_displayed_columns ||= generate_sortable_displayed_columns
+ def global_search_delimiter
+ GLOBAL_SEARCH_DELIMITER
end
- def generate_sortable_displayed_columns
- @sortable_displayed_columns = []
- params[:columns].each_value do |column|
- @sortable_displayed_columns << column[:data] if column[:orderable] == 'true'
- end
- @sortable_displayed_columns
+ # See: https://datatables.net/manual/server-side#Returned-data
+ def draw_id
+ params[:draw].present? ? { draw: params[:draw].to_i } : {}
end
- def load_paginator
- case config.paginator
- when :kaminari
- extend Extensions::Kaminari
- when :will_paginate
- extend Extensions::WillPaginate
- else
- extend Extensions::SimplePaginator
- end
- self
- end
end
end
diff --git a/lib/ajax-datatables-rails/config.rb b/lib/ajax-datatables-rails/config.rb
deleted file mode 100644
index d63cff3c..00000000
--- a/lib/ajax-datatables-rails/config.rb
+++ /dev/null
@@ -1,25 +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 }
- config_accessor(:paginator) { :simple_paginator }
- 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/models.rb b/lib/ajax-datatables-rails/models.rb
deleted file mode 100644
index e1d571d9..00000000
--- a/lib/ajax-datatables-rails/models.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-require 'active_support/ordered_options'
-
-module AjaxDatatablesRails
- class Models < ActiveSupport::OrderedOptions
- end
-end
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 8f3e58f4..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.3.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 7d530fac..00000000
--- a/lib/generators/rails/templates/datatable.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-class <%= @datatable_name %>Datatable < AjaxDatatablesRails::Base
-
- def sortable_columns
- # Declare strings in this format: ModelName.column_name
- @sortable_columns ||= []
- end
-
- def searchable_columns
- # Declare strings in this format: ModelName.column_name
- @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 1a42f6a0..00000000
--- a/spec/ajax-datatables-rails/ajax_datatables_rails_spec.rb
+++ /dev/null
@@ -1,351 +0,0 @@
-require 'spec_helper'
-
-describe AjaxDatatablesRails::Base 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) }
-
- 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 => params
- )
- datatable = AjaxDatatablesRails::Base.new(sort_view)
- allow(datatable).to receive(:sortable_displayed_columns) { ["0", "1"] }
- allow(datatable).to receive(:sortable_columns) { ['User.foo', 'User.bar', 'User.baz'] }
-
- expect(datatable.send(:sort_column, sort_view.params[:order]["0"])).to eq('users.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, sorting_view.params[:order]["0"])).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, sorting_view.params[:order]["0"])).to eq('ASC')
- end
- end
-
- describe "#configure" do
- let(:datatable) do
- class FooDatatable < AjaxDatatablesRails::Base
- end
-
- FooDatatable.new view
- end
-
- context "when model class name is regular" do
- it "should successfully get right model class" do
- expect(
- datatable.send(:search_condition, 'User.bar', 'bar')
- ).to be_a(Arel::Nodes::Matches)
- end
- end
-
- context "when custom named model class" do
- it "should successfully get right model class" do
- expect(
- datatable.send(:search_condition, 'Statistics::Request.bar', 'bar')
- ).to be_a(Arel::Nodes::Matches)
- end
- end
-
-
- context "when model class name camelcased" do
- it "should successfully get right model class" do
- expect(
- datatable.send(:search_condition, 'PurchasedOrder.bar', 'bar')
- ).to be_a(Arel::Nodes::Matches)
- end
- end
-
- context "when model class name is namespaced" do
- it "should successfully get right model class" do
- expect(
- datatable.send(:search_condition, 'Statistics::Session.bar', 'bar')
- ).to be_a(Arel::Nodes::Matches)
- end
- end
-
- context "when model class defined but not found" do
- it "raise 'uninitialized constant'" do
- expect {
- datatable.send(:search_condition, 'UnexistentModel.bar', 'bar')
- }.to raise_error(NameError, /uninitialized constant/)
- end
- end
-
- context 'when using deprecated notation' do
- it 'should successfully get right model class if exists' do
- expect(
- datatable.send(:search_condition, 'users.bar', 'bar')
- ).to be_a(Arel::Nodes::Matches)
- end
-
- it 'should display a deprecated message' do
- expect(AjaxDatatablesRails::Base).to receive(:deprecated)
- datatable.send(:search_condition, 'users.bar', 'bar')
- end
- 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) }
- let(:records) { double('Array').as_null_object }
-
- before(:each) do
- allow(datatable).to receive(:sortable_columns) { ['User.foo', 'User.bar'] }
- allow(datatable).to receive(:sortable_displayed_columns) { ["0", "1"] }
- end
-
- describe '#paginate_records' do
- it 'defaults to Extensions::SimplePaginator#paginate_records' do
- allow(records).to receive_message_chain(:offset, :limit)
-
- expect { datatable.send(:paginate_records, records) }.not_to raise_error
- end
- end
-
- describe '#sort_records' do
- it 'calls #order on a collection' do
- expect(results).to 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)
- allow(datatable).to receive(:searchable_columns) { ['users.foo'] }
-
- expect(records).to receive(:where)
- records.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)
- allow(datatable).to receive(:searchable_columns) { ['user_datas.bar'] }
-
- expect(records).to receive(:where)
- records.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 b0f995e3..00000000
--- a/spec/ajax-datatables-rails/kaminari_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-require 'spec_helper'
-
-class KaminariDatatable < AjaxDatatablesRails::Base
-end
-
-describe KaminariDatatable do
- before(:each) do
- allow_any_instance_of(AjaxDatatablesRails::Configuration).to receive(:paginator) { :kaminari }
- end
-
- 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
- expect(records).to receive(:page)
- datatable.send(:paginate_records, records)
- end
-
- it 'calls #per_page on passed record collection' do
- arry = double('Array', :per => [])
- allow(records).to receive(:page).and_return(arry)
- expect(arry).to receive(:per)
- datatable.send(:paginate_records, records)
- end
- end
-end
diff --git a/spec/ajax-datatables-rails/models_spec.rb b/spec/ajax-datatables-rails/models_spec.rb
deleted file mode 100644
index 774a2204..00000000
--- a/spec/ajax-datatables-rails/models_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'spec_helper'
-
-describe AjaxDatatablesRails::Models do
- let(:models){ AjaxDatatablesRails::Models.new }
-
- it "is configurable" do
- models.user = User
- expect(models.user).to eq(User)
- end
-end
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 1e20a618..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
- expect(records).to receive(:offset)
- datatable.send(:paginate_records, records)
- end
-
- it 'calls #limit on passed record collection' do
- arry = double('Array', :limit => [])
- allow(records).to receive(:offset).and_return(arry)
- expect(arry).to 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 9c890eb4..00000000
--- a/spec/ajax-datatables-rails/will_paginate_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-require 'spec_helper'
-
-class WillPaginateDatatable < AjaxDatatablesRails::Base
-end
-
-describe WillPaginateDatatable do
- before(:each) do
- allow_any_instance_of(AjaxDatatablesRails::Configuration).to receive(:paginator) { :will_paginate }
- end
-
- 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
- expect(records).to receive(:paginate).with(:page=>1, :per_page=>10)
- datatable.send(:paginate_records, records)
- end
- end
-end
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('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/schema.rb b/spec/schema.rb
deleted file mode 100644
index a5442bc1..00000000
--- a/spec/schema.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-ActiveRecord::Schema.define do
- self.verbose = false
-
- create_table :users, :force => true do |t|
- t.string :username
-
- t.timestamps
- end
-
- create_table :user_data, :force => true do |t|
- t.string :address
-
- t.timestamps
- end
-
- create_table :purchased_orders, :force => true do |t|
- t.string :foo
- t.string :bar
-
- t.timestamps
- end
-
- create_table :statistics_requests, :force => true do |t|
- t.string :baz
-
- t.timestamps
- end
-
- create_table :statistics_sessions, :force => true do |t|
- t.string :foo
- t.integer :bar
-
- t.timestamps
- end
-end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index d12bd0a1..822b8c5a 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,9 +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/retry'
+require 'database_cleaner'
+require 'factory_bot'
+require 'faker'
require 'pry'
-require 'rails'
-require 'active_record'
-require 'ajax-datatables-rails'
-ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
+# Start Simplecov
+SimpleCov.start do
+ formatter 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: 3
+ 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('DB_ADAPTER', nil))
+ end
+
+ def self.postgresql?
+ %w[postgresql postgis].include?(ENV.fetch('DB_ADAPTER', nil))
+ end
+end
+
+# Require our gem
+require 'ajax-datatables-rails'
-load File.dirname(__FILE__) + '/schema.rb'
-require File.dirname(__FILE__) + '/test_models.rb'
+# 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
diff --git a/spec/test_models.rb b/spec/test_models.rb
deleted file mode 100644
index 6a2267a6..00000000
--- a/spec/test_models.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-class User < ActiveRecord::Base
-end
-
-class UserData < ActiveRecord::Base
- self.table_name = "user_data"
-end
-
-class PurchasedOrder < ActiveRecord::Base
-end
-
-module Statistics
- def self.table_name_prefix
- "statistics_"
- end
-end
-
-class Statistics::Request < ActiveRecord::Base
-end
-
-class Statistics::Session < ActiveRecord::Base
-end
|