Use Enumerators in Rails 7 with MongoDB - ruby-on-rails

I'm building a new project with Rails 7 and MongoDB 8. And I wanted to use enumerators for multiple fields ( states etc .. )
I wanted to use the gem mongoid-enum but it doesn't work with Mongo 8.
Is switching to SQL database a solution ? Or is there any other way ?
I've checked on Mongo's doc and found a Phantom Custom Field Types but it looks like it's not saving in the db. In the rails console, I'll do the Model.status = "open" then saving it, it doesn't return any errors. So I close the console then open it again. Run the Model.status and it returns nil.
Thank you for reading and trying to help me !

First of all, there is good and bad in both MongoDB and PostgreSQL, it depends of the kind of features you need, see: https://www.geeksforgeeks.org/difference-between-postgresql-and-mongodb/
About the Phantom Custom Field Types, this is indeed doing the same stuff than ActiveRecord::Enum but asking more code to write. Could you share the not working code that you've run for your test please ?
Edit 07/02/22:
Here is an example of you could use enum in mongo without writing too much code:
module MongoEnum
# Takes application-scope value and converts it to how it would be
# stored in the database. Converts invalid values to nil.
def mongoize(object)
mapping[object]
end
# Get the value as it was stored in the database, and convert to
# application-scope value. Converts invalid values to nil.
def demongoize(object)
inverse_mapping[object]
end
# Converts the object that was supplied to a criteria and converts it
# into a query-friendly form. Returns invalid values as is.
def evolve(object)
mapping.fetch(object, object)
end
def mapping
#mapping ||= self.const_get(:MAPPING).freeze
end
def inverse_mapping
#inverse_mapping ||= mapping.invert.freeze
end
end
class RoleEnum
extend MongoEnum
MAPPING = {
'admin' => 0,
'user' => 1,
}.freeze
end
class ColorEnum
extend MongoEnum
MAPPING = {
'black' => 0,
'white' => 1,
}.freeze
end
class Profile
include Mongoid::Document
field :color, type: ColorEnum
end
class User
include Mongoid::Document
field :role, type: RoleEnum
end
Disclaimer: I didn't test it in a real app, let me know if it does not work.

Related

Existing data serialized as hash produces error when upgrading to Rails 5

