327 lines
7.3 KiB
Markdown
327 lines
7.3 KiB
Markdown
# 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| %>
|
|
<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
|
|
```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!
|