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:
@@ -1,6 +1,8 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
|
||||
include ColumnLimits
|
||||
|
||||
configure_replica_connections
|
||||
|
||||
attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user