I am currently upgrading a Ruby on Rails app from 4.2 to 5.0 and am running into a roadblock concerning fields that store data as a serialized hash. For instance, I have
class Club
serialize :social_media, Hash
end
When creating new clubs and inputting the social media everything works fine, but for the existing social media data I'm getting:
ActiveRecord::SerializationTypeMismatch: Attribute was supposed to be a Hash, but was a ActionController::Parameters.
How can I convert all of the existing data from ActionController::Parameter objects to simple hashes? Database is mysql.
From the fine manual:
serialize(attr_name, class_name_or_coder = Object)
[...] If class_name is specified, the serialized object must be of that class on assignment and retrieval. Otherwise SerializationTypeMismatch will be raised.
So when you say this:
serialize :social_media, Hash
ActiveRecord will require the unserialized social_media to be a Hash. However, as noted by vnbrs, ActionController::Parameters no longer subclasses Hash like it used to and you have a table full of serialized ActionController::Parameters instances. If you look at the raw YAML data in your social_media column, you'll see a bunch of strings like:
--- !ruby/object:ActionController::Parameters...
rather than Hashes like this:
---\n:key: value...
You should fix up all your existing data to have YAMLized Hashes in social_media rather than ActionController::Parameters and whatever else is in there. This process will be somewhat unpleasant:
Pull each social_media out of the table as a string.
Unpack that YAML string into a Ruby object: obj = YAML.load(str).
Convert that object to a Hash: h = obj.to_unsafe_h.
Write that Hash back to a YAML string: str = h.to_yaml.
Put that string back into the database to replace the old one from (1).
Note the to_unsafe_h call in (3). Just calling to_h (or to_hash for that matter) on an ActionController::Parameters instance will give you an exception in Rails5, you have to include a permit call to filter the parameters first:
h = params.to_h # Exception!
h = params.permit(:whatever).to_h # Indifferent access hash with one entry
If you use to_unsafe_h (or to_unsafe_hash) then you get the whole thing in a HashWithIndifferentAccess. Of course, if you really want a plain old Hash then you'd say:
h = obj.to_unsafe_h.to_h
to unwrap the indifferent access wrapper as well. This also assumes that you only have ActionController::Parameters in social_media so you might need to include an obj.respond_to?(:to_unsafe_hash) check to see how you unpack your social_media values.
You could do the above data migration through direct database access in a Rails migration. This could be really cumbersome depending on how nice the low level MySQL interface is. Alternatively, you could create a simplified model class in your migration, something sort of like this:
class YourMigration < ...
class ModelHack < ApplicationRecord
self.table_name = 'clubs'
serialize :social_media
end
def up
ModelHack.all.each do |m|
# Update this to match your real data and what you want `h` to be.
h = m.social_media.to_unsafe_h.to_h
m.social_media = h
m.save!
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
You'd want to use find_in_batches or in_batches_of instead all if you have a lot of Clubs of course.
If your MySQL supports json columns and ActiveRecord works with MySQL's json columns (sorry, PostgreSQL guy here), then this might be a good time to change the column to json and run far away from serialize.
Extending on short's reply - a solution that does not require a database migration:
class Serializer
def self.load(value)
obj = YAML.load(value || "{}")
if obj.respond_to?(:to_unsafe_h)
obj.to_unsafe_h
else
obj
end
end
def self.dump(value)
value = if value.respond_to?(:to_unsafe_h)
value.to_unsafe_h
else
value
end
YAML.dump(value)
end
end
serialize :social_media, Serializer
Now club.social_media will work whether it was created on Rails 4 or on Rails 5.
The reply by #schor was a life-saver, but I kept getting no implicit conversion of nil into String errors when doing the YAML.load(value).
What worked for me was:
class Foo < ApplicationRecord
class NewSerializer
def self.load(value)
return {} if !value #### THIS NEW LINE
obj = YAML.load(value)
if obj.respond_to?(:to_unsafe_h)
obj.to_unsafe_h
else
obj
end
end
def self.dump(value)
if value.respond_to?(:to_unsafe_h)
YAML.dump(value.to_unsafe_h)
else
YAML.dump(value)
end
end
end
serialize :some_hash_field, NewSerializer
end
I gotta admin the Rails team totally blindsided me on this one, a most unwelcome breaking change that doesn't even let an app fetch the "old" data.
The official Ruby on Rails documentation has a section about upgrading between Rails versions that explains more about the error you have:
ActionController::Parameters No Longer Inherits from HashWithIndifferentAccess
Calling params in your application will now return an object instead of a hash. If your parameters are already permitted, then you will not need to make any changes. If you are regardless of permitted? you will need to upgrade your application to first permit and then convert to a hash.
params.permit([:proceed_to, :return_to]).to_h
Run a migration on Rails 4 to prepare the data for Rails 5.
We're going through the exact same thing, except we serialize as ActiveSupport::HashWithIndifferentAccess instead of just Hash, which I recommend doing, but I'll provide my answer here for just a simple Hash.
If you have not yet upgraded to Rails 5, which I hope you haven't and your tests have uncovered this issue, you can run a migration on the Rails 4 branch that will get your data ready for Rails 5.
It essentially re-serializes all of your records from ActionController::Parameters to Hash while in Rails 4 and ActionController::Parameters still inherits from HashWithIndifferentAccess.
class ConvertSerializedActionControllerParametersToHashInClubs < ActiveRecord::Migration
disable_ddl_transaction! # This prevents the locking of the table (e.g. in production).
def up
clubs = Club.where.not( social_media: nil )
total_records = clubs.count
say "Updating #{ total_records } records."
clubs.each.with_index( 1 ) do |club, index|
say "Updating #{ index } of #{ total_records }...", true
club.social_media = club.social_media.to_h
club.social_media_will_change!
club.save
end
end
def down
puts "Cannot be reverse! See backup table."
end
end
If you have multiple columns that need to be converted, it's easy to modify this migration to convert all of the necessary tables and columns.
Depending on when you do this, your data should be ready for Rails 5.

