├── .gitignore ├── .rspec ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── concerns_on_rails.gemspec ├── lib ├── concerns_on_rails.rb └── concerns_on_rails │ ├── publishable.rb │ ├── sluggable.rb │ ├── soft_deletable.rb │ ├── sortable.rb │ └── version.rb └── spec ├── concerns ├── publishable_spec.rb ├── sluggable_spec.rb ├── soft_deletable_spec.rb └── sortable_spec.rb ├── spec_helper.rb └── support └── database.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | .byebug_history 13 | *.gem -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## 1.1.0 (2025-04-17) 4 | 5 | ### Added 6 | - SoftDeletable: Add soft delete concern with configurable field, scopes, callbacks, and default_scope support 7 | 8 | ## 1.0.0 (2025-04-12) 9 | 10 | ### Added 11 | - Initial release 12 | 13 | ### Fixed 14 | - None -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We are committed to providing a friendly, safe, and welcoming environment for all, regardless of gender, sexual orientation, disability, physical appearance, body size, race, ethnicity, age, religion, or technology choices. 4 | 5 | ### Our Standards 6 | 7 | Examples of behavior that contributes to creating a positive environment include: 8 | 9 | - Being kind and respectful to others. 10 | - Behaving in a welcoming and inclusive manner. 11 | - Showing empathy towards other community members. 12 | - Communicating in a respectful and constructive way. 13 | - Acknowledging and respecting the different viewpoints of others. 14 | 15 | Examples of unacceptable behavior by participants include: 16 | 17 | - The use of sexualized language or imagery, and unwelcome sexual attention or advances. 18 | - Trolling, insulting/derogatory comments, and personal or political attacks. 19 | - Public or private harassment. 20 | - Any form of discrimination or marginalization. 21 | - Sharing private information without permission, including personal addresses or contact details. 22 | - Intimidation, bullying, or deliberate exclusion of others. 23 | - Disruptive behavior in discussions, issues, or pull requests. 24 | 25 | ### Reporting 26 | 27 | If you are being harassed, notice that someone else is being harassed, or have any other concerns, please contact us immediately at [doctorit@gmail.com](mailto:doctorit@gmail.com). 28 | 29 | You can also use the [GitHub issue tracker](https://github.com/VSN2015/concerns_on_rails/issues) to report any incidents or violations of the code of conduct. 30 | 31 | We take all reports seriously and will respond promptly. 32 | 33 | ### Enforcement 34 | 35 | Participants who violate this Code of Conduct may be warned or expelled from the community at the discretion of the project maintainers. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers at the contact information above. All complaints will be reviewed and investigated promptly and in a fair manner. 38 | 39 | If necessary, the project maintainers will take appropriate action, which may include a temporary or permanent ban from the community. 40 | 41 | ### Our Commitment 42 | 43 | By participating in this project, you are agreeing to adhere to this Code of Conduct. We expect all members of this community to be respectful and considerate to others at all times. 44 | 45 | ### Scope 46 | 47 | This Code of Conduct applies to all spaces related to this project, including the project's GitHub repository, chat rooms, mailing lists, and other communication channels. 48 | 49 | --- 50 | 51 | **Adapted from the Contributor Covenant**, version 1.4, available at https://www.contributor-covenant.org 52 | 53 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rspec", "~> 3.12" 6 | gem "activerecord", ">= 5.0", "< 8.0" 7 | gem "sqlite3", "~> 1.6.2" 8 | gem "friendly_id", "~> 5.4" 9 | gem "faker", "~> 3.2" 10 | gem "simplecov", "~> 0.22" 11 | gem 'acts_as_list', '~> 0.7.5' 12 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | concerns_on_rails (1.1.0) 5 | acts_as_list (~> 0.7.5) 6 | friendly_id (~> 5.4) 7 | rails (~> 5.0) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | actioncable (5.2.8.1) 13 | actionpack (= 5.2.8.1) 14 | nio4r (~> 2.0) 15 | websocket-driver (>= 0.6.1) 16 | actionmailer (5.2.8.1) 17 | actionpack (= 5.2.8.1) 18 | actionview (= 5.2.8.1) 19 | activejob (= 5.2.8.1) 20 | mail (~> 2.5, >= 2.5.4) 21 | rails-dom-testing (~> 2.0) 22 | actionpack (5.2.8.1) 23 | actionview (= 5.2.8.1) 24 | activesupport (= 5.2.8.1) 25 | rack (~> 2.0, >= 2.0.8) 26 | rack-test (>= 0.6.3) 27 | rails-dom-testing (~> 2.0) 28 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 29 | actionview (5.2.8.1) 30 | activesupport (= 5.2.8.1) 31 | builder (~> 3.1) 32 | erubi (~> 1.4) 33 | rails-dom-testing (~> 2.0) 34 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 35 | activejob (5.2.8.1) 36 | activesupport (= 5.2.8.1) 37 | globalid (>= 0.3.6) 38 | activemodel (5.2.8.1) 39 | activesupport (= 5.2.8.1) 40 | activerecord (5.2.8.1) 41 | activemodel (= 5.2.8.1) 42 | activesupport (= 5.2.8.1) 43 | arel (>= 9.0) 44 | activestorage (5.2.8.1) 45 | actionpack (= 5.2.8.1) 46 | activerecord (= 5.2.8.1) 47 | marcel (~> 1.0.0) 48 | activesupport (5.2.8.1) 49 | concurrent-ruby (~> 1.0, >= 1.0.2) 50 | i18n (>= 0.7, < 2) 51 | minitest (~> 5.1) 52 | tzinfo (~> 1.1) 53 | acts_as_list (0.7.7) 54 | activerecord (>= 3.0) 55 | arel (9.0.0) 56 | base64 (0.2.0) 57 | builder (3.3.0) 58 | concurrent-ruby (1.3.5) 59 | crass (1.0.6) 60 | date (3.4.1) 61 | diff-lcs (1.6.1) 62 | docile (1.4.1) 63 | erubi (1.13.1) 64 | faker (3.4.2) 65 | i18n (>= 1.8.11, < 2) 66 | friendly_id (5.5.1) 67 | activerecord (>= 4.0.0) 68 | globalid (1.1.0) 69 | activesupport (>= 5.0) 70 | i18n (1.14.7) 71 | concurrent-ruby (~> 1.0) 72 | loofah (2.24.0) 73 | crass (~> 1.0.2) 74 | nokogiri (>= 1.12.0) 75 | mail (2.8.1) 76 | mini_mime (>= 0.1.1) 77 | net-imap 78 | net-pop 79 | net-smtp 80 | marcel (1.0.4) 81 | method_source (1.1.0) 82 | mini_mime (1.1.5) 83 | mini_portile2 (2.8.8) 84 | minitest (5.25.5) 85 | net-imap (0.4.19) 86 | date 87 | net-protocol 88 | net-pop (0.1.2) 89 | net-protocol 90 | net-protocol (0.2.2) 91 | timeout 92 | net-smtp (0.5.1) 93 | net-protocol 94 | nio4r (2.7.4) 95 | nokogiri (1.15.7-arm64-darwin) 96 | racc (~> 1.4) 97 | racc (1.8.1) 98 | rack (2.2.13) 99 | rack-test (2.2.0) 100 | rack (>= 1.3) 101 | rails (5.2.8.1) 102 | actioncable (= 5.2.8.1) 103 | actionmailer (= 5.2.8.1) 104 | actionpack (= 5.2.8.1) 105 | actionview (= 5.2.8.1) 106 | activejob (= 5.2.8.1) 107 | activemodel (= 5.2.8.1) 108 | activerecord (= 5.2.8.1) 109 | activestorage (= 5.2.8.1) 110 | activesupport (= 5.2.8.1) 111 | bundler (>= 1.3.0) 112 | railties (= 5.2.8.1) 113 | sprockets-rails (>= 2.0.0) 114 | rails-dom-testing (2.2.0) 115 | activesupport (>= 5.0.0) 116 | minitest 117 | nokogiri (>= 1.6) 118 | rails-html-sanitizer (1.6.2) 119 | loofah (~> 2.21) 120 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 121 | railties (5.2.8.1) 122 | actionpack (= 5.2.8.1) 123 | activesupport (= 5.2.8.1) 124 | method_source 125 | rake (>= 0.8.7) 126 | thor (>= 0.19.0, < 2.0) 127 | rake (13.2.1) 128 | rspec (3.13.0) 129 | rspec-core (~> 3.13.0) 130 | rspec-expectations (~> 3.13.0) 131 | rspec-mocks (~> 3.13.0) 132 | rspec-core (3.13.3) 133 | rspec-support (~> 3.13.0) 134 | rspec-expectations (3.13.3) 135 | diff-lcs (>= 1.2.0, < 2.0) 136 | rspec-support (~> 3.13.0) 137 | rspec-mocks (3.13.2) 138 | diff-lcs (>= 1.2.0, < 2.0) 139 | rspec-support (~> 3.13.0) 140 | rspec-support (3.13.2) 141 | simplecov (0.22.0) 142 | docile (~> 1.1) 143 | simplecov-html (~> 0.11) 144 | simplecov_json_formatter (~> 0.1) 145 | simplecov-html (0.13.1) 146 | simplecov_json_formatter (0.1.4) 147 | sprockets (4.2.1) 148 | concurrent-ruby (~> 1.0) 149 | rack (>= 2.2.4, < 4) 150 | sprockets-rails (3.4.2) 151 | actionpack (>= 5.2) 152 | activesupport (>= 5.2) 153 | sprockets (>= 3.0.0) 154 | sqlite3 (1.6.9) 155 | mini_portile2 (~> 2.8.0) 156 | thor (1.3.2) 157 | thread_safe (0.3.6) 158 | timeout (0.4.3) 159 | tzinfo (1.2.11) 160 | thread_safe (~> 0.1) 161 | websocket-driver (0.7.7) 162 | base64 163 | websocket-extensions (>= 0.1.0) 164 | websocket-extensions (0.1.5) 165 | 166 | PLATFORMS 167 | ruby 168 | 169 | DEPENDENCIES 170 | activerecord (>= 5.0, < 8.0) 171 | acts_as_list (~> 0.7.5) 172 | concerns_on_rails! 173 | faker (~> 3.2) 174 | friendly_id (~> 5.4) 175 | rspec (~> 3.12) 176 | simplecov (~> 0.22) 177 | sqlite3 (~> 1.6.2) 178 | 179 | BUNDLED WITH 180 | 2.1.4 181 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ethan Nguyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧩 ConcernsOnRails 2 | 3 | **🇻🇳 Note: Hoàng Sa and Trường Sa belong to Việt Nam.** 4 | 5 | A simple collection of reusable Rails concerns to keep your models clean and DRY. 6 | 7 | ## ✨ Features 8 | 9 | - ✅ `Sluggable`: Generate friendly slugs from a specified field 10 | - 🔢 `Sortable`: Sort records based on a field using `acts_as_list`, with flexible sorting field and direction 11 | - 📤 `Publishable`: Easily manage published/unpublished records using a simple `published_at` field 12 | - ❌ `SoftDeletable`: Soft delete records using a configurable timestamp field (e.g., `deleted_at`) with automatic scoping 13 | 14 | --- 15 | 16 | ## 📦 Installation 17 | 18 | Add this line to your application's Gemfile: 19 | 20 | ```ruby 21 | gem 'concerns_on_rails', github: 'VSN2015/concerns_on_rails' 22 | ``` 23 | 24 | Then execute: 25 | 26 | ```sh 27 | bundle install 28 | ``` 29 | 30 | --- 31 | 32 | ## 🚀 Usage 33 | 34 | ### 1. 📝 Sluggable 35 | 36 | Add slugs based on a specified attribute. 37 | 38 | ```ruby 39 | class Post < ApplicationRecord 40 | include ConcernsOnRails::Sluggable 41 | 42 | sluggable_by :title 43 | end 44 | 45 | post = Post.create!(title: "Hello World") 46 | post.slug # => "hello-world" 47 | ``` 48 | 49 | If the slug source is changed, the slug will auto-update. 50 | 51 | --- 52 | 53 | ### 2. 🔢 Sortable 54 | 55 | Use for models that need ordering. 56 | 57 | ```ruby 58 | class Task < ApplicationRecord 59 | include ConcernsOnRails::Sortable 60 | 61 | sortable_by :position 62 | end 63 | 64 | Task.create!(name: "B") 65 | Task.create!(name: "A") 66 | Task.first.name # => "B" (sorted by position ASC) 67 | ``` 68 | 69 | You can customize the sort field and direction: 70 | 71 | ```ruby 72 | class PriorityTask < ApplicationRecord 73 | include ConcernsOnRails::Sortable 74 | 75 | sortable_by priority: :desc 76 | end 77 | ``` 78 | 79 | Additional features: 80 | - 📌 Automatically sets `acts_as_list` on the configured column 81 | - 📋 Adds default sorting scope to your model 82 | - ↕️ Supports custom direction: `:asc` or `:desc` 83 | - 🔍 Validates that the sortable field exists in the table schema 84 | - 🧠 Compatible with scopes and ActiveRecord queries 85 | - 🔄 Can be reconfigured dynamically within the model using `sortable_by` 86 | 87 | --- 88 | 89 | ### 3. 📤 Publishable 90 | 91 | Manage published/unpublished records using a `published_at` field. 92 | 93 | ```ruby 94 | class Article < ApplicationRecord 95 | include ConcernsOnRails::Publishable 96 | end 97 | 98 | Article.published # => returns only published articles 99 | Article.unpublished # => returns only unpublished articles 100 | 101 | article = Article.create!(title: "Draft") 102 | article.published? # => false 103 | 104 | article.publish! 105 | article.published? # => true 106 | 107 | article.unpublish! 108 | article.published? # => false 109 | ``` 110 | 111 | Additional features: 112 | - ✅ `published?` returns true if `published_at` is present and in the past 113 | - 🕒 `publish!` sets `published_at` to current time 114 | - 🚫 `unpublish!` sets `published_at` to `nil` 115 | - 🔎 Add scopes: `.published`, `.unpublished`, and a default scope (optional) 116 | - 📰 Ideal for blog posts, articles, or any content that toggles visibility 117 | - 🧩 Lightweight and non-invasive 118 | - 🧪 Easy to test and override in custom implementations 119 | 120 | --- 121 | 122 | ### 4. ❌ SoftDeletable 123 | 124 | Soft delete records using a timestamp field (default: `deleted_at`). 125 | 126 | ```ruby 127 | class User < ApplicationRecord 128 | include ConcernsOnRails::SoftDeletable 129 | 130 | # Optional: customize field and touch behavior 131 | soft_deletable_by :deleted_at, touch: true 132 | end 133 | ``` 134 | 135 | #### Scopes 136 | ```ruby 137 | User.without_deleted # => returns only active users 138 | User.soft_deleted # => returns soft-deleted users 139 | User.active # => same as without_deleted 140 | User.all # => returns only non-deleted by default (default_scope applied) 141 | ``` 142 | 143 | #### Soft delete and restore 144 | ```ruby 145 | user.soft_delete! # Soft delete the user (sets deleted_at) 146 | user.deleted? # => true 147 | user.soft_deleted? # => true (alias) 148 | user.is_soft_deleted? # => true (alias) 149 | 150 | user.restore! # Restore the user (sets deleted_at to nil) 151 | user.deleted? # => false 152 | ``` 153 | 154 | #### Permanently delete 155 | ```ruby 156 | user.really_delete! # Hard delete the record from DB 157 | ``` 158 | 159 | #### Soft delete/hard delete all records 160 | ```ruby 161 | User.destroy_all # Soft delete all users (sets deleted_at) 162 | User.really_destroy_all # Hard delete ALL users (removes from DB) 163 | ``` 164 | 165 | #### Callbacks (Hooks) 166 | You can use the following hooks to run logic before/after soft delete or restore: 167 | ```ruby 168 | class User < ApplicationRecord 169 | include ConcernsOnRails::SoftDeletable 170 | 171 | def before_soft_delete 172 | # Code to run before soft delete 173 | end 174 | 175 | def after_soft_delete 176 | # Code to run after soft delete 177 | end 178 | 179 | def before_restore 180 | # Code to run before restore 181 | end 182 | 183 | def after_restore 184 | # Code to run after restore 185 | end 186 | end 187 | ``` 188 | 189 | #### Notes 190 | - Default field is `deleted_at`, can be changed with `soft_deletable_by :your_field` 191 | - `touch: false` to skip updating updated_at when soft deleting/restoring 192 | - Aliases for `deleted?`: `soft_deleted?`, `is_soft_deleted?` 193 | - All scopes and methods work seamlessly with ActiveRecord 194 | 195 | --- 196 | 197 | ## 🛠️ Development 198 | 199 | To build the gem: 200 | 201 | ```sh 202 | gem build concerns_on_rails.gemspec 203 | ``` 204 | 205 | To install locally: 206 | 207 | ```sh 208 | gem install ./concerns_on_rails-1.0.0.gem 209 | ``` 210 | 211 | --- 212 | 213 | ## 🤝 Contributing 214 | 215 | Bug reports and pull requests are welcome! 216 | 217 | --- 218 | 219 | ## 📄 License 220 | 221 | This project is licensed under the MIT License. 222 | 223 | --- 224 | 225 | 🇻🇳 **Hoàng Sa and Trường Sa belong to Việt Nam.** 226 | 227 | --- 228 | 229 | ### 🔗 Source Code 230 | 231 | The source code is available on GitHub: 232 | 233 | [👉 https://github.com/VSN2015/concerns_on_rails](https://github.com/VSN2015/concerns_on_rails) 234 | 235 | Feel free to star ⭐️, fork 🍴, or contribute with issues and PRs. 236 | 237 | -------------------------------------------------------------------------------- /concerns_on_rails.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/concerns_on_rails/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "concerns_on_rails" 5 | spec.version = ConcernsOnRails::VERSION 6 | spec.authors = ["Ethan Nguyen"] 7 | spec.email = ["doctorit@gmail.com"] 8 | 9 | spec.summary = "Reusable Rails concerns like Sortable, Publishable, and Sluggable" 10 | spec.description = "A collection of plug-and-play ActiveSupport concerns for Rails models and Rails controllers" 11 | spec.homepage = "https://github.com/VSN2015/concerns_on_rails" 12 | spec.license = "MIT" 13 | spec.metadata["license"] = "MIT" 14 | 15 | 16 | spec.required_ruby_version = ">= 2.7.0" 17 | 18 | spec.files = Dir["lib/**/*", "bin/*", "README.md", "LICENSE.txt", "CODE_OF_CONDUCT.md", "CHANGELOG.md"] 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_runtime_dependency 'rails', '~> 5.0' 22 | spec.add_runtime_dependency 'acts_as_list', '~> 0.7.5' 23 | spec.add_runtime_dependency 'friendly_id', '~> 5.4' 24 | 25 | spec.metadata = { 26 | "homepage_uri" => spec.homepage, 27 | "source_code_uri" => spec.homepage, 28 | "changelog_uri" => "#{spec.homepage}/CHANGELOG.md" 29 | } 30 | end -------------------------------------------------------------------------------- /lib/concerns_on_rails.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | require "concerns_on_rails/version" 3 | require "concerns_on_rails/sortable" 4 | require "concerns_on_rails/publishable" 5 | require "concerns_on_rails/sluggable" 6 | require "concerns_on_rails/soft_deletable" 7 | 8 | module ConcernsOnRails 9 | end 10 | -------------------------------------------------------------------------------- /lib/concerns_on_rails/publishable.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | 3 | module ConcernsOnRails 4 | module Publishable 5 | extend ActiveSupport::Concern 6 | 7 | # instance methods 8 | included do 9 | # declare class attributes and set default values 10 | class_attribute :publishable_field, instance_accessor: false 11 | self.publishable_field ||= :published_at 12 | end 13 | 14 | # class methods 15 | class_methods do 16 | # Define publishable field 17 | # Example: 18 | # publishable_by :published_at 19 | def publishable_by(field = nil) 20 | self.publishable_field = field || :published_at 21 | 22 | # validate publishable_field exists in database 23 | unless column_names.include?(publishable_field.to_s) 24 | raise ArgumentError, "ConcernsOnRails::Publishable: publishable_field '#{publishable_field}' does not exist in the database" 25 | end 26 | 27 | scope :published, -> { where(arel_table[publishable_field].not_eq(nil)) } 28 | scope :unpublished, -> { where(arel_table[publishable_field].eq(nil)) } 29 | end 30 | end 31 | 32 | # Instance methods 33 | # Publish the record 34 | # Example: 35 | # record.publish! 36 | def publish! 37 | update(self.class.publishable_field => Time.zone.now) 38 | end 39 | 40 | # Unpublish the record 41 | # Example: 42 | # record.unpublish! 43 | def unpublish! 44 | update(self.class.publishable_field => nil) 45 | end 46 | 47 | # Check if the record is published 48 | # Example: 49 | # record.published? 50 | def published? 51 | self[self.class.publishable_field].present? 52 | end 53 | 54 | # Check if the record is unpublished 55 | # Example: 56 | # record.unpublished? 57 | def unpublished? 58 | !published? 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/concerns_on_rails/sluggable.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | require "friendly_id" 3 | 4 | module ConcernsOnRails 5 | module Sluggable 6 | extend ActiveSupport::Concern 7 | 8 | # instance methods 9 | included do 10 | # declare class attributes and set default values 11 | class_attribute :sluggable_field, instance_accessor: false 12 | self.sluggable_field ||= :name 13 | 14 | extend FriendlyId 15 | # we need use a lambda to access the instance variable 16 | # instead of friendly_id :slug_source, use: :slugged 17 | friendly_id :slug_source, use: :slugged 18 | # friendly_id ->(record) { record.slug_source }, use: :slugged 19 | 20 | # we must override should_generate_new_friendly_id? to support update slug 21 | # if we don't override this method, friendly_id will not generate the new slug when update 22 | define_method :should_generate_new_friendly_id? do 23 | field = self.class.sluggable_field 24 | respond_to?("will_save_change_to_#{field}?") && send("will_save_change_to_#{field}?") 25 | end 26 | end 27 | 28 | # class methods 29 | class_methods do 30 | # Define sluggable field 31 | # Example: 32 | # sluggable_by :wonderful_name 33 | def sluggable_by(field) 34 | self.sluggable_field = field.to_sym 35 | 36 | validate_sluggable_field! 37 | end 38 | 39 | private 40 | # Validate sluggable_field exists in database 41 | def validate_sluggable_field! 42 | unless column_names.include?(sluggable_field.to_s) 43 | raise ArgumentError, "ConcernsOnRails::Sluggable: sluggable_field '#{sluggable_field}' does not exist in the database" 44 | end 45 | end 46 | end 47 | 48 | # Instance methods 49 | # Returns the source for the slug 50 | # we are calling the class attribute, so we can use it in the lambda 51 | # Example: 52 | # record.slug_source 53 | def slug_source 54 | if self.class.sluggable_field.present? && respond_to?(self.class.sluggable_field) 55 | send(self.class.sluggable_field) 56 | elsif respond_to?(:title) 57 | title 58 | else 59 | to_s 60 | end 61 | end 62 | end 63 | end -------------------------------------------------------------------------------- /lib/concerns_on_rails/soft_deletable.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | 3 | module ConcernsOnRails 4 | module SoftDeletable 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | # declare class attributes and set default values 9 | class_attribute :soft_delete_field, instance_accessor: false, default: :deleted_at 10 | class_attribute :soft_delete_touch, instance_accessor: false, default: true 11 | 12 | # scopes 13 | scope :active, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) } 14 | scope :without_deleted, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) } 15 | scope :soft_deleted, -> { unscope(where: soft_delete_field).where.not(soft_delete_field => nil) } 16 | # Optionally, uncomment to hide deleted by default: 17 | default_scope { without_deleted } 18 | 19 | # define callbacks 20 | define_model_callbacks :soft_delete 21 | define_model_callbacks :restore 22 | end 23 | 24 | class_methods do 25 | # Define soft delete field and options 26 | # Example: 27 | # soft_deletable_by :deleted_at, touch: false 28 | def soft_deletable_by(field = nil, touch: true) 29 | self.soft_delete_field = field || :deleted_at 30 | self.soft_delete_touch = touch 31 | 32 | unless column_names.include?(soft_delete_field.to_s) 33 | raise ArgumentError, "ConcernsOnRails::SoftDeletable: soft_delete_field '#{soft_delete_field}' does not exist in the database" 34 | end 35 | end 36 | 37 | # Override destroy_all to perform soft delete on all records 38 | def destroy_all 39 | all.each(&:soft_delete!) 40 | end 41 | 42 | # Provide really_destroy_all to hard delete all records 43 | def really_destroy_all 44 | unscoped.delete_all 45 | end 46 | end 47 | 48 | # Soft delete hooks 49 | def before_soft_delete; end 50 | def after_soft_delete; end 51 | def before_restore; end 52 | def after_restore; end 53 | 54 | # add soft delete methods 55 | def soft_delete! 56 | return true if deleted? 57 | run_callbacks(:soft_delete) do 58 | before_soft_delete 59 | if self.class.soft_delete_touch 60 | update(self.class.soft_delete_field => Time.zone.now).tap do |result| 61 | touch if respond_to?(:touch) 62 | after_soft_delete if result 63 | end 64 | else 65 | update_column(self.class.soft_delete_field, Time.zone.now).tap do |result| 66 | after_soft_delete if result 67 | end 68 | end 69 | end 70 | end 71 | 72 | # really delete the record 73 | def really_delete! 74 | destroy 75 | end 76 | 77 | def restore! 78 | run_callbacks(:restore) do 79 | before_restore 80 | if self.class.soft_delete_touch 81 | update(self.class.soft_delete_field => nil).tap do |result| 82 | touch if respond_to?(:touch) 83 | after_restore if result 84 | end 85 | else 86 | update_column(self.class.soft_delete_field, nil).tap do |result| 87 | after_restore if result 88 | end 89 | end 90 | end 91 | end 92 | 93 | def deleted? 94 | self[self.class.soft_delete_field].present? 95 | end 96 | 97 | # alias methods 98 | # define here to avoid issue: undefined method `deleted?' for module `ConcernsOnRails::SoftDeletable' 99 | alias_method :is_soft_deleted?, :deleted? 100 | alias_method :soft_deleted?, :deleted? 101 | 102 | # Is really deleted? 103 | def is_really_deleted? 104 | !self.class.exists?(id) 105 | end 106 | end 107 | end 108 | 109 | # Usage Example: 110 | # class MyModel < ApplicationRecord 111 | # include ConcernsOnRails::SoftDeletable 112 | # soft_deletable_by :deleted_at 113 | # end 114 | -------------------------------------------------------------------------------- /lib/concerns_on_rails/sortable.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | require "acts_as_list" 3 | 4 | module ConcernsOnRails 5 | module Sortable 6 | extend ActiveSupport::Concern 7 | 8 | # instance methods 9 | # include Sortable in model to enable sorting 10 | # Example: 11 | # class Task < ApplicationRecord 12 | # include Sortable 13 | # sortable_by :priority 14 | # end 15 | included do 16 | # declare class attributes 17 | class_attribute :sortable_field, instance_accessor: false 18 | class_attribute :sortable_direction, instance_accessor: false 19 | 20 | # set default values 21 | self.sortable_field ||= :position 22 | self.sortable_direction ||= :asc 23 | 24 | # we cannot use acts_as_list here 25 | default_scope { order(sortable_field => sortable_direction) } 26 | end 27 | 28 | # class methods 29 | # Example: Task.sortable_by(priority: :asc) 30 | class_methods do 31 | # Define sortable field and direction 32 | # Example: 33 | # sortable_by :position 34 | # sortable_by position: :asc 35 | # sortable_by position: :desc 36 | # 37 | # sortable_by :position, use_acts_as_list: false 38 | def sortable_by(field_config, use_acts_as_list: true) 39 | # parse field_config 40 | field, direction = parse_sortable_config(field_config) 41 | 42 | # validate direction and must be :asc or :desc 43 | direction = :asc unless %i[asc desc].include?(direction) 44 | 45 | # set class attributes 46 | self.sortable_field = field 47 | self.sortable_direction = direction 48 | 49 | validate_sortable_field! 50 | 51 | # add acts_as_list and default scope 52 | # Setup sorting behaviors 53 | acts_as_list column: sortable_field if use_acts_as_list 54 | 55 | # add default scope: position => asc 56 | default_scope { order(sortable_field => sortable_direction) } 57 | end 58 | 59 | private 60 | def parse_sortable_config(config) 61 | if config.is_a?(Hash) 62 | # extract key and value 63 | # when we call .first, we get the first key-value pair 64 | # Example: { position: :asc }.first => ["position", :asc] 65 | key, value = config.first 66 | [key.to_sym, value.to_sym] 67 | else 68 | [config.to_sym, :asc] 69 | end 70 | end 71 | 72 | # Validate sortable_field exists in database 73 | def validate_sortable_field! 74 | unless column_names.include?(sortable_field.to_s) 75 | raise ArgumentError, "ConcernsOnRails::Sortable: sortable_field '#{sortable_field}' does not exist in the database" 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/concerns_on_rails/version.rb: -------------------------------------------------------------------------------- 1 | module ConcernsOnRails 2 | VERSION = "1.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/concerns/publishable_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ConcernsOnRails::Publishable do 4 | before do 5 | ActiveRecord::Schema.define do 6 | create_table :articles, force: true do |t| 7 | t.string :title 8 | t.datetime :published_at 9 | t.boolean :is_published 10 | end 11 | end 12 | 13 | class Article < TestModel 14 | include ConcernsOnRails::Publishable 15 | publishable_by :published_at 16 | end 17 | end 18 | 19 | after(:each) do 20 | ActiveRecord::Base.connection.tables.each do |table| 21 | next if table == "schema_migrations" 22 | ActiveRecord::Base.connection.drop_table(table) 23 | end 24 | end 25 | 26 | it "defaults to unpublished" do 27 | article = Article.create!(title: "Draft") 28 | expect(article.published?).to be false 29 | expect(article.unpublished?).to be true 30 | end 31 | 32 | it "publishes and unpublishes the article" do 33 | article = Article.create!(title: "News") 34 | article.publish! 35 | expect(article.published?).to be true 36 | 37 | article.unpublish! 38 | expect(article.published?).to be false 39 | expect(article.unpublished?).to be true 40 | end 41 | 42 | it "returns only published articles" do 43 | Article.create!(title: "Visible Article", published_at: Time.now) 44 | Article.create!(title: "Hidden Article", published_at: nil) 45 | expect(Article.published.map(&:title)).to eq(["Visible Article"]) 46 | end 47 | 48 | it "returns only unpublished articles" do 49 | Article.create!(title: "Visible Article", published_at: Time.now) 50 | Article.create!(title: "Hidden Article", published_at: nil) 51 | expect(Article.unpublished.map(&:title)).to eq(["Hidden Article"]) 52 | end 53 | 54 | it "allows dynamic field configuration" do 55 | ActiveRecord::Schema.define do 56 | create_table :custom_articles, force: true do |t| 57 | t.string :title 58 | t.boolean :is_published 59 | end 60 | end 61 | 62 | class CustomArticle < TestModel 63 | include ConcernsOnRails::Publishable 64 | publishable_by :is_published 65 | end 66 | 67 | article = CustomArticle.create!(title: "Custom") 68 | expect(article.published?).to be false 69 | 70 | article.publish! 71 | expect(article.published?).to be true 72 | 73 | article.unpublish! 74 | expect(article.unpublished?).to be true 75 | end 76 | 77 | it "raises error if field does not exist" do 78 | ActiveRecord::Schema.define do 79 | create_table :invalid_articles, force: true do |t| 80 | t.string :title 81 | t.datetime :published_at 82 | t.boolean :is_published 83 | end 84 | end 85 | 86 | expect { 87 | class InvalidArticle < TestModel 88 | include ConcernsOnRails::Publishable 89 | publishable_by :non_existing_field 90 | end 91 | }.to raise_error(ArgumentError) 92 | end 93 | 94 | it "supports custom publish time" do 95 | article = Article.create!(title: "Timed") 96 | time = Time.now - 1.day 97 | article.update(published_at: time) 98 | expect(article.published?).to be true 99 | expect(article.published_at.to_i).to eq(time.to_i) 100 | end 101 | 102 | it "allows multiple reconfigurations with different fields" do 103 | ActiveRecord::Schema.define do 104 | create_table :reconfig_articles, force: true do |t| 105 | t.string :title 106 | t.datetime :published_at 107 | t.boolean :is_published 108 | end 109 | end 110 | 111 | class ReconfigArticle < TestModel 112 | include ConcernsOnRails::Publishable 113 | end 114 | 115 | ReconfigArticle.publishable_by :is_published 116 | expect(ReconfigArticle.publishable_field).to eq(:is_published) 117 | 118 | ReconfigArticle.publishable_by :published_at 119 | expect(ReconfigArticle.publishable_field).to eq(:published_at) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/concerns/sluggable_spec.rb: -------------------------------------------------------------------------------- 1 | describe ConcernsOnRails::Sluggable do 2 | before do 3 | ActiveRecord::Schema.define do 4 | create_table :pages, force: true do |t| 5 | t.string :title 6 | t.string :slug 7 | t.timestamps 8 | end 9 | end 10 | 11 | class Page < TestModel 12 | extend FriendlyId 13 | include ConcernsOnRails::Sluggable 14 | 15 | sluggable_by :title 16 | end 17 | end 18 | 19 | after(:each) do 20 | ActiveRecord::Base.connection.tables.each do |table| 21 | next if table == "schema_migrations" 22 | ActiveRecord::Base.connection.drop_table(table) 23 | end 24 | end 25 | 26 | it "generates slug from field" do 27 | page = Page.create!(title: "My First Page") 28 | expect(page.slug).to eq("my-first-page") 29 | end 30 | 31 | it "returns slug_source from field" do 32 | page = Page.new(title: "Nice Page") 33 | expect(page.slug_source).to eq("Nice Page") 34 | end 35 | 36 | it "updates slug if title changes" do 37 | page = Page.create!(title: "Initial Title") 38 | page.update(title: "Updated Title") 39 | expect(page.slug).to eq("updated-title") 40 | end 41 | 42 | it "falls back to to_s if sluggable_field is missing" do 43 | ActiveRecord::Schema.define do 44 | create_table :fallback_models, force: true do |t| 45 | t.string :slug 46 | end 47 | end 48 | 49 | class FallbackModel < TestModel 50 | def to_s 51 | "fallback-value" 52 | end 53 | 54 | def self.column_names 55 | [] 56 | end 57 | 58 | include ConcernsOnRails::Sluggable 59 | end 60 | 61 | expect(FallbackModel.new.slug_source).to eq("fallback-value") 62 | end 63 | 64 | it "raises error if sluggable field is missing" do 65 | ActiveRecord::Schema.define do 66 | create_table :invalid_pages, force: true do |t| 67 | t.string :title 68 | t.string :slug 69 | end 70 | end 71 | 72 | expect do 73 | class InvalidPage < TestModel 74 | include ConcernsOnRails::Sluggable 75 | sluggable_by :nonexistent_field 76 | end 77 | end.to raise_error(ArgumentError) 78 | end 79 | 80 | it "supports dynamic sluggable field" do 81 | ActiveRecord::Schema.define do 82 | create_table :dynamic_pages, force: true do |t| 83 | t.string :custom_title 84 | t.string :slug 85 | end 86 | end 87 | 88 | class DynamicPage < TestModel 89 | extend FriendlyId 90 | include ConcernsOnRails::Sluggable 91 | 92 | sluggable_by :custom_title 93 | end 94 | 95 | page = DynamicPage.create!(custom_title: "Dynamic Slug") 96 | expect(page.slug).to eq("dynamic-slug") 97 | end 98 | 99 | it "ensures unique slugs for duplicate titles" do 100 | Page.create!(title: "Same") 101 | second = Page.create!(title: "Same") 102 | expect(second.slug).to match(/^same(-[-\w]+)?$/) 103 | end 104 | 105 | it "generates slug from unicode characters" do 106 | page = Page.create!(title: "Tiếng Việt có dấu") 107 | expect(page.slug).to eq("ti-ng-vi-t-co-d-u") 108 | end 109 | 110 | it "does not update slug if sluggable field did not change" do 111 | page = Page.create!(title: "Same Title") 112 | original_slug = page.slug 113 | page.update(updated_at: Time.now) 114 | expect(page.slug).to eq(original_slug) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/concerns/soft_deletable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | 4 | describe ConcernsOnRails::SoftDeletable do 5 | # Setup a dummy model for testing 6 | let(:dummy_class) do 7 | Class.new(ActiveRecord::Base) do 8 | self.table_name = 'dummy_soft_deletables' 9 | include ConcernsOnRails::SoftDeletable 10 | soft_deletable_by :deleted_at 11 | 12 | # For callback test 13 | attr_accessor :callback_log 14 | def before_soft_delete; @callback_log ||= []; @callback_log << :before_soft_delete; end 15 | def after_soft_delete; @callback_log ||= []; @callback_log << :after_soft_delete; end 16 | def before_restore; @callback_log ||= []; @callback_log << :before_restore; end 17 | def after_restore; @callback_log ||= []; @callback_log << :after_restore; end 18 | end 19 | end 20 | 21 | before(:all) do 22 | ActiveRecord::Schema.define do 23 | create_table :dummy_soft_deletables, force: true do |t| 24 | t.string :name 25 | t.datetime :deleted_at 26 | t.timestamps null: false 27 | end 28 | end 29 | end 30 | 31 | let!(:record) { dummy_class.create!(name: 'test') } 32 | 33 | describe '.soft_deletable_by' do 34 | it 'raises error if field does not exist' do 35 | expect { 36 | Class.new(ActiveRecord::Base) do 37 | self.table_name = 'dummy_soft_deletables' 38 | include ConcernsOnRails::SoftDeletable 39 | soft_deletable_by :not_a_field 40 | end 41 | }.to raise_error(ArgumentError) 42 | end 43 | it 'sets the soft delete field' do 44 | expect(dummy_class.soft_delete_field).to eq(:deleted_at) 45 | end 46 | end 47 | 48 | describe 'scopes' do 49 | it 'returns active/without_deleted records' do 50 | expect(dummy_class.active).to include(record) 51 | expect(dummy_class.without_deleted).to include(record) 52 | end 53 | it 'returns no soft_deleted records initially' do 54 | expect(dummy_class.soft_deleted).to be_empty 55 | end 56 | end 57 | 58 | describe '#soft_delete!' do 59 | it 'sets the deleted_at field' do 60 | expect { record.soft_delete! }.to change { record.reload.deleted_at }.from(nil) 61 | expect(record).to be_deleted 62 | expect(record).to be_is_soft_deleted 63 | expect(record).to be_soft_deleted 64 | end 65 | it 'runs callbacks' do 66 | record.callback_log = [] 67 | record.soft_delete! 68 | expect(record.callback_log).to include(:before_soft_delete, :after_soft_delete) 69 | end 70 | it 'touches updated_at if enabled' do 71 | t = record.updated_at 72 | sleep 1 73 | record.soft_delete! 74 | expect(record.reload.updated_at).to be > t 75 | end 76 | it 'does not touch updated_at if disabled' do 77 | dummy_class.soft_deletable_by :deleted_at, touch: false 78 | t = record.updated_at 79 | sleep 1 80 | record.soft_delete! 81 | expect(record.reload.updated_at).to eq t 82 | end 83 | end 84 | 85 | describe '#restore!' do 86 | before { record.soft_delete! } 87 | it 'restores the record' do 88 | expect { record.restore! }.to change { record.reload.deleted_at }.to(nil) 89 | expect(record).not_to be_deleted 90 | end 91 | it 'runs callbacks' do 92 | record.callback_log = [] 93 | record.restore! 94 | expect(record.callback_log).to include(:before_restore, :after_restore) 95 | end 96 | it 'touches updated_at if enabled' do 97 | t = record.updated_at 98 | sleep 1 99 | record.restore! 100 | expect(record.reload.updated_at).to be > t 101 | end 102 | it 'does not touch updated_at if disabled' do 103 | dummy_class.soft_deletable_by :deleted_at, touch: false 104 | record.soft_delete! 105 | t = record.updated_at 106 | sleep 1 107 | record.restore! 108 | expect(record.reload.updated_at).to eq t 109 | end 110 | end 111 | 112 | describe '#really_delete!' do 113 | it 'destroys the record' do 114 | expect { record.really_delete! }.to change { dummy_class.count }.by(-1) 115 | end 116 | end 117 | 118 | describe '#is_really_deleted?' do 119 | it 'returns false if record exists' do 120 | expect(record.is_really_deleted?).to be false 121 | end 122 | it 'returns true after destroy' do 123 | record.really_delete! 124 | expect(record.is_really_deleted?).to be true 125 | end 126 | end 127 | 128 | context 'with multiple models using SoftDeletable' do 129 | let(:other_class) do 130 | Class.new(ActiveRecord::Base) do 131 | self.table_name = 'other_soft_deletables' 132 | include ConcernsOnRails::SoftDeletable 133 | soft_deletable_by :removed_on 134 | end 135 | end 136 | before(:all) do 137 | ActiveRecord::Schema.define do 138 | create_table :other_soft_deletables, force: true do |t| 139 | t.string :name 140 | t.datetime :removed_on 141 | t.timestamps null: false 142 | end 143 | end 144 | end 145 | let!(:other) { other_class.create!(name: 'other') } 146 | it 'does not interfere with other models' do 147 | expect(other_class.active).to include(other) 148 | other.soft_delete! 149 | expect(other_class.soft_deleted).to include(other) 150 | end 151 | end 152 | 153 | context 'with custom soft delete field' do 154 | let(:custom_class) do 155 | Class.new(ActiveRecord::Base) do 156 | self.table_name = 'custom_soft_deletables' 157 | include ConcernsOnRails::SoftDeletable 158 | soft_deletable_by :removed_on 159 | end 160 | end 161 | before(:all) do 162 | ActiveRecord::Schema.define do 163 | create_table :custom_soft_deletables, force: true do |t| 164 | t.string :name 165 | t.datetime :removed_on 166 | t.timestamps null: false 167 | end 168 | end 169 | end 170 | let!(:custom) { custom_class.create!(name: 'custom') } 171 | it 'soft deletes and restores using custom field' do 172 | expect { custom.soft_delete! }.to change { custom.reload.removed_on }.from(nil) 173 | expect(custom_class.soft_deleted).to include(custom) 174 | expect { custom.restore! }.to change { custom.reload.removed_on }.to(nil) 175 | expect(custom_class.active).to include(custom) 176 | end 177 | end 178 | 179 | context 'callbacks order' do 180 | it 'calls callbacks in order' do 181 | record.callback_log = [] 182 | record.soft_delete! 183 | expect(record.callback_log).to eq([:before_soft_delete, :after_soft_delete]) 184 | record.callback_log = [] 185 | record.restore! 186 | expect(record.callback_log).to eq([:before_restore, :after_restore]) 187 | end 188 | end 189 | 190 | context 'idempotency' do 191 | it 'soft_delete! twice does not error and does not change deleted_at again' do 192 | record.soft_delete! 193 | t = record.deleted_at 194 | sleep 1 195 | expect { record.soft_delete! }.not_to change { record.reload.deleted_at } 196 | end 197 | it 'restore! twice does not error and does not change deleted_at' do 198 | record.restore! 199 | expect { record.restore! }.not_to change { record.reload.deleted_at } 200 | end 201 | end 202 | 203 | context 'return values' do 204 | it 'returns true on successful soft_delete!' do 205 | expect(record.soft_delete!).to eq(true) 206 | end 207 | it 'returns true on successful restore!' do 208 | record.soft_delete! 209 | expect(record.restore!).to eq(true) 210 | end 211 | end 212 | 213 | context 'validation/failure cases' do 214 | it 'soft_delete! still works if other validations fail' do 215 | allow(record).to receive(:update).and_return(false) 216 | expect(record.soft_delete!).to eq(false) 217 | end 218 | it 'restore! still works if other validations fail' do 219 | record.soft_delete! 220 | allow(record).to receive(:update).and_return(false) 221 | expect(record.restore!).to eq(false) 222 | end 223 | end 224 | 225 | context 'scope chaining' do 226 | it 'returns no record when chaining soft_deleted and active' do 227 | record.soft_delete! 228 | # Chaining these scopes is not meaningful due to unscope usage, so test intersection instead 229 | expect(dummy_class.soft_deleted & dummy_class.active).to be_empty 230 | end 231 | end 232 | 233 | context 'STI (Single Table Inheritance)' do 234 | before do 235 | ActiveRecord::Schema.define do 236 | create_table :sti_models, force: true do |t| 237 | t.string :type 238 | t.string :name 239 | t.datetime :deleted_at 240 | t.timestamps null: false 241 | end 242 | end 243 | 244 | class StiModel < ActiveRecord::Base 245 | self.table_name = 'sti_models' 246 | include ConcernsOnRails::SoftDeletable 247 | soft_deletable_by :deleted_at 248 | end 249 | class ChildModel < StiModel; end 250 | end 251 | 252 | it 'works for subclasses' do 253 | child = ChildModel.create!(name: 'child') 254 | expect(ChildModel.active).to include(child) 255 | child.soft_delete! 256 | expect(ChildModel.soft_deleted).to include(child) 257 | child.restore! 258 | expect(ChildModel.active).to include(child) 259 | end 260 | end 261 | 262 | context 'with default_scope enabled' do 263 | let(:scoped_class) do 264 | Class.new(ActiveRecord::Base) do 265 | self.table_name = 'scoped_soft_deletables' 266 | include ConcernsOnRails::SoftDeletable 267 | default_scope { without_deleted } 268 | soft_deletable_by :deleted_at 269 | end 270 | end 271 | before(:all) do 272 | ActiveRecord::Schema.define do 273 | create_table :scoped_soft_deletables, force: true do |t| 274 | t.string :name 275 | t.datetime :deleted_at 276 | t.timestamps null: false 277 | end 278 | end 279 | end 280 | let!(:active) { scoped_class.create!(name: 'active') } 281 | let!(:deleted) { scoped_class.create!(name: 'deleted', deleted_at: Time.zone.now) } 282 | it 'hides soft deleted records by default' do 283 | expect(scoped_class.all).to include(active) 284 | expect(scoped_class.all).not_to include(deleted) 285 | end 286 | it 'can find soft deleted records with unscoped' do 287 | expect(scoped_class.unscoped.all).to include(deleted) 288 | end 289 | it 'still allows soft_delete! and restore! to work' do 290 | expect { active.soft_delete! }.to change { active.reload.deleted_at }.from(nil) 291 | expect(scoped_class.all).not_to include(active) 292 | expect { active.restore! }.to change { active.reload.deleted_at }.to(nil) 293 | expect(scoped_class.all).to include(active) 294 | end 295 | end 296 | 297 | describe '.destroy_all' do 298 | let!(:record1) { dummy_class.create!(name: 'foo') } 299 | let!(:record2) { dummy_class.create!(name: 'bar') } 300 | 301 | it 'soft deletes all records created in this test' do 302 | dummy_class.destroy_all 303 | expect(record1.reload).to be_deleted 304 | expect(record2.reload).to be_deleted 305 | end 306 | end 307 | 308 | describe '.really_destroy_all' do 309 | before do 310 | dummy_class.create!(name: 'baz') 311 | dummy_class.create!(name: 'qux') 312 | end 313 | 314 | it 'hard deletes all records' do 315 | expect { 316 | dummy_class.really_destroy_all 317 | }.to change { dummy_class.count }.to(0) 318 | end 319 | end 320 | end 321 | -------------------------------------------------------------------------------- /spec/concerns/sortable_spec.rb: -------------------------------------------------------------------------------- 1 | describe ConcernsOnRails::Sortable do 2 | before(:each) do 3 | ActiveRecord::Schema.define do 4 | create_table :tasks, force: true do |t| 5 | t.string :name 6 | t.integer :position 7 | t.integer :priority 8 | end 9 | end 10 | 11 | class Task < TestModel 12 | include ConcernsOnRails::Sortable 13 | end 14 | end 15 | 16 | after(:each) do 17 | ActiveRecord::Base.connection.tables.each do |table| 18 | next if table == "schema_migrations" 19 | ActiveRecord::Base.connection.drop_table(table) 20 | end 21 | end 22 | 23 | context "with default configuration" do 24 | it "sorts records by position ascending" do 25 | Task.create!(name: "Task B", position: 2) 26 | Task.create!(name: "Task A", position: 1) 27 | Task.create!(name: "Task C", position: 3) 28 | 29 | names = Task.pluck(:name) 30 | expect(names).to eq(["Task A", "Task B", "Task C"]) 31 | end 32 | end 33 | 34 | context "with custom field and direction" do 35 | before do 36 | ActiveRecord::Schema.define do 37 | create_table :priority_tasks, force: true do |t| 38 | t.string :name 39 | t.integer :priority 40 | end 41 | end 42 | 43 | class PriorityTask < TestModel 44 | include ConcernsOnRails::Sortable 45 | sortable_by priority: :desc 46 | end 47 | end 48 | 49 | it "sorts by priority descending" do 50 | PriorityTask.create!(name: "Low", priority: 1) 51 | PriorityTask.create!(name: "High", priority: 3) 52 | PriorityTask.create!(name: "Medium", priority: 2) 53 | 54 | expect(PriorityTask.all.pluck(:name)).to eq(["High", "Medium", "Low"]) 55 | end 56 | end 57 | 58 | context "when given invalid field" do 59 | it "raises error when sortable field is missing from DB" do 60 | ActiveRecord::Schema.define do 61 | create_table :invalid_tasks, force: true do |t| 62 | t.string :name 63 | end 64 | end 65 | 66 | expect { 67 | class InvalidTask < TestModel 68 | include ConcernsOnRails::Sortable 69 | sortable_by :nonexistent_column 70 | end 71 | }.to raise_error(ArgumentError, /sortable_field 'nonexistent_column' does not exist/) 72 | end 73 | end 74 | 75 | context "when given invalid direction" do 76 | before do 77 | ActiveRecord::Schema.define do 78 | create_table :fallback_direction_tasks, force: true do |t| 79 | t.string :name 80 | t.integer :priority 81 | end 82 | end 83 | 84 | class FallbackDirectionTask < TestModel 85 | include ConcernsOnRails::Sortable 86 | sortable_by priority: :invalid_direction 87 | end 88 | end 89 | 90 | it "defaults to ascending if direction is invalid" do 91 | FallbackDirectionTask.create!(name: "Low", priority: 1) 92 | FallbackDirectionTask.create!(name: "High", priority: 3) 93 | FallbackDirectionTask.create!(name: "Medium", priority: 2) 94 | 95 | expect(FallbackDirectionTask.all.pluck(:name)).to eq(["Low", "Medium", "High"]) 96 | end 97 | end 98 | 99 | context "when sortable_by is called multiple times" do 100 | before do 101 | ActiveRecord::Schema.define do 102 | create_table :multi_sortable_tasks, force: true do |t| 103 | t.string :name 104 | t.integer :position 105 | t.integer :priority 106 | end 107 | end 108 | 109 | class MultiSortableTask < TestModel 110 | include ConcernsOnRails::Sortable 111 | sortable_by :position 112 | end 113 | end 114 | 115 | it "respects the latest sortable_by config" do 116 | MultiSortableTask.sortable_by(priority: :desc) 117 | 118 | MultiSortableTask.create!(name: "Low", priority: 1, position: 1) 119 | MultiSortableTask.create!(name: "Medium", priority: 2, position: 3) 120 | MultiSortableTask.create!(name: "High", priority: 3, position: 2) 121 | 122 | expect(MultiSortableTask.all.pluck(:name)).to eq(["High", "Medium", "Low"]) 123 | end 124 | end 125 | 126 | context "when sortable_by is called multiple times" do 127 | before do 128 | ActiveRecord::Schema.define do 129 | create_table :multi_sortable_tasks, force: true do |t| 130 | t.string :name 131 | t.integer :position 132 | t.integer :priority 133 | end 134 | end 135 | 136 | class MultiSortableTask < TestModel 137 | include ConcernsOnRails::Sortable 138 | sortable_by :position 139 | end 140 | end 141 | 142 | it "respects the latest sortable_by config" do 143 | MultiSortableTask.sortable_by(priority: :desc) 144 | 145 | MultiSortableTask.create!(name: "Low", priority: 1, position: 1) 146 | MultiSortableTask.create!(name: "Medium", priority: 2, position: 3) 147 | MultiSortableTask.create!(name: "High", priority: 3, position: 2) 148 | 149 | expect(MultiSortableTask.all.pluck(:name)).to eq(["High", "Medium", "Low"]) 150 | end 151 | end 152 | 153 | context "with sorting but without acts_as_list" do 154 | before do 155 | ActiveRecord::Schema.define do 156 | create_table :simple_tasks, force: true do |t| 157 | t.string :name 158 | t.integer :priority 159 | end 160 | end 161 | 162 | class SimpleTask < TestModel 163 | include ConcernsOnRails::Sortable 164 | sortable_by :priority, use_acts_as_list: false 165 | end 166 | end 167 | 168 | it "sorts correctly without using acts_as_list" do 169 | SimpleTask.create!(name: "Low", priority: 1) 170 | SimpleTask.create!(name: "High", priority: 3) 171 | SimpleTask.create!(name: "Medium", priority: 2) 172 | 173 | names = SimpleTask.all.pluck(:name) 174 | expect(names).to eq(["Low", "Medium", "High"]) 175 | end 176 | end 177 | 178 | context "acts_as_list functionality" do 179 | before do 180 | ActiveRecord::Schema.define do 181 | create_table :tasks, force: true do |t| 182 | t.string :name 183 | t.integer :position 184 | end 185 | end 186 | 187 | class Task < TestModel 188 | include ConcernsOnRails::Sortable 189 | sortable_by :position 190 | end 191 | end 192 | 193 | after do 194 | ActiveRecord::Base.connection.drop_table(:tasks) rescue nil 195 | Object.send(:remove_const, :Task) if defined?(Task) 196 | end 197 | 198 | it "automatically assigns position on creation" do 199 | task1 = Task.create!(name: "Task 1") 200 | task2 = Task.create!(name: "Task 2") 201 | task3 = Task.create!(name: "Task 3") 202 | 203 | expect([task1.position, task2.position, task3.position]).to eq([1, 2, 3]) 204 | end 205 | 206 | it "allows moving higher in the list" do 207 | task1 = Task.create!(name: "Task 1") 208 | task2 = Task.create!(name: "Task 2") 209 | 210 | task2.move_higher 211 | 212 | expect(Task.order(:position).pluck(:name)).to eq(["Task 2", "Task 1"]) 213 | end 214 | 215 | it "allows moving lower in the list" do 216 | task1 = Task.create!(name: "Task 1") 217 | task2 = Task.create!(name: "Task 2") 218 | 219 | task1.move_lower 220 | 221 | expect(Task.order(:position).pluck(:name)).to eq(["Task 2", "Task 1"]) 222 | end 223 | 224 | it "can move to top" do 225 | task1 = Task.create!(name: "Task 1") 226 | task2 = Task.create!(name: "Task 2") 227 | task3 = Task.create!(name: "Task 3") 228 | 229 | task3.move_to_top 230 | 231 | expect(Task.order(:position).pluck(:name)).to eq(["Task 3", "Task 1", "Task 2"]) 232 | end 233 | 234 | it "can move to bottom" do 235 | task1 = Task.create!(name: "Task 1") 236 | task2 = Task.create!(name: "Task 2") 237 | task3 = Task.create!(name: "Task 3") 238 | 239 | task1.move_to_bottom 240 | 241 | expect(Task.order(:position).pluck(:name)).to eq(["Task 2", "Task 3", "Task 1"]) 242 | end 243 | 244 | it "reorders remaining items correctly when one is removed" do 245 | task1 = Task.create!(name: "Task 1") 246 | task2 = Task.create!(name: "Task 2") 247 | task3 = Task.create!(name: "Task 3") 248 | 249 | task2.destroy 250 | 251 | expect(Task.order(:position).pluck(:name)).to eq(["Task 1", "Task 3"]) 252 | expect(Task.pluck(:position)).to eq([1, 2]) 253 | end 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "active_record" 3 | require "concerns_on_rails" 4 | require "faker" 5 | require "simplecov" 6 | require "support/database" 7 | require "active_support/core_ext/time" # for Time.zone 8 | 9 | Time.zone = "UTC" 10 | 11 | SimpleCov.start do 12 | add_filter "/spec/" 13 | end 14 | 15 | 16 | RSpec.configure do |config| 17 | config.expect_with :rspec do |expectations| 18 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 19 | end 20 | 21 | config.mock_with :rspec do |mocks| 22 | mocks.verify_partial_doubles = true 23 | end 24 | 25 | config.shared_context_metadata_behavior = :apply_to_host_groups 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/database.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | require "sqlite3" 3 | require "logger" 4 | 5 | # Configure ActiveRecord to use an in-memory SQLite database 6 | ActiveRecord::Base.establish_connection( 7 | adapter: "sqlite3", 8 | database: ":memory:" 9 | ) 10 | 11 | # Optional: log SQL to STDOUT during test run 12 | ActiveRecord::Base.logger = Logger.new(STDOUT) 13 | 14 | # Base class for test models 15 | class TestModel < ActiveRecord::Base 16 | self.abstract_class = true 17 | end --------------------------------------------------------------------------------