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.
This commit is contained in:
Donal McBreen
2025-11-26 09:50:29 +00:00
parent 97bcdf1853
commit 9c86205510
4 changed files with 37 additions and 79 deletions
-2
View File
@@ -1,8 +1,6 @@
class ApplicationRecord < ActiveRecord::Base class ApplicationRecord < ActiveRecord::Base
primary_abstract_class primary_abstract_class
include ColumnLimits
configure_replica_connections configure_replica_connections
attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate } attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
-46
View File
@@ -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
@@ -8,8 +8,6 @@
# - size: :medium: 16,777,215 (MEDIUMTEXT) # - size: :medium: 16,777,215 (MEDIUMTEXT)
# - size: :long: 4,294,967,295 (LONGTEXT) # - 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 module TableDefinitionColumnLimits
TEXT_SIZE_TO_LIMIT = { TEXT_SIZE_TO_LIMIT = {
@@ -45,14 +43,28 @@ ActiveSupport.on_load(:active_record) do
ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionColumnLimits) ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionColumnLimits)
end end
ActiveSupport.on_load(:action_text_rich_text) do # For SQLite: append inline CHECK constraints to enforce string/text length limits.
include ColumnLimits # 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 end
ActiveSupport.on_load(:active_storage_blob) do ActiveSupport.on_load(:active_record_sqlite3adapter) do
include ColumnLimits ActiveRecord::ConnectionAdapters::SQLite3::SchemaCreation.prepend(SQLiteColumnLimitCheckConstraints)
end
ActiveSupport.on_load(:active_storage_attachment) do
include ColumnLimits
end end
+15 -21
View File
@@ -1,62 +1,56 @@
require "test_helper" require "test_helper"
class ColumnLimitsTest < ActiveSupport::TestCase 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 test "account name rejects strings over 255 characters" do
account = Account.new(name: "a" * 256) account = Account.new(name: "a" * 256)
assert_not account.valid? assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! }
assert_includes account.errors[:name], "is too long (maximum is 255 characters)"
end end
test "account name accepts strings up to 255 characters" do test "account name accepts strings up to 255 characters" do
account = Account.new(name: "a" * 255) account = Account.new(name: "a" * 255)
assert account.valid? assert account.save
assert_not_includes account.errors[:name], "is too long (maximum is 255 characters)"
end end
test "account name accepts 255 emoji characters" do test "account name accepts 255 emoji characters" do
account = Account.new(name: "🎉" * 255) account = Account.new(name: "🎉" * 255)
assert account.valid? assert account.save
assert_not_includes account.errors[:name], "is too long (maximum is 255 characters)"
end end
test "account name rejects 256 emoji characters" do test "account name rejects 256 emoji characters" do
account = Account.new(name: "🎉" * 256) account = Account.new(name: "🎉" * 256)
assert_not account.valid? assert_raises(*COLUMN_LIMIT_ERRORS) { account.save! }
assert_includes account.errors[:name], "is too long (maximum is 255 characters)"
end end
# Test text column limits (65535 bytes for TEXT) # Test text column limits (65535 bytes for TEXT)
test "step content rejects text over 65535 bytes" do test "step content rejects text over 65535 bytes" do
step = Step.new(content: "a" * 65536, card: cards(:logo)) step = Step.new(content: "a" * 65536, card: cards(:logo))
assert_not step.valid? assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! }
assert_includes step.errors[:content], "is too long (maximum is 65535 bytes)"
end end
test "step content accepts text up to 65535 bytes" do test "step content accepts text up to 65535 bytes" do
step = Step.new(content: "a" * 65535, card: cards(:logo)) step = Step.new(content: "a" * 65535, card: cards(:logo))
assert step.valid? assert step.save
assert_not_includes step.errors[:content], "is too long (maximum is 65535 bytes)"
end end
test "step content counts bytes not characters for text columns" do test "step content counts bytes not characters for text columns" do
# 20000 emoji = 20000 chars but 80000 bytes (over 65535 limit) # 20000 emoji = 20000 chars but 80000 bytes (over 65535 limit)
step = Step.new(content: "🎉" * 20000, card: cards(:logo)) step = Step.new(content: "🎉" * 20000, card: cards(:logo))
assert_not step.valid? assert_raises(*COLUMN_LIMIT_ERRORS) { step.save! }
assert_includes step.errors[:content], "is too long (maximum is 65535 bytes)"
end end
# Test ActionText::RichText (inherits from ActionText::Record, not ApplicationRecord)
test "ActionText::RichText name rejects strings over 255 characters" do test "ActionText::RichText name rejects strings over 255 characters" do
rich_text = ActionText::RichText.new(name: "a" * 256, record: cards(:logo)) rich_text = ActionText::RichText.new(name: "a" * 256, record: cards(:logo))
assert_not rich_text.valid? assert_raises(*COLUMN_LIMIT_ERRORS) { rich_text.save! }
assert_includes rich_text.errors[:name], "is too long (maximum is 255 characters)"
end end
# Test ActiveStorage::Blob (inherits from ActiveStorage::Record, not ApplicationRecord)
test "ActiveStorage::Blob filename rejects strings over 255 characters" do test "ActiveStorage::Blob filename rejects strings over 255 characters" do
Current.account = accounts(:"37s") Current.account = accounts(:"37s")
blob = ActiveStorage::Blob.new(filename: "a" * 256) blob = ActiveStorage::Blob.new(filename: "a" * 256, key: "test-key", byte_size: 0, checksum: "test", service_name: "local")
assert_not blob.valid? assert_raises(*COLUMN_LIMIT_ERRORS) { blob.save! }
assert_includes blob.errors[:filename], "is too long (maximum is 255 characters)"
end end
end end