From 97bcdf1853ac57a72ec4febd7fe4f775b327afff Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 25 Nov 2025 15:29:42 +0000 Subject: [PATCH] Enforce column limits via concern SQLite columns lengths are purely informational, so we'll enforce the limits via a concern that checks the lengths from the schema. --- app/models/application_record.rb | 2 + app/models/concerns/column_limits.rb | 46 ++++++++++++++ .../table_definition_column_limits.rb | 37 ++++++----- db/schema_sqlite.rb | 2 +- test/models/column_limits_test.rb | 62 +++++++++++++++++++ 5 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 app/models/concerns/column_limits.rb create mode 100644 test/models/column_limits_test.rb diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 45e9c2f21..84fdb190c 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,6 +1,8 @@ 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 new file mode 100644 index 000000000..de179c34b --- /dev/null +++ b/app/models/concerns/column_limits.rb @@ -0,0 +1,46 @@ +# 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 f9a75d174..373f19138 100644 --- a/config/initializers/table_definition_column_limits.rb +++ b/config/initializers/table_definition_column_limits.rb @@ -1,4 +1,4 @@ -# Apply MySQL-compatible column limits when using SQLite. +# Apply MySQL-compatible column limits when defining tables. # # For string columns: defaults to 255 (MySQL's VARCHAR default) # @@ -7,17 +7,19 @@ # - size: :tiny: 255 (TINYTEXT) # - 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 - # Map MySQL size options to limits TEXT_SIZE_TO_LIMIT = { - tiny: 255, # TINYTEXT - medium: 16_777_215, # MEDIUMTEXT - long: 4_294_967_295 # LONGTEXT + tiny: 255, + medium: 16_777_215, + long: 4_294_967_295 }.freeze - TEXT_DEFAULT_LIMIT = 65_535 # TEXT - STRING_DEFAULT_LIMIT = 255 # VARCHAR + TEXT_DEFAULT_LIMIT = 65_535 + STRING_DEFAULT_LIMIT = 255 def column(name, type, **options) if type == :string @@ -27,16 +29,11 @@ module TableDefinitionColumnLimits if type == :text if options.key?(:size) size = options.delete(:size) - options[:limit] = TEXT_SIZE_TO_LIMIT.fetch(size) do + options[:limit] ||= TEXT_SIZE_TO_LIMIT.fetch(size) do raise ArgumentError, "Unknown text size: #{size.inspect}. Use :tiny, :medium, or :long" end - elsif options.key?(:limit) - valid_limits = [TEXT_DEFAULT_LIMIT] + TEXT_SIZE_TO_LIMIT.values - unless valid_limits.include?(options[:limit]) - raise ArgumentError, "Invalid limit #{options[:limit]} for text column. Use `size:` (:tiny, :medium, :long) or omit for default TEXT." - end else - options[:limit] = TEXT_DEFAULT_LIMIT + options[:limit] ||= TEXT_DEFAULT_LIMIT end end @@ -47,3 +44,15 @@ end ActiveSupport.on_load(:active_record) do ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionColumnLimits) end + +ActiveSupport.on_load(:action_text_rich_text) do + include ColumnLimits +end + +ActiveSupport.on_load(:active_storage_blob) do + include ColumnLimits +end + +ActiveSupport.on_load(:active_storage_attachment) do + include ColumnLimits +end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index bc40a0f42..d3533e53d 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -46,7 +46,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_21_112416) do create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false - t.text "body", limit: 65535 + t.text "body", limit: 4294967295 t.datetime "created_at", null: false t.string "name", limit: 255, null: false t.uuid "record_id", null: false diff --git a/test/models/column_limits_test.rb b/test/models/column_limits_test.rb new file mode 100644 index 000000000..68144e99c --- /dev/null +++ b/test/models/column_limits_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class ColumnLimitsTest < ActiveSupport::TestCase + 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)" + 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)" + 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)" + 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)" + 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)" + 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)" + 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)" + 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)" + 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)" + end +end