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 -[![Build Status](https://travis-ci.org/antillas21/ajax-datatables-rails.svg?branch=master)](https://travis-ci.org/antillas21/ajax-datatables-rails) -[![Gem Version](https://badge.fury.io/rb/ajax-datatables-rails.svg)](http://badge.fury.io/rb/ajax-datatables-rails) -[![Code Climate](https://codeclimate.com/github/antillas21/ajax-datatables-rails/badges/gpa.svg)](https://codeclimate.com/github/antillas21/ajax-datatables-rails) +[![GitHub license](https://img.shields.io/github/license/jbox-web/ajax-datatables-rails.svg)](https://github.com/jbox-web/ajax-datatables-rails/blob/master/LICENSE) +[![Gem](https://img.shields.io/gem/v/ajax-datatables-rails.svg)](https://rubygems.org/gems/ajax-datatables-rails) +[![Gem](https://img.shields.io/gem/dtv/ajax-datatables-rails.svg)](https://rubygems.org/gems/ajax-datatables-rails) +[![CI](https://github.com/jbox-web/ajax-datatables-rails/workflows/CI/badge.svg)](https://github.com/jbox-web/ajax-datatables-rails/actions) +[![Code Climate](https://codeclimate.com/github/jbox-web/ajax-datatables-rails/badges/gpa.svg)](https://codeclimate.com/github/jbox-web/ajax-datatables-rails) +[![Test Coverage](https://codeclimate.com/github/jbox-web/ajax-datatables-rails/badges/coverage.svg)](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 +
+ + + + + + + + + + + +
IDFirst NameLast NameEmailBrief 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 NameLast NameBrief 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