From 9c862055108fe72f4d3ce47d25a8a14074e2e52a Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 26 Nov 2025 09:50:29 +0000 Subject: [PATCH] Enforce SQLite column limits via CHECK constraints Patch the sqlite adapter to add CHECK constraints for string and text column limits. We'll do them inline, so that any column changes automatically update the constraints. --- app/models/application_record.rb | 2 - app/models/concerns/column_limits.rb | 46 ------------------- .../table_definition_column_limits.rb | 32 +++++++++---- test/models/column_limits_test.rb | 36 ++++++--------- 4 files changed, 37 insertions(+), 79 deletions(-) delete mode 100644 app/models/concerns/column_limits.rb 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