diff --git a/.tool-versions b/.tool-versions index a4023dc..13369f6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 2.7.5 +ruby 3.1.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae96dc..a777694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,40 +4,60 @@ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Note: This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2025-07-09 + +### Added + +- Added large_text_field_class_name_override and large_text_field_deprecated_class_name_override class methods allowing a model to use a different table for large_text_field support +- Previously the association to the large text file model was created when the module was included. The creation of this association is now delayed until the first field is defined. This allows for a cleaner syntax for setting options. + ## [1.1.0] - 2023-05-17 + ### Fixed + - Relaxed dependency on rails to allow rails 7. ### Added + - Added support for rails 7 - Added appraisal tests for rails 7 ### Removed + - Rails 4 and Ruby 2.6 are no longer supported. Automated testing of those versions has been removed. ## [1.0.2] - 2021-10-22 + ### Fixed + - Bug where reload didn't accept an argument even though ActiveRecord::Persistence#reload can. ## [1.0.1] - 2021-02-16 + ### Fixed + - Fixed migration to work with Rails 6 (by adding [4.2] suffix). - Set rails version to < 6.1 since that moved some files around and broke requires. ## [1.0.0] - 2020-05-15 + ### Added + - Added support for rails 5 and 6. - Added appraisal tests for all supported rails version: 4/5/6 ### Removed + - Support for `protected_parameters` has been removed ## [0.3.2] - 2020-05-03 + ### Changed + - Replaced dependence on hobo_support with invoca_utils - Make invoca_utils a declared dependency. (It always was, it just wasn't declared) - +[1.3.0]: https://github.com/Invoca/large_text_field/compare/v1.1.0...v1.3.0 [1.1.0]: https://github.com/Invoca/large_text_field/compare/v1.0.2...v1.1.0 [1.0.2]: https://github.com/Invoca/large_text_field/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/Invoca/large_text_field/compare/v1.0.0...v1.0.1 diff --git a/Gemfile.lock b/Gemfile.lock index b29554e..10ea20a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - large_text_field (1.2.0) + large_text_field (1.3.0) invoca-utils (~> 0.3) rails (>= 6.0) diff --git a/README.md b/README.md index cc6c53d..2d6cc90 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ characters. Defining new fields on models does not require database migrations. central table that is polymorphically associated with the model, but they act like a column on the same model. ## How do I use it? + In you Gemfile add: ``` @@ -13,8 +14,8 @@ In you Gemfile add: There will be database migration you need to run to define the table, so go ahead and run that... -Any class that wants to define a large text field should include **::LargeTextField::Owner**, -and then define text fields by calling the **large_text_field** macro. +Any class that wants to define a large text field should include **::LargeTextField::Owner**, +and then define text fields by calling the **large_text_field** macro. For example the following is a `Library` class that has large text fields for `notes` and `description`. @@ -27,25 +28,35 @@ class Library < ApplicationRecord end ``` -That's it! You can then access `notes` and `description` as if they were columns on your class. +That's it! You can then access `notes` and `description` as if they were columns on your class. The `large_text_field` macro takes the following options... - * **maximum: number** - The maximum length of a large text field. By default this is 5,000,000 characters, -but it can be set to less using this option. - * **singularize_errors: boolean** - should validation messages be singularized. +- **maximum: number** - The maximum length of a large text field. By default this is 5,000,000 characters, + but it can be set to less using this option. +- **singularize_errors: boolean** - should validation messages be singularized. -Large text fields defaults to an empty string. You cannot store `nil` in a large text field. +Large text fields defaults to an empty string. You cannot store `nil` in a large text field. -**Please note:** Large text field uses the *before_save* callback on the class that is the owner for book-keeping. -Callbacks are great, but if there are multiple handlers for the same callback the order in which they are called is -not predictable. If you want to make changes to large_text_field values in the before_save callback, use the -**large_text_field_save** callback instead. This will be called before the large text field book-keeping so your -changes will be saved. For example, this will call the `save_preprocess` method on your class before the large text +**Please note:** Large text field uses the _before_save_ callback on the class that is the owner for book-keeping. +Callbacks are great, but if there are multiple handlers for the same callback the order in which they are called is +not predictable. If you want to make changes to large_text_field values in the before_save callback, use the +**large_text_field_save** callback instead. This will be called before the large text field book-keeping so your +changes will be saved. For example, this will call the `save_preprocess` method on your class before the large text fields are saved... ```ruby set_callback(:large_text_field_save, :before, :save_preprocess) ``` -This project rocks and uses MIT-LICENSE. You should too. +Added class methods: + +```ruby + large_text_field_deprecated_class_name_override "LargeTextField::NamedTextValue" + large_text_field_class_name_override "MyCustomLargeTextField" +``` + +You will not generally need this support; however, it can be helpful when trying to separate a model into a +different database. + +This project rocks and uses MIT-LICENSE. You should too. diff --git a/lib/large_text_field/named_text_value.rb b/lib/large_text_field/named_text_value.rb index 1ac144c..67a502e 100644 --- a/lib/large_text_field/named_text_value.rb +++ b/lib/large_text_field/named_text_value.rb @@ -5,7 +5,8 @@ module LargeTextField class NamedTextValue < ApplicationRecord # Schema - # field_name :string, :limit => 255 + # field_name :string, :limit => 255 + # foo :string # value :text, :null=>true, :limit => MYSQL_MEDIUM_TEXT_UTF8_LIMIT # # index [ :owner_type, :owner_id, :field_name ], :name => 'large_text_field_by_owner_field', :unique=>true diff --git a/lib/large_text_field/owner.rb b/lib/large_text_field/owner.rb index cb7489b..5aa92af 100644 --- a/lib/large_text_field/owner.rb +++ b/lib/large_text_field/owner.rb @@ -8,20 +8,25 @@ module Owner include ActiveSupport::Callbacks included do - has_many( - :large_text_fields, - class_name: "LargeTextField::NamedTextValue", - as: :owner, - autosave: true, - dependent: :destroy, - inverse_of: :owner - ) validate :validate_large_text_fields before_save :write_large_text_field_changes define_callbacks :large_text_field_save class_attribute :large_text_field_options self.large_text_field_options = {} + + class_attribute :initialized + self.initialized = false + + class_attribute :large_text_field_class_name + self.large_text_field_class_name = "LargeTextField::NamedTextValue" + class_attribute :large_text_field_class + self.large_text_field_class = LargeTextField::NamedTextValue + + class_attribute :large_text_field_deprecated_class_name + self.large_text_field_deprecated_class_name = nil + class_attribute :large_text_field_deprecated_class + self.large_text_field_deprecated_class = nil end def dup @@ -42,7 +47,13 @@ def reload(options = nil) end def text_field_hash - @text_field_hash ||= large_text_fields.build_hash { |text_field| [text_field.field_name, text_field] } + unless @text_field_hash + @text_field_hash = large_text_fields.build_hash { |text_field| [text_field.field_name, text_field] } + if large_text_field_deprecated_class_name + deprecated_large_text_fields.each { |text_field| @text_field_hash[text_field.field_name] ||= text_field } + end + end + @text_field_hash end def text_field_hash_loaded? @@ -61,7 +72,7 @@ def set_text_field(original_field_name, original_value) if (field = text_field_hash[field_name]) field.value = value else - text_field_hash[field_name] = LargeTextField::NamedTextValue.new(owner: self, field_name: field_name, value: value) + text_field_hash[field_name] = large_text_field_class.new(owner: self, field_name:, value:) end end @@ -91,7 +102,10 @@ def validate_large_text_fields def write_large_text_field_changes run_callbacks(:large_text_field_save) - @text_field_hash = text_field_hash.compact.select { |_key, value| value.value.presence } + @text_field_hash = text_field_hash + .compact + .select { |_key, value| value.value.presence } + .transform_values { |value| value.is_a?(large_text_field_class) ? value : large_text_field_class.new(owner: self, field_name: value.field_name, value: value.value) } self.large_text_fields = text_field_hash.values.compact true end @@ -103,7 +117,43 @@ def _clear_text_field_on_dup end module ClassMethods + def large_text_field_class_name_override(value) + self.large_text_field_class_name = value + self.large_text_field_class = Object.const_get(value) + end + + def large_text_field_deprecated_class_name_override(value) + self.large_text_field_deprecated_class_name = value + self.large_text_field_deprecated_class = Object.const_get(value) + end + + def initialize_large_text_field + return if initialized # skip if already initialized + + has_many( + :large_text_fields, + class_name: large_text_field_class_name, + as: :owner, + autosave: true, + dependent: :destroy, + inverse_of: :owner + ) + if large_text_field_deprecated_class_name + has_many( + :deprecated_large_text_fields, + class_name: large_text_field_deprecated_class_name, + as: :owner, + autosave: true, + dependent: :destroy, + inverse_of: :owner + ) + end + self.initialized = true + end + def large_text_field(field_name, maximum: nil, singularize_errors: false) + initialize_large_text_field # ensure the association is initialized + field_name = field_name.to_s # validate custom maximum @@ -117,7 +167,7 @@ def large_text_field(field_name, maximum: nil, singularize_errors: false) end end - large_text_field_options[field_name] = { maximum: maximum, singularize_errors: singularize_errors } + large_text_field_options[field_name] = { maximum:, singularize_errors: } define_method(field_name) { get_text_field(field_name) } define_method("#{field_name}=") { |value| set_text_field(field_name, value) } define_method("#{field_name}_changed?") { text_field_changed(field_name) } diff --git a/lib/large_text_field/version.rb b/lib/large_text_field/version.rb index b2af9cf..171cf8e 100644 --- a/lib/large_text_field/version.rb +++ b/lib/large_text_field/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module LargeTextField - VERSION = "1.2.0" + VERSION = "1.3.0" end diff --git a/test/dummy/app/models/dummy_large_text_value.rb b/test/dummy/app/models/dummy_large_text_value.rb new file mode 100644 index 0000000..3720e8e --- /dev/null +++ b/test/dummy/app/models/dummy_large_text_value.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class DummyLargeTextValue < ApplicationRecord + belongs_to :owner, polymorphic: true, inverse_of: :large_text_fields + self.table_name = "dummy_large_text_fields" +end diff --git a/test/dummy/app/models/person.rb b/test/dummy/app/models/person.rb new file mode 100644 index 0000000..81bde7a --- /dev/null +++ b/test/dummy/app/models/person.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Person < ApplicationRecord + include ::LargeTextField::Owner + + # Schema + # name :string, :limit => 255 + + large_text_field_class_name_override "DummyLargeTextValue" + large_text_field_deprecated_class_name_override "LargeTextField::NamedTextValue" + + large_text_field :story + large_text_field :resume +end diff --git a/test/dummy/config/storage.yml b/test/dummy/config/storage.yml new file mode 100644 index 0000000..d32f76e --- /dev/null +++ b/test/dummy/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket + +# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/test/dummy/db/migrate/20250602151745_create_dummy_large_text_fields.rb b/test/dummy/db/migrate/20250602151745_create_dummy_large_text_fields.rb new file mode 100644 index 0000000..5707166 --- /dev/null +++ b/test/dummy/db/migrate/20250602151745_create_dummy_large_text_fields.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateDummyLargeTextFields < (Rails::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration) + def self.up + # rubocop:disable Rails/CreateTableWithTimestamps + create_table :dummy_large_text_fields do |t| + t.string :field_name, null: false + t.text :value, char_limit: 5_592_405, limit: 16_777_215 + t.integer :owner_id, null: false + t.string :owner_type, null: false + end + # rubocop:enable Rails/CreateTableWithTimestamps + add_index :dummy_large_text_fields, %i[owner_type owner_id field_name], unique: true, name: 'dummy_large_text_field_by_owner_field' + end + + def self.down + drop_table :dummy_large_text_fields + end +end diff --git a/test/dummy/db/migrate/20250604150254_create_people.rb b/test/dummy/db/migrate/20250604150254_create_people.rb new file mode 100644 index 0000000..bbec02c --- /dev/null +++ b/test/dummy/db/migrate/20250604150254_create_people.rb @@ -0,0 +1,9 @@ +class CreatePeople < ActiveRecord::Migration[6.1] + def change + create_table :people do |t| + t.string :name + + t.timestamps + end + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index b6b62a1..073f2ab 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,15 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2016_02_17_033529) do +ActiveRecord::Schema.define(version: 2025_06_04_150254) do + + create_table "dummy_large_text_fields", id: :integer, charset: "utf8mb3", force: :cascade do |t| + t.string "field_name", null: false + t.text "value", size: :medium + t.integer "owner_id", null: false + t.string "owner_type", null: false + t.index ["owner_type", "owner_id", "field_name"], name: "dummy_large_text_field_by_owner_field", unique: true + end create_table "large_text_fields", id: :integer, charset: "utf8mb3", force: :cascade do |t| t.string "field_name", null: false @@ -24,4 +32,10 @@ t.string "name", null: false end + create_table "people", charset: "utf8mb3", force: :cascade do |t| + t.string "name" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + end diff --git a/test/dummy/db/storage.yml b/test/dummy/db/storage.yml new file mode 100644 index 0000000..695f17b --- /dev/null +++ b/test/dummy/db/storage.yml @@ -0,0 +1,7 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> diff --git a/test/unit/dummy/person_test.rb b/test/unit/dummy/person_test.rb new file mode 100644 index 0000000..879aabb --- /dev/null +++ b/test/unit/dummy/person_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'test_helper' + +module LargeTextField + class PersonTest < ActiveSupport::TestCase + should "be able to construct a Person" do + p = Person.new(name: "Obama") + p.save! + + resume_text = "Obama's resume is very long and detailed, covering his early life, education, political career, and achievements. It includes numerous accomplishments and accolades that highlight his leadership skills and public service." + p.resume = resume_text + p.save! + + assert_equal resume_text, p.resume + + p = Person.find(p.id) + + assert_equal resume_text, p.resume + end + + should "find fields from deprecated large text field table" do + p = Person.new(name: "Obama") + p.story = "Story about Obama" + p.save! + NamedTextValue.create!(field_name: "resume", value: "Old value", owner: p) + p = Person.find(p.id) + + assert_equal "Old value", p.resume + assert_equal "Story about Obama", p.story + + # Ensure that the story was saved in the new table + assert_not_predicate DummyLargeTextValue.where(owner: p, field_name: "resume"), :exists? + assert_predicate DummyLargeTextValue.where(owner: p, field_name: "story"), :exists? + + p.resume = "New resume value" + p.save! + + # Ensure that the updated resume was saved in the new table + assert_predicate DummyLargeTextValue.where(owner: p, field_name: "resume"), :exists? + end + + should "find fields from deprecated large text field table, updates are written to default table" do + p = Person.new(name: "Obama") + NamedTextValue.create!(field_name: "resume", value: "Old value", owner: p) + p = Person.find(p.id) + + new_value = "Retired" + p.resume = new_value + p.save! + + # Ensure that the resume was saved in the new table + assert_predicate DummyLargeTextValue.where(owner: p, field_name: "resume", value: new_value), :exists? + + # Both values are currently maintained, but the new table is the source of truth + assert_predicate NamedTextValue.where(owner: p, field_name: "resume", value: new_value), :exists? + end + end +end