7.3 KiB
JSONB Field Fixes - Complete Solution
Issue
undefined method 'stringify_keys' for an instance of String
This error occurred when rendering views that use Rails helpers like button_to, link_to, or form_with that serialize model objects containing JSONB fields.
Root Cause
PostgreSQL JSONB columns can sometimes return inconsistent data types:
- Sometimes returns a Hash (correct)
- Sometimes returns a String (incorrect - causes stringify_keys error)
- Sometimes returns nil (causes nil errors)
This happens when Rails tries to serialize model objects for URL generation or form helpers.
Solution Applied
Added explicit JSONB attribute handling to ALL models with JSONB fields to ensure they always return Hash objects.
Models Fixed
1. ApiKey Model - permissions field
File: app/models/api_key.rb
JSONB Field: permissions (stores API key permissions like send_sms, receive_sms, etc.)
Fix Applied:
class ApiKey < ApplicationRecord
# Normalize permissions to always be a Hash
attribute :permissions, :jsonb, default: {}
before_validation :ensure_permissions_is_hash
# ... rest of the model ...
private
def ensure_permissions_is_hash
self.permissions = {} if permissions.nil?
self.permissions = {} unless permissions.is_a?(Hash)
end
end
2. Gateway Model - metadata field
File: app/models/gateway.rb
JSONB Field: metadata (stores additional gateway configuration and metadata)
Fix Applied:
class Gateway < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
before_validation :ensure_metadata_is_hash
# ... rest of the model ...
private
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end
3. OtpCode Model - metadata field
File: app/models/otp_code.rb
JSONB Field: metadata (stores OTP-related metadata like device info, IP address context, etc.)
Fix Applied:
class OtpCode < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
before_validation :ensure_metadata_is_hash
# ... rest of the model ...
private
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end
4. SmsMessage Model - metadata field
File: app/models/sms_message.rb
JSONB Field: metadata (stores message metadata like delivery reports, carrier info, etc.)
Fix Applied:
class SmsMessage < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
before_validation :ensure_metadata_is_hash
# ... rest of the model ...
private
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end
Database Schema Reference
All JSONB columns are properly defined in the schema with default values:
# db/schema.rb
create_table "api_keys" do |t|
# ...
t.jsonb "permissions", default: {}
# ...
end
create_table "gateways" do |t|
# ...
t.jsonb "metadata", default: {}
# ...
end
create_table "otp_codes" do |t|
# ...
t.jsonb "metadata", default: {}
# ...
end
create_table "sms_messages" do |t|
# ...
t.jsonb "metadata", default: {}
# ...
end
How The Fix Works
1. Explicit Attribute Declaration
attribute :permissions, :jsonb, default: {}
This tells ActiveRecord to explicitly treat the column as JSONB and always default to an empty Hash.
2. Before Validation Callback
before_validation :ensure_permissions_is_hash
Runs before every validation, ensuring the field is always a Hash before Rails processes it.
3. Hash Normalization Method
def ensure_permissions_is_hash
self.permissions = {} if permissions.nil?
self.permissions = {} unless permissions.is_a?(Hash)
end
- Converts nil to {}
- Converts any non-Hash value to {}
- Leaves Hash values unchanged
Why This Prevents stringify_keys Error
When Rails helpers like button_to serialize model objects:
Before Fix:
api_key.permissions # Sometimes returns String "{"send_sms": true}"
# Rails tries to call .stringify_keys on String
# ERROR: undefined method 'stringify_keys' for String
After Fix:
api_key.permissions # Always returns Hash {"send_sms" => true}
# Rails successfully calls .stringify_keys on Hash
# SUCCESS: No error
View Safety Measures
In addition to model fixes, views also have defensive coding:
app/views/admin/api_keys/index.html.erb:
<% perms = api_key.permissions || {} %>
<% perms = {} unless perms.is_a?(Hash) %>
<% if perms.any? %>
<% perms.select { |_, v| v }.keys.each do |perm| %>
<span><%= perm.to_s.humanize %></span>
<% end %>
<% else %>
<span>None</span>
<% end %>
This provides double protection:
- Model ensures JSONB field is always Hash
- View verifies and provides fallback
Testing The Fix
Console Test
bin/rails console
# Test ApiKey permissions
api_key = ApiKey.first
api_key.permissions.class # => Hash
api_key.can?("send_sms") # => true or false
# Test Gateway metadata
gateway = Gateway.first
gateway.metadata.class # => Hash
gateway.metadata["foo"] = "bar"
gateway.save! # Should work without errors
# Test OtpCode metadata
otp = OtpCode.first
otp.metadata.class # => Hash
# Test SmsMessage metadata
msg = SmsMessage.first
msg.metadata.class # => Hash
Server Test
# Start server
bin/dev
# Visit admin interface
# http://localhost:3000/admin/login
# Login and navigate to API Keys
# Should load without stringify_keys error
Benefits of This Approach
✅ Consistent: All JSONB fields behave the same way across all models ✅ Safe: Handles nil, String, and other edge cases gracefully ✅ Performance: Minimal overhead (callback only runs on save/update) ✅ Rails-native: Uses Rails attribute API, not monkey-patching ✅ Future-proof: Works with all Rails helpers and serializers ✅ Maintainable: Clear, documented pattern that's easy to understand
Adding JSONB Fields in the Future
When adding new JSONB fields to any model, follow this pattern:
class MyModel < ApplicationRecord
# 1. Declare the attribute
attribute :my_jsonb_field, :jsonb, default: {}
# 2. Add before_validation callback
before_validation :ensure_my_jsonb_field_is_hash
# ... rest of model code ...
private
# 3. Add normalization method
def ensure_my_jsonb_field_is_hash
self.my_jsonb_field = {} if my_jsonb_field.nil?
self.my_jsonb_field = {} unless my_jsonb_field.is_a?(Hash)
end
end
Summary
✅ Fixed Models: ApiKey, Gateway, OtpCode, SmsMessage ✅ Fixed Fields: permissions (ApiKey), metadata (Gateway, OtpCode, SmsMessage) ✅ Error Resolved: stringify_keys error when using button_to, form_with, etc. ✅ Approach: Explicit attribute declaration + before_validation normalization ✅ Safety: Double protection (model + view defensive coding)
All JSONB fields now consistently return Hash objects, preventing serialization errors throughout the Rails application!