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.
This commit is contained in:
Donal McBreen
2025-11-25 15:29:42 +00:00
parent 67f648f356
commit 97bcdf1853
5 changed files with 134 additions and 15 deletions
+2
View File
@@ -1,6 +1,8 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
include ColumnLimits
configure_replica_connections
attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
+46
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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
+62
View File
@@ -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