# 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**: ```ruby 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**: ```ruby 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**: ```ruby 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**: ```ruby 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: ```ruby # 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 ```ruby 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 ```ruby 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 ```ruby 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**: ```ruby 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**: ```ruby 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**: ```erb <% perms = api_key.permissions || {} %> <% perms = {} unless perms.is_a?(Hash) %> <% if perms.any? %> <% perms.select { |_, v| v }.keys.each do |perm| %> <%= perm.to_s.humanize %> <% end %> <% else %> None <% end %> ``` This provides double protection: 1. Model ensures JSONB field is always Hash 2. View verifies and provides fallback --- ## Testing The Fix ### Console Test ```bash bin/rails console ``` ```ruby # 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 ```bash # 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: ```ruby 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!