diff --git a/Appraisals b/Appraisals index a50ddc6..85db27e 100644 --- a/Appraisals +++ b/Appraisals @@ -1,25 +1,15 @@ -appraise "rails-6" do - gem "activerecord", "~> 6.1.4" - gem "activesupport", "~> 6.1.4" - gem "mutex_m", "~> 0.3.0" - gem "base64" +appraise "rails-7.1" do + gem "activerecord", "~> 7.1.0" + gem "activesupport", "~> 7.1.0" gem "bigdecimal" - gem "sqlite3", "~> 1.4" - - # Fixes uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError) - gem "concurrent-ruby", "< 1.3.5" + gem "sqlite3", "~> 2.6.0" end -appraise "rails-7" do - gem "activerecord", "~> 7.0.1" - gem "activesupport", "~> 7.0.1" - gem "mutex_m", "~> 0.3.0" - gem "base64" +appraise "rails-7.2" do + gem "activerecord", "~> 7.2.0" + gem "activesupport", "~> 7.2.0" gem "bigdecimal" - gem "sqlite3", "~> 1.4" - - # Fixes uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError) - gem "concurrent-ruby", "< 1.3.5" + gem "sqlite3", "~> 2.6.0" end appraise "rails-8" do diff --git a/CHANGELOG.md b/CHANGELOG.md index 004a5ca..5d0af39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 2.0 +* **BREAKING CHANGE:** Minimum Rails version is now 7.1 (dropped Rails 6.x and 7.0) +* **BREAKING CHANGE:** Minimum Ruby version is now 3.2 +* Added support for Rails 7.1 and 7.2 in test matrix +* Reimplemented internals on top of Rails 7.1's `ActiveRecord::Base.normalizes`. The `sanitizes` DSL is unchanged, but normalization now runs at **attribute assignment time** instead of `before_save`. This produces a few subtle behavior changes: + * **Read-after-write returns the normalized value.** `person.name = " john "; person.name` now returns `"john"` immediately, not `" john "`. Validations and any pre-save reads see normalized values. + * **Legacy un-normalized rows are no longer rewritten on every save.** Previously the `before_save` callback rewrote every declared column on each save, opportunistically cleaning up old data. To migrate legacy rows now, reassign the attribute or call `record.normalize_attribute(:col)` before saving. + * **`where` and `find_by` hash conditions now normalize their input.** `User.where(email: " X@Y.com ")` will match a row stored as `"x@y.com"`. Raw-SQL conditions (`where("email = ?", ...)`) still bypass normalization. + * Apps upgrading from Rails < 7.1 should set `config.active_record.marshalling_format_version = 7.1` to avoid `TypeError` when marshalling records with normalized attributes. + # 1.2 * Official support for Rails 8 * Official support for Ruby 3.4 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..11afdcb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +The `sanitization` gem is a Ruby gem that adds automatic string sanitization to ActiveRecord models via a `before_save` callback. It can strip whitespace, collapse multiple spaces, convert empty strings to nil, and apply case transformations. + +## Commands + +```bash +# Run tests (default rake task) +rake spec + +# Run tests with RSpec directly +bundle exec rspec + +# Run a single test file or example +bundle exec rspec spec/sanitization_spec.rb +bundle exec rspec spec/sanitization_spec.rb:42 + +# Run tests across all supported Rails versions (6, 7, 8) +bundle exec appraisal rspec + +# Run tests for a specific Rails version +bundle exec appraisal rails-8 rspec + +# Install appraisal gemfiles after changing Appraisals file +bundle exec appraisal install +``` + +## Architecture + +The gem has three core files: + +- **`lib/sanitization/configuration.rb`** - `Sanitization::Configuration` class with global defaults (strip, collapse, case, nullify, include_text_type). `simple_defaults!` is a shortcut that enables strip + collapse + nullify. +- **`lib/sanitization/active_record_extension.rb`** - The core logic. `ClassMethods.sanitizes()` introspects table columns, filters by type (string, optionally text), validates options, and stores per-column config in `@sanitization__store`. Registers a `before_save` callback that applies transformations in order: strip -> collapse -> nullify -> case. +- **`lib/sanitization.rb`** - Entry point that includes `ActiveRecordExtension` into `ActiveRecord::Base` via `class_eval`. + +## Testing + +Tests use RSpec with an in-memory SQLite database. The `spec_helper.rb` creates a `people` table with string, text, date, and integer columns. A custom `String#leetcase` method is defined for testing custom case transformations. + +CI runs on Ruby 3.2, 3.3, 3.4 against Rails 6.1, 7.0, and 8.0 via Appraisal. + +Note: ActiveSupport 7+ changed `titlecase` behavior (it strips leading spaces), which affects test expectations between Rails versions. + +## Changelog Workflow + +Track changes in the `## Unreleased` section of `CHANGELOG.md` as they are made. When a new version is released, rename the `## Unreleased` heading to the version number (e.g., `# 1.3`) and add a fresh `## Unreleased` section above it. diff --git a/Gemfile.lock b/Gemfile.lock index 9b38135..4d5795c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ PATH remote: . specs: - sanitization (1.2.0) - activerecord - activesupport + sanitization (2.0.0) + activerecord (>= 7.1) + activesupport (>= 7.1) GEM remote: https://rubygems.org/ @@ -43,7 +43,10 @@ GEM i18n (1.14.7) concurrent-ruby (~> 1.0) logger (1.7.0) - minitest (5.25.5) + minitest (6.0.5) + drb (~> 2.0) + prism (~> 1.5) + prism (1.9.0) rake (13.2.1) rspec (3.13.0) rspec-core (~> 3.13.0) @@ -59,16 +62,16 @@ GEM rspec-support (~> 3.13.0) rspec-support (3.13.2) securerandom (0.4.1) - sqlite3 (2.6.0-aarch64-linux-gnu) - sqlite3 (2.6.0-aarch64-linux-musl) - sqlite3 (2.6.0-arm-linux-gnu) - sqlite3 (2.6.0-arm-linux-musl) - sqlite3 (2.6.0-arm64-darwin) - sqlite3 (2.6.0-x86-linux-gnu) - sqlite3 (2.6.0-x86-linux-musl) - sqlite3 (2.6.0-x86_64-darwin) - sqlite3 (2.6.0-x86_64-linux-gnu) - sqlite3 (2.6.0-x86_64-linux-musl) + sqlite3 (2.9.3-aarch64-linux-gnu) + sqlite3 (2.9.3-aarch64-linux-musl) + sqlite3 (2.9.3-arm-linux-gnu) + sqlite3 (2.9.3-arm-linux-musl) + sqlite3 (2.9.3-arm64-darwin) + sqlite3 (2.9.3-x86-linux-gnu) + sqlite3 (2.9.3-x86-linux-musl) + sqlite3 (2.9.3-x86_64-darwin) + sqlite3 (2.9.3-x86_64-linux-gnu) + sqlite3 (2.9.3-x86_64-linux-musl) thor (1.3.2) timeout (0.4.3) tzinfo (2.0.6) @@ -91,7 +94,6 @@ PLATFORMS DEPENDENCIES appraisal (~> 2.5) byebug - concurrent-ruby (< 1.3.5) cucumber-ci-environment rake (~> 13.2) rspec (~> 3.0) diff --git a/README.md b/README.md index fc363b0..3718d82 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,19 @@ Sanitization makes it easy to store slightly cleaner strings to your database. - Change casing (ie. upcase, downcase, titlecase, etc) +### Built on top of `ActiveRecord::Base.normalizes` + +As of 2.0, Sanitization is a thin macro layer on top of Rails 7.1's [`ActiveRecord::Base.normalizes`](https://api.rubyonrails.org/classes/ActiveRecord/Normalization/ClassMethods.html). The `sanitizes` DSL is unchanged — under the hood, each declared column is registered with `normalizes`, and Sanitization composes a single normalizer per column from your `:strip`, `:collapse`, `:nullify`, and `:case` options (plus their global defaults). + +Because `normalizes` runs at attribute **assignment** time (not `before_save`), this gives you a few useful behaviors automatically: + +- **Read-after-write returns the normalized value.** `person.name = " john "; person.name` returns `"john"` immediately — validations and any pre-save reads see the cleaned value. +- **`where`/`find_by` hash conditions normalize the lookup value.** `User.where(email: " RAW@X.COM ")` matches a row stored as `"raw@x.com"`. (Raw-SQL conditions still bypass normalization.) +- **Legacy un-normalized rows are not silently rewritten on save.** Reload + save no longer "heals" old data. To migrate, reassign the attribute or call `record.normalize_attribute(:col)` (a built-in helper from `normalizes`). + +If you're upgrading from 1.x, see the CHANGELOG for the full list of behavior changes. + + ### Defaults By default, Sanitization has all options disabled. It is recommended you use a configuration block to set @@ -45,6 +58,11 @@ Sanitization.simple_defaults! - Change casing: (`case: :none|:up|:down|:custom`) +## Compatibility + +- Ruby >= 3.2 +- Rails >= 7.1 + ## Installation ```sh diff --git a/gemfiles/rails_6.gemfile.lock b/gemfiles/rails_6.gemfile.lock deleted file mode 100644 index 344e545..0000000 --- a/gemfiles/rails_6.gemfile.lock +++ /dev/null @@ -1,86 +0,0 @@ -PATH - remote: .. - specs: - sanitization (1.1.4) - activerecord - activesupport - -GEM - remote: https://rubygems.org/ - specs: - activemodel (6.1.7.10) - activesupport (= 6.1.7.10) - activerecord (6.1.7.10) - activemodel (= 6.1.7.10) - activesupport (= 6.1.7.10) - activesupport (6.1.7.10) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - appraisal (2.5.0) - bundler - rake - thor (>= 0.14.0) - base64 (0.2.0) - bigdecimal (3.1.9) - byebug (12.0.0) - concurrent-ruby (1.3.4) - cucumber-ci-environment (10.0.1) - diff-lcs (1.6.1) - i18n (1.14.7) - concurrent-ruby (~> 1.0) - mini_portile2 (2.8.8) - minitest (5.25.5) - mutex_m (0.3.0) - rake (13.2.1) - rspec (3.13.0) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.3) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.2) - sqlite3 (1.7.3) - mini_portile2 (~> 2.8.0) - thor (1.3.2) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - zeitwerk (2.7.2) - -PLATFORMS - aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl - arm64-darwin - x86-linux-gnu - x86-linux-musl - x86_64-darwin - x86_64-linux-gnu - x86_64-linux-musl - -DEPENDENCIES - activerecord (~> 6.1.4) - activesupport (~> 6.1.4) - appraisal (~> 2.5) - base64 - bigdecimal - byebug - concurrent-ruby (< 1.3.5) - cucumber-ci-environment - mutex_m (~> 0.3.0) - rake (~> 13.2) - rspec (~> 3.0) - sanitization! - sqlite3 (~> 1.4) - -BUNDLED WITH - 2.6.3 diff --git a/gemfiles/rails_6.gemfile b/gemfiles/rails_7.1.gemfile similarity index 58% rename from gemfiles/rails_6.gemfile rename to gemfiles/rails_7.1.gemfile index 5084a08..3291eb6 100644 --- a/gemfiles/rails_6.gemfile +++ b/gemfiles/rails_7.1.gemfile @@ -6,13 +6,10 @@ gem "rake", "~> 13.2" gem "rspec", "~> 3.0" gem "appraisal", "~> 2.5" gem "cucumber-ci-environment" -gem "sqlite3", "~> 1.4" +gem "sqlite3", "~> 2.6.0" gem "byebug" -gem "activerecord", "~> 6.1.4" -gem "activesupport", "~> 6.1.4" -gem "mutex_m", "~> 0.3.0" -gem "base64" +gem "activerecord", "~> 7.1.0" +gem "activesupport", "~> 7.1.0" gem "bigdecimal" -gem "concurrent-ruby", "< 1.3.5" gemspec path: "../" diff --git a/gemfiles/rails_7.1.gemfile.lock b/gemfiles/rails_7.1.gemfile.lock new file mode 100644 index 0000000..4caea02 --- /dev/null +++ b/gemfiles/rails_7.1.gemfile.lock @@ -0,0 +1,113 @@ +PATH + remote: .. + specs: + sanitization (2.0.0) + activerecord (>= 7.1) + activesupport (>= 7.1) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.1.6) + activesupport (= 7.1.6) + activerecord (7.1.6) + activemodel (= 7.1.6) + activesupport (= 7.1.6) + timeout (>= 0.4.0) + activesupport (7.1.6) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) + tzinfo (~> 2.0) + appraisal (2.5.0) + bundler + rake + thor (>= 0.14.0) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + byebug (13.0.0) + reline (>= 0.6.0) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + cucumber-ci-environment (13.0.0) + diff-lcs (1.6.2) + drb (2.2.3) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + io-console (0.8.2) + logger (1.7.0) + mini_portile2 (2.8.9) + minitest (6.0.5) + drb (~> 2.0) + prism (~> 1.5) + mutex_m (0.3.0) + prism (1.9.0) + rake (13.4.2) + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + securerandom (0.4.1) + sqlite3 (2.6.0) + mini_portile2 (~> 2.8.0) + sqlite3 (2.6.0-aarch64-linux-gnu) + sqlite3 (2.6.0-aarch64-linux-musl) + sqlite3 (2.6.0-arm-linux-gnu) + sqlite3 (2.6.0-arm-linux-musl) + sqlite3 (2.6.0-arm64-darwin) + sqlite3 (2.6.0-x86-linux-gnu) + sqlite3 (2.6.0-x86-linux-musl) + sqlite3 (2.6.0-x86_64-darwin) + sqlite3 (2.6.0-x86_64-linux-gnu) + sqlite3 (2.6.0-x86_64-linux-musl) + thor (1.5.0) + timeout (0.6.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + activerecord (~> 7.1.0) + activesupport (~> 7.1.0) + appraisal (~> 2.5) + bigdecimal + byebug + cucumber-ci-environment + rake (~> 13.2) + rspec (~> 3.0) + sanitization! + sqlite3 (~> 2.6.0) + +BUNDLED WITH + 2.6.3 diff --git a/gemfiles/rails_7.gemfile b/gemfiles/rails_7.2.gemfile similarity index 58% rename from gemfiles/rails_7.gemfile rename to gemfiles/rails_7.2.gemfile index 7179ce8..6625a0e 100644 --- a/gemfiles/rails_7.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -6,13 +6,10 @@ gem "rake", "~> 13.2" gem "rspec", "~> 3.0" gem "appraisal", "~> 2.5" gem "cucumber-ci-environment" -gem "sqlite3", "~> 1.4" +gem "sqlite3", "~> 2.6.0" gem "byebug" -gem "activerecord", "~> 7.0.1" -gem "activesupport", "~> 7.0.1" -gem "mutex_m", "~> 0.3.0" -gem "base64" +gem "activerecord", "~> 7.2.0" +gem "activesupport", "~> 7.2.0" gem "bigdecimal" -gem "concurrent-ruby", "< 1.3.5" gemspec path: "../" diff --git a/gemfiles/rails_7.2.gemfile.lock b/gemfiles/rails_7.2.gemfile.lock new file mode 100644 index 0000000..f34f9a5 --- /dev/null +++ b/gemfiles/rails_7.2.gemfile.lock @@ -0,0 +1,108 @@ +PATH + remote: .. + specs: + sanitization (2.0.0) + activerecord (>= 7.1) + activesupport (>= 7.1) + +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.2.3.1) + activesupport (= 7.2.3.1) + activerecord (7.2.3.1) + activemodel (= 7.2.3.1) + activesupport (= 7.2.3.1) + timeout (>= 0.4.0) + activesupport (7.2.3.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + appraisal (2.5.0) + bundler + rake + thor (>= 0.14.0) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + byebug (13.0.0) + reline (>= 0.6.0) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + cucumber-ci-environment (13.0.0) + diff-lcs (1.6.2) + drb (2.2.3) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + io-console (0.8.2) + logger (1.7.0) + mini_portile2 (2.8.9) + minitest (5.27.0) + rake (13.4.2) + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + securerandom (0.4.1) + sqlite3 (2.6.0) + mini_portile2 (~> 2.8.0) + sqlite3 (2.6.0-aarch64-linux-gnu) + sqlite3 (2.6.0-aarch64-linux-musl) + sqlite3 (2.6.0-arm-linux-gnu) + sqlite3 (2.6.0-arm-linux-musl) + sqlite3 (2.6.0-arm64-darwin) + sqlite3 (2.6.0-x86-linux-gnu) + sqlite3 (2.6.0-x86-linux-musl) + sqlite3 (2.6.0-x86_64-darwin) + sqlite3 (2.6.0-x86_64-linux-gnu) + sqlite3 (2.6.0-x86_64-linux-musl) + thor (1.5.0) + timeout (0.6.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + activerecord (~> 7.2.0) + activesupport (~> 7.2.0) + appraisal (~> 2.5) + bigdecimal + byebug + cucumber-ci-environment + rake (~> 13.2) + rspec (~> 3.0) + sanitization! + sqlite3 (~> 2.6.0) + +BUNDLED WITH + 2.6.3 diff --git a/gemfiles/rails_7.gemfile.lock b/gemfiles/rails_7.gemfile.lock deleted file mode 100644 index 6341616..0000000 --- a/gemfiles/rails_7.gemfile.lock +++ /dev/null @@ -1,84 +0,0 @@ -PATH - remote: .. - specs: - sanitization (1.1.4) - activerecord - activesupport - -GEM - remote: https://rubygems.org/ - specs: - activemodel (7.0.8.7) - activesupport (= 7.0.8.7) - activerecord (7.0.8.7) - activemodel (= 7.0.8.7) - activesupport (= 7.0.8.7) - activesupport (7.0.8.7) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - appraisal (2.5.0) - bundler - rake - thor (>= 0.14.0) - base64 (0.2.0) - bigdecimal (3.1.9) - byebug (12.0.0) - concurrent-ruby (1.3.4) - cucumber-ci-environment (10.0.1) - diff-lcs (1.6.1) - i18n (1.14.7) - concurrent-ruby (~> 1.0) - mini_portile2 (2.8.8) - minitest (5.25.5) - mutex_m (0.3.0) - rake (13.2.1) - rspec (3.13.0) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.3) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.2) - sqlite3 (1.7.3) - mini_portile2 (~> 2.8.0) - thor (1.3.2) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - -PLATFORMS - aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl - arm64-darwin - x86-linux-gnu - x86-linux-musl - x86_64-darwin - x86_64-linux-gnu - x86_64-linux-musl - -DEPENDENCIES - activerecord (~> 7.0.1) - activesupport (~> 7.0.1) - appraisal (~> 2.5) - base64 - bigdecimal - byebug - concurrent-ruby (< 1.3.5) - cucumber-ci-environment - mutex_m (~> 0.3.0) - rake (~> 13.2) - rspec (~> 3.0) - sanitization! - sqlite3 (~> 1.4) - -BUNDLED WITH - 2.6.3 diff --git a/gemfiles/rails_8.gemfile.lock b/gemfiles/rails_8.gemfile.lock index bec8e3a..0fb3ba7 100644 --- a/gemfiles/rails_8.gemfile.lock +++ b/gemfiles/rails_8.gemfile.lock @@ -1,9 +1,9 @@ PATH remote: .. specs: - sanitization (1.1.4) - activerecord - activesupport + sanitization (2.0.0) + activerecord (>= 7.1) + activesupport (>= 7.1) GEM remote: https://rubygems.org/ @@ -43,7 +43,11 @@ GEM i18n (1.14.7) concurrent-ruby (~> 1.0) logger (1.7.0) - minitest (5.25.5) + mini_portile2 (2.8.9) + minitest (6.0.5) + drb (~> 2.0) + prism (~> 1.5) + prism (1.9.0) rake (13.2.1) rspec (3.13.0) rspec-core (~> 3.13.0) @@ -59,6 +63,8 @@ GEM rspec-support (~> 3.13.0) rspec-support (3.13.2) securerandom (0.4.1) + sqlite3 (2.6.0) + mini_portile2 (~> 2.8.0) sqlite3 (2.6.0-aarch64-linux-gnu) sqlite3 (2.6.0-aarch64-linux-musl) sqlite3 (2.6.0-arm-linux-gnu) @@ -93,7 +99,6 @@ DEPENDENCIES appraisal (~> 2.5) bigdecimal byebug - concurrent-ruby (< 1.3.5) cucumber-ci-environment rake (~> 13.2) rspec (~> 3.0) diff --git a/lib/sanitization/active_record_extension.rb b/lib/sanitization/active_record_extension.rb index f6fb125..f4d3ca4 100644 --- a/lib/sanitization/active_record_extension.rb +++ b/lib/sanitization/active_record_extension.rb @@ -5,6 +5,52 @@ def self.append_features(base) base.extend(ClassMethods) end + # Taken from `strip_attributes`: https://github.com/rmm5t/strip_attributes/blob/master/lib/strip_attributes.rb + # Unicode invisible and whitespace characters. The POSIX character class + # [:space:] corresponds to the Unicode class Z ("separator"). We also + # include the following characters from Unicode class C ("control"), which + # are spaces or invisible characters that make no sense at the start or end + # of a string: + # U+180E MONGOLIAN VOWEL SEPARATOR + # U+200B ZERO WIDTH SPACE + # U+200C ZERO WIDTH NON-JOINER + # U+200D ZERO WIDTH JOINER + # U+2060 WORD JOINER + # U+FEFF ZERO WIDTH NO-BREAK SPACE + MULTIBYTE_WHITE = "\u180E\u200B\u200C\u200D\u2060\uFEFF".freeze + MULTIBYTE_BLANK = /[[:blank:]#{MULTIBYTE_WHITE}]/.freeze + MULTIBYTE_SUPPORTED = "\u0020" == " " + + def self.format_value(value, settings) + return value unless value.is_a?(String) + + v = value.dup + v.strip! if value_or_default(settings, :strip) + + if value_or_default(settings, :collapse) + if MULTIBYTE_SUPPORTED && Encoding.compatible?(v, MULTIBYTE_BLANK) + v.gsub!(/#{MULTIBYTE_BLANK}+/, " ") + else + v.squeeze!(" ") + end + end + + return nil if value_or_default(settings, :nullify) && v.empty? && settings[:null] + + case_method = value_or_default(settings, :case) + v = v.send(case_method) if case_method && case_method != :none + + v + end + + def self.value_or_default(settings, transform) + if settings[transform].nil? + Sanitization.configuration[transform] + else + settings[transform] + end + end + module ClassMethods attr_accessor :sanitization__store @@ -51,84 +97,21 @@ def sanitizes(options = {}) unless @valid_case_methods.include?(options[:case]) || options[:case] == :none end + klass = self columns_to_format.each do |col| - self.sanitization__store[col.name] ||= {} + fresh = !self.sanitization__store.key?(col.name) + self.sanitization__store[col.name] ||= { null: col.null } self.sanitization__store[col.name].merge!(options.slice(:case, :strip, :collapse, :nullify)) - end - - class_eval <<-EOV - include Sanitization::ActiveRecordExtension::InstanceMethods - before_save :sanitization__format_strings - EOV - end - alias sanitization sanitizes - end # module ClassMethods - - module InstanceMethods - # Taken from `strip_attributes`: https://github.com/rmm5t/strip_attributes/blob/master/lib/strip_attributes.rb - # Unicode invisible and whitespace characters. The POSIX character class - # [:space:] corresponds to the Unicode class Z ("separator"). We also - # include the following characters from Unicode class C ("control"), which - # are spaces or invisible characters that make no sense at the start or end - # of a string: - # U+180E MONGOLIAN VOWEL SEPARATOR - # U+200B ZERO WIDTH SPACE - # U+200C ZERO WIDTH NON-JOINER - # U+200D ZERO WIDTH JOINER - # U+2060 WORD JOINER - # U+FEFF ZERO WIDTH NO-BREAK SPACE - MULTIBYTE_WHITE = "\u180E\u200B\u200C\u200D\u2060\uFEFF".freeze - MULTIBYTE_SPACE = /[[:space:]#{MULTIBYTE_WHITE}]/.freeze - MULTIBYTE_BLANK = /[[:blank:]#{MULTIBYTE_WHITE}]/.freeze - MULTIBYTE_SUPPORTED = "\u0020" == " " - - private - - def sanitization__format_strings - return unless self.class.sanitization__store - class_formatting = self.class.sanitization__store - class_formatting.each_pair do |col_name, col_formatting| - sanitization__format_column(col_name, col_formatting) - end - end - - def sanitization__format_column(col_name, col_formatting) - return unless self[col_name].is_a?(String) - - self[col_name].strip! if value_or_default(col_formatting, :strip) - - if value_or_default(col_formatting, :collapse) - if MULTIBYTE_SUPPORTED && Encoding.compatible?(self[col_name], MULTIBYTE_BLANK) - self[col_name].gsub!(/#{MULTIBYTE_BLANK}+/, " ") - else - self[col_name].squeeze!(" ") + if fresh + col_name = col.name + normalizes col_name.to_sym, with: ->(value) { + Sanitization::ActiveRecordExtension.format_value(value, klass.sanitization__store[col_name]) + } end end - - if value_or_default(col_formatting, :nullify) && !self[col_name].nil? && self[col_name].to_s.empty? && \ - self.class.columns.select { |c| c.name == col_name }.first.null - return self[col_name] = nil - end - - case_formatting_method = value_or_default(col_formatting, :case) - if !case_formatting_method.nil? && case_formatting_method != :none - self[col_name] = self[col_name].send(case_formatting_method) - end - - self[col_name] end - - def value_or_default(col_formatting, transform) - if col_formatting[transform].nil? - Sanitization.configuration[transform] - else - col_formatting[transform] - end - end - - - end # module InstanceMethods + alias sanitization sanitizes + end # module ClassMethods end # module ActiveRecordExt end # module Sanitization - diff --git a/lib/sanitization/version.rb b/lib/sanitization/version.rb index 6296627..1775452 100644 --- a/lib/sanitization/version.rb +++ b/lib/sanitization/version.rb @@ -1,3 +1,3 @@ module Sanitization - VERSION = "1.2.0" + VERSION = "2.0.0" end diff --git a/sanitization.gemspec b/sanitization.gemspec index a6bb44e..a3ea99a 100644 --- a/sanitization.gemspec +++ b/sanitization.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |spec| spec.description = %q{Sanitization makes it easy to store slightly cleaner strings to your database.} spec.homepage = "https://github.com/cmer/sanitization" spec.license = "MIT" - spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0") + spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0") spec.metadata["allowed_push_host"] = "https://rubygems.org" @@ -26,9 +26,8 @@ Gem::Specification.new do |spec| spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_runtime_dependency "activerecord" - spec.add_runtime_dependency "activesupport" + spec.add_runtime_dependency "activerecord", ">= 7.1" + spec.add_runtime_dependency "activesupport", ">= 7.1" spec.add_development_dependency "appraisal", "~> 2.5" - spec.add_development_dependency "concurrent-ruby", "< 1.3.5" spec.add_development_dependency "sqlite3", "~> 2.6.0" end diff --git a/spec/sanitization_spec.rb b/spec/sanitization_spec.rb index 45c7484..5dbe61e 100644 --- a/spec/sanitization_spec.rb +++ b/spec/sanitization_spec.rb @@ -94,4 +94,92 @@ class Person < ActiveRecord::Base end end end + + context "Assignment-time semantics (normalizes integration)" do + before(:all) { Sanitization.simple_defaults! } + + let(:base_attrs) { + { first_name: "John", last_name: "Smith", city: "NYC", + email: "user@example.org", do_not_collapse: "x" } + } + + it "normalizes on assignment, before save" do + person = Person.new + person.first_name = " john " + expect(person.first_name).to eq("JOHN") + expect(person.persisted?).to be false + end + + it "normalizes attributes passed to .new" do + person = Person.new(first_name: " jane ", email: " USER@X.ORG ") + expect(person.first_name).to eq("JANE") + expect(person.email).to eq("user@x.org") + end + + it "leaves nil values as nil (apply_to_nil: false default)" do + person = Person.new(first_name: nil) + expect(person.first_name).to be_nil + end + + it "is idempotent — normalizing a normalized value is a no-op" do + once = Person.normalize_value_for(:email, " USER@X.ORG ") + twice = Person.normalize_value_for(:email, once) + expect(once).to eq("user@x.org") + expect(twice).to eq(once) + end + + it "normalizes hash conditions in .where" do + Person.create!(base_attrs.merge(email: "raw@example.org")) + expect(Person.where(email: " RAW@EXAMPLE.ORG ").count).to be >= 1 + end + + it "normalizes the lookup value in .find_by" do + Person.create!(base_attrs.merge(email: "lookup@example.org")) + expect(Person.find_by(email: " LOOKUP@EXAMPLE.ORG ")).not_to be_nil + end + + it "does NOT auto-normalize legacy rows on plain save" do + person = Person.create!(base_attrs.merge(email: "legacy@example.org")) + ActiveRecord::Base.connection.execute( + "UPDATE people SET email = ' LEGACY-RAW@X.ORG ' WHERE id = #{person.id}" + ) + person.reload + expect(person.email).to eq(" LEGACY-RAW@X.ORG ") # reload does not normalize + person.last_name = "OTHER" + person.save! + person.reload + expect(person.email).to eq(" LEGACY-RAW@X.ORG ") # unrelated save did not heal it + end + + it "migrates legacy rows on demand via normalize_attribute" do + person = Person.create!(base_attrs.merge(email: "migrate@example.org")) + ActiveRecord::Base.connection.execute( + "UPDATE people SET email = ' MIGRATE-RAW@X.ORG ' WHERE id = #{person.id}" + ) + person.reload + person.normalize_attribute(:email) + person.save! + person.reload + expect(person.email).to eq("migrate-raw@x.org") + end + + it "re-normalizes values mutated in place before validation" do + person = Person.new(base_attrs) + person.email << " " # mutate in place + person.valid? # before_validation hook re-normalizes + expect(person.email).to eq("user@example.org") + end + + it "runs validations against normalized values" do + validating = Class.new(Person) do + def self.name; "ValidatingPerson"; end + validates_each :email do |record, attr, value| + record.errors.add(attr, "must be lowercase") if value && value != value.downcase + end + end + person = validating.new(base_attrs.merge(email: " RAW@X.ORG ")) + expect(person.email).to eq("raw@x.org") + expect(person.valid?).to be true + end + end end