Files
MySMSAPio/JSONB_FIXES.md
2025-10-22 17:22:17 +08:00

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:

  1. Model ensures JSONB field is always Hash
  2. 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!