diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 84fdb190c..45e9c2f21 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,8 +1,6 @@ class ApplicationRecord < ActiveRecord::Base primary_abstract_class - include ColumnLimits - configure_replica_connections attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate } diff --git a/app/models/concerns/column_limits.rb b/app/models/concerns/column_limits.rb deleted file mode 100644 index de179c34b..000000000 --- a/app/models/concerns/column_limits.rb +++ /dev/null @@ -1,46 +0,0 @@ -# Validates string and text column lengths to match MySQL limits. -# SQLite doesn't enforce column limits, so we need validations to ensure -# data portability between databases. -# -# Usage: -# class Card < ApplicationRecord -# include ColumnLimits -# end -# -# This will automatically add length validations for all string and text -# columns based on their defined limits in the database schema. -# -# MySQL VARCHAR limits are in characters, not bytes. -# MySQL TEXT limits are in bytes. - -module ColumnLimits - extend ActiveSupport::Concern - - included do - validate :validate_column_limits - end - - private - def validate_column_limits - self.class.columns.each do |column| - next unless column.limit - next unless %i[string text].include?(column.type) - - value = read_attribute(column.name) - next if value.nil? - next unless value.is_a?(String) - - if column.type == :string - # VARCHAR limits are in characters - if value.length > column.limit - errors.add(column.name, "is too long (maximum is #{column.limit} characters)") - end - else - # TEXT limits are in bytes - if value.bytesize > column.limit - errors.add(column.name, "is too long (maximum is #{column.limit} bytes)") - end - end - end - end -end diff --git a/config/initializers/table_definition_column_limits.rb b/config/initializers/table_definition_column_limits.rb index 373f19138..77dd1ded2 100644 --- a/config/initializers/table_definition_column_limits.rb +++ b/config/initializers/table_definition_column_limits.rb @@ -8,8 +8,6 @@ # - size: :medium: 16,777,215 (MEDIUMTEXT) # - size: :long: 4,294,967,295 (LONGTEXT) # -# This ensures the SQLite schema records the same limits as MySQL, -# which the ColumnLimits validation concern uses to enforce limits. module TableDefinitionColumnLimits TEXT_SIZE_TO_LIMIT = { @@ -45,14 +43,28 @@ ActiveSupport.on_load(:active_record) do ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionColumnLimits) end -ActiveSupport.on_load(:action_text_rich_text) do - include ColumnLimits +# For SQLite: append inline CHECK constraints to enforce string/text length limits. +# since SQLite doesn't natively enforce VARCHAR/TEXT length limits. +module SQLiteColumnLimitCheckConstraints + def add_column_options!(sql, options) + super + + column = options[:column] + if column && column.limit && %i[string text].include?(column.type) + check_expr = if column.type == :string + # VARCHAR limits are in characters + %(length("#{column.name}") <= #{column.limit}) + else + # TEXT limits are in bytes + %(length(CAST("#{column.name}" AS BLOB)) <= #{column.limit}) + end + sql << " CHECK(#{check_expr})" + end + + sql + end end -ActiveSupport.on_load(:active_storage_blob) do - include ColumnLimits -end - -ActiveSupport.on_load(:active_storage_attachment) do - include ColumnLimits +ActiveSupport.on_load(:active_record_sqlite3adapter) do + ActiveRecord::ConnectionAdapters::SQLite3::SchemaCreation.prepend(SQLiteColumnLimitCheckConstraints) end diff --git a/test/models/column_limits_test.rb b/test/models/column_limits_test.rb index 68144e99c..ba5f67b92 100644 --- a/test/models/column_limits_test.rb +++ b/test/models/column_limits_test.rb @@ -1,62 +1,56 @@ require "test_helper" class ColumnLimitsTest < ActiveSupport::TestCase + # Database errors for exceeding column limits: + # - MySQL: ActiveRecord::ValueTooLong + # - SQLite: ActiveRecord::CheckViolation + COLUMN_LIMIT_ERRORS = [ ActiveRecord::ValueTooLong, ActiveRecord::CheckViolation ] + test "account name rejects strings over 255 characters" do account = Account.new(name: "a" * 256) - assert_not account.valid? - assert_includes account.errors[:name], "is too long (maximum is 255 characters)" + assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! } end test "account name accepts strings up to 255 characters" do account = Account.new(name: "a" * 255) - assert account.valid? - assert_not_includes account.errors[:name], "is too long (maximum is 255 characters)" + assert account.save end test "account name accepts 255 emoji characters" do account = Account.new(name: "🎉" * 255) - assert account.valid? - assert_not_includes account.errors[:name], "is too long (maximum is 255 characters)" + assert account.save end test "account name rejects 256 emoji characters" do account = Account.new(name: "🎉" * 256) - assert_not account.valid? - assert_includes account.errors[:name], "is too long (maximum is 255 characters)" + assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! } end # Test text column limits (65535 bytes for TEXT) test "step content rejects text over 65535 bytes" do step = Step.new(content: "a" * 65536, card: cards(:logo)) - assert_not step.valid? - assert_includes step.errors[:content], "is too long (maximum is 65535 bytes)" + assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! } end test "step content accepts text up to 65535 bytes" do step = Step.new(content: "a" * 65535, card: cards(:logo)) - assert step.valid? - assert_not_includes step.errors[:content], "is too long (maximum is 65535 bytes)" + assert step.save end test "step content counts bytes not characters for text columns" do # 20000 emoji = 20000 chars but 80000 bytes (over 65535 limit) step = Step.new(content: "🎉" * 20000, card: cards(:logo)) - assert_not step.valid? - assert_includes step.errors[:content], "is too long (maximum is 65535 bytes)" + assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! } end - # Test ActionText::RichText (inherits from ActionText::Record, not ApplicationRecord) test "ActionText::RichText name rejects strings over 255 characters" do rich_text = ActionText::RichText.new(name: "a" * 256, record: cards(:logo)) - assert_not rich_text.valid? - assert_includes rich_text.errors[:name], "is too long (maximum is 255 characters)" + assert_raises(*COLUMN_LIMIT_ERRORS) { rich_text.save! } end - # Test ActiveStorage::Blob (inherits from ActiveStorage::Record, not ApplicationRecord) test "ActiveStorage::Blob filename rejects strings over 255 characters" do Current.account = accounts(:"37s") - blob = ActiveStorage::Blob.new(filename: "a" * 256) - assert_not blob.valid? - assert_includes blob.errors[:filename], "is too long (maximum is 255 characters)" + blob = ActiveStorage::Blob.new(filename: "a" * 256, key: "test-key", byte_size: 0, checksum: "test", service_name: "local") + assert_raises(*COLUMN_LIMIT_ERRORS) { blob.save! } end end