Search for empty string field in Sunspot (Solr)

In my model, I'm simply using something like:
class Person < ActiveRecord::Base
searchable do
string :middle_name
end
end
The particular object I'm trying to search for has a :middle_name attribute that contains '', an empty string and is of the String datatype. Based on that information, I am assuming that Sunspot is also saving an empty string for that field in the Solr index.
After successfully doing Person.reindex and Sunspot.commit, I tried searching for the said object using Person.search{with(:middle_name, '')}.resultsin the rails console and it returns a 400 error in regards to Solr query syntax.
I then looked around and found some information on a query like Person.search{with(:middle_name, "* TO ''")}.results and Person.search{without(:middle_name, "* TO *")}.results, both of which return an empty set: [].
Anyone know a way that actually works and/or what the best way to do this is?
To make it work you have make monkey patch Sunspot::Query::Restriction::EqualTo method. Create a new file in config/initializers directory and add this code:
module Sunspot
module Query
module Restriction
class EqualTo < Base
def to_positive_boolean_phrase
case #value
when nil
"#{escape(#field.indexed_name)}:[* TO *]"
when ''
%Q(#{escape(#field.indexed_name)}:[* TO ""])
else
super
end
end
def negated?
if #value.nil?
!super
else
super
end
end
private
def to_solr_conditional
"#{solr_value}"
end
end
end
end
end
Remember to restart rails server before you try this.
Try this:
person = Person.solr_search do
keywords params[:middle_name]
end
person.results
If you want to try in console then, replace params[:middle_name]
with middle name of your choice. eg 'burger'

Store functions in mongodb using Mongoid 3

Just as the title suggests. I am not able to find anything related to Mongoid 3. The things I found only apply to old versions of mongoid that didn't use Moped.
I found this and it doesn't work:
def self.install_javascript
getWeekJs = Rails.root.join("lib/javascript/getWeek.js")
if collection.master['system.js'].find_one({'_id' => "getWeek"}).nil?
collection.master.db.add_stored_function("getWeek", File.new(getWeekJs).read)
end
end
This method would add a getWeek function to the system.js collection.
How can this be done in mongoid 3?
Nailed it!
Codes:
class StoredProcedure
include Mongoid::Document
store_in collection: "system.js"
field :_id, type: String, default: ""
def self.test
equalsJS = Rails.root.join("lib/assets/javascripts/equals.js")
code = Moped::BSON::Code.new(File.new(equalsJS).read)
unless where(id: "equals").first
proc = new(value: code)
proc._id = "equals"
proc.save
end
end
end
Explanation:
I'm using the system.js in mongoid as if it were a normal collection. I'm then simply adding new documents.
IMPORTANT:
The value needs to be a Moped::BSON::Code instance otherwise it will be saved as string, thus useless. The id needs to be the function's name. I wasn't able to specify the id in a create statement, therefore I added multiple steps.
Just add this to a rake task to make sure you add all required functions to mongo after deployment.

How to perform Rails model validation checks within model but outside of filters using ledermann-rails-settings and extensions

Background
I'm using ledermann-rails-settings (https://github.com/ledermann/rails-settings) on a Rails 2/3 project to extend virtually the model with certain attributes that don't necessarily need to be placed into the DB in a wide table and it's working out swimmingly for our needs.
An additional reason I chose this Gem is because of the post How to create a form for the rails-settings plugin which ties ledermann-rails-settings more closely to the model for the purpose of clean form_for usage for administrator GUI support. It's a perfect solution for addressing form_for support although...
Something that I'm running into now though is properly validating the dynamic getters/setters before being passed to the ledermann-rails-settings module. At the moment they are saved immediately, regardless if the model validation has actually fired - I can see through script/console that validation errors are being raised.
Example
For instance I would like to validate that the attribute :foo is within the range of 0..100 for decimal usage (or even a regex). I've found that with the previous post that I can use standard Rails validators (surprise, surprise) but I want to halt on actually saving any values until those are addressed - ensure that the user of the GUI has given 61.43 as a numerical value.
The following code has been borrowed from the quoted post.
class User < ActiveRecord::Base
has_settings
validates_inclusion_of :foo, :in => 0..100
def self.settings_attr_accessor(*args)
>>SOME SORT OF UNLESS MODEL.VALID? CHECK HERE
args.each do |method_name|
eval "
def #{method_name}
self.settings.send(:#{method_name})
end
def #{method_name}=(value)
self.settings.send(:#{method_name}=, value)
end
"
end
>>END UNLESS
end
settings_attr_accessor :foo
end
Anyone have any thoughts here on pulling the state of the model at this point outside of having to put this into a before filter? The goal here is to be able to use the standard validations and avoid rolling custom validation checks for each new settings_attr_accessor that is added. Thanks!
Here is a newer version that works in the new 2x syntax. Yes it is ugly and does double eval.
This produces namespaces method names and adds them to the attr_accessible list. The names are in the form of "#{namespace}_#{attribute} and can be use in forms. I am monkeying with a ppatch to the gem to do this automatically but I an not there yet.
has_settings do |s|
eval 'def self.settings_accessors(namespace, defaults)
defaults.keys.each do |method_name|
attr_accessible "#{namespace}_#{method_name}"
eval "def #{namespace}_#{method_name}
self.settings(:#{namespace.to_s}).send(:#{method_name})
end
def #{namespace}_#{method_name}=(value)
self.settings(:#{namespace}).send(:#{method_name}=, value)
end
"
end
end'
namespace = :fileshare
defaults = {:media => false, :sit => false, :quota_size => 1}
s.key namespace, :defaults => defaults
self.settings_accessors(namespace, defaults)
end

Simple boolean data update with mongdb?

I am using Rails and mongoid to work with mongodb.
Usually in rails when working with Active:Record, you have access to the method .toggle! which simply allows you to invert the value of a boolean field in your db.
Unfortunately this method is not available for mongoDB:
user = User.first
user.toggle!(:admin)
NoMethodError: undefined method `toggle!' for #<User:0x00000100eee700>
This is unfortunate... and stupidly enough I don't see how to get around without some complicated code...
Any suggestion on how to achieve the same result concisely ?
Thanks,
Alex
ps: also one of the problems is that when I want to modify the field, it goes through validation again... and it's asking for the :password which I don't save in the db, so:
User.first.admin = !User.first.admin
won't even work :(
The issue here is specifically mongoid, not mongodb. toggle! is a part of ActiveRecord::Base, but fortunately it's not hard to replicate.
def toggle!(field)
send "#{field}=", !self.send("#{field}?")
save :validation => false
end
Add that into your model (or add it into a module, and include it in your model), and your Mongoid models will gain functionality equivalent to what you're used to in AR. It will read the field's value, invert it, write it (through the setter, per the toggle! documentation), and then save the document, bypassing validation.
# Get object's boolean field and toggle it
# #param [Object] mongoid object
# #param [String, Symbol] flag
# #example
# foo = User.find('123')
# toggle_flag!(object: foo, flag: :bar)
def toggle_flag!(object:, flag:)
object.update(flag => !object[flag])
object.save!
end
Ok the validation did not work because of a type, the code should be:
save :validate => false (not :validation)

Resources