How to store enum as string to database in rails - ruby-on-rails

How do I create a migration in ruby where the default is a string rather than an Integer, I want to store enum into the database, but I do not want to store it as Integer, because then it does not make sense to another application that wants to use the same table. How do I do default: "female" instead of default:0
class AddSexToUsers < ActiveRecord::Migration
def change
add_column :users, :sex, :integer, default: 0
end
end
class User < ActiveRecord::Base
enum sex: [:female, :male]
has_secure_password
end
I

Reading the enum documentation, you can see Rails use the value index of the Array explained as:
Note that when an Array is used, the implicit mapping from the values to database integers is derived from the order the values appear in the array.
But it is also stated that you can use a Hash:
it's also possible to explicitly map the relation between attribute and database integer with a Hash.
With the example:
class Conversation < ActiveRecord::Base
enum status: { active: 0, archived: 1 }
end
So I tested using Rails 4.2.4 and sqlite3 and created an User class with a string type for sex type and a Hash in the enum with string values(I am using fem and mal values to differ from female and male):
Migration:
class CreateUsers < ActiveRecord::Migration
def change
create_table :users do |t|
t.string :sex, default: 'fem'
end
end
end
Model:
class User < ActiveRecord::Base
enum sex: { female: 'fem', male: 'mal' }
end
And in console:
u = User.new
#=> #<User id: nil, sex: "fem">
u.male?
#=> false
u.female?
#=> true
u.sex
#=> "female"
u[:sex]
#=> "fem"
u.male!
# INSERT transaction...
u.sex
#=> "male"
u[:sex]
#=> "mal"

I normally do the following:
# in the migration in db/migrate/…
def self.up
add_column :works, :status, :string, null: false, default: 'offering'
end
# in app/models/work.rb
class Work < ApplicationRecord
ALL_STATES = %w[canceled offering running payment rating done].freeze
enum status: ALL_STATES.zip(ALL_STATES).to_h
end
By using a hash as argument for enum (see docs) this stores strings in the database. At the same time this still allows you to use all the cool Rails helper methods:
w = Work.new
#=> #<Work id: nil, status: "offering">
w.rating?
#=> false
w.offering?
#=> true
w.status
#=> "offering"
w[:status]
#=> "offering"
w.done!
# INSERT transaction...
w.status
#=> "done"
w[:status]
#=> "done"
Update for one-liner:
I overlooked completely we've got index_by since Rails 1.2.6. This makes the solution a one-liner even:
enum status: %w[canceled offering running payment rating done].index_by(&:to_sym)
Alternatively we've got index_with since Rails 6.0.0:
enum status: %i[canceled offering running payment rating done].index_with(&:to_s)

enum in Rails and ENUM type in MySQL are 2 different things.
enum in Rails is just a wrapper around your integer column so it's easier for you to use strings in queries, rather than integers. But on database level it's all converted to integers (automatically by Rails), since that's the type of the column.
ENUM type in MySQL is vendor-specific column type (for example, SQLite doesn't support it, but PostgreSQL does). In MySQL :
An ENUM is a string object with a value chosen from a list of permitted values that are enumerated explicitly in the column specification at table creation time.
CREATE TABLE shirts (
name VARCHAR(40),
size ENUM('x-small', 'small', 'medium', 'large', 'x-large')
);
INSERT INTO shirts (name, size) VALUES ('dress shirt','large'), ('t-shirt','medium'),
('polo shirt','small');
SELECT name, size FROM shirts WHERE size = 'medium';
+---------+--------+
| name | size |
+---------+--------+
| t-shirt | medium |
+---------+--------+
For the migration, you need to do this:
class AddSexToUsers < ActiveRecord::Migration
def change
add_column :users, :sex, "ENUM('female', 'male') DEFAULT 'female'"
end
end

Take a look at this Gist, Rails doesn't provide it out of the box so you have to use a concern:
https://gist.github.com/mani47/86096220ccd06fe46f0c09306e9d382d

There's steps to add enum as string to model Company
bin/rails g migration AddStatusToCompanies status
class AddStatusToCompanies < ActiveRecord::Migration[7.0]
def change
add_column :companies, :status, :string, null: false, default: 'claimed'
add_index :companies, :status
end
end
bin/rails db:migrate
Values are strings (symbols not working)
add Default
add Prefix
enum status: {
claimed: 'claimed',
unverified: 'unverified',
verified: 'verified',
}, default: 'claimed'
Add validation (or will raise sql exception)
validates :status, inclusion: { in: statuses.keys }, allow_nil: true

To my knowledge it is not possible with standard Rails enum. Look at https://github.com/lwe/simple_enum, it is more functionally rich, and also allows storing of enum values as strings to DB (column type string, i.e. varchar in terms of DB).

Related

Rails Model - custom primary key with ID as a custom column

I struggle with modeling my table to Rails model with custom primary key and id as a regular (not null) column.
The problem is my table has:
auto_id primary key column with AUTO_INCREMENT
id as custom column with NOT NULL constraint on it (it should be set by the application side before saving)
class InitialMigration < ActiveRecord::Migration[6.1]
def change
create_table :cars, id: false, force: true do |t|
t.primary_key :auto_id
t.string :id, null: false
end
end
end
I want to:
Make the primary_key being auto generated by database.
Set the id value directly in the application before saving the model.
But when I try to save a Car instance to database I have a problem with setting id from the app.
class Car < ActiveRecord::Base
self.primary_key = "auto_id"
before_create do
binding.pry
end
end
Even in the binding.pry line, when I call self.id = '1234' it's being reassigned to auto_id field, instead of id.
Thus id columns always remain NULL which leads to a DB error Field 'id' doesn't have a default value.
[2] pry(#<Car>)> self.id = '9'
=> "9"
[3] pry(#<Car>)> self
=> #<Car:0x00007fcdb34ebe60
auto_id: 9,
id: nil>
PS. It's Rails 6.
I'd avoid using id this way by renaming it to something else. In rails id is whatever the #primary_key is set to. There is a whole module dedicated to it, that defines id attribute methods:
https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/PrimaryKey.html
These methods can be overridden, but shouldn't:
class Car < ApplicationRecord
self.primary_key = "auto_id"
def id= value
_write_attribute("id", value)
end
def id
_read_attribute("id")
end
end
"id" is hardcoded in write_attribute to use primary_key, so we can't use write_attribute to set id attribute itself.
Also, id methods are used in other places. Overriding them is a bad idea.
>> Car.create(id: "99")
=> #<Car:0x00007f723e801788 auto_id: nil, id: "99">
# ^
# NOTE: Even though car has been saved, `auto_id` is still `nil`
# but only in `car` object. Because we have overridden
# `id`, primary key is read from `id` column and then `id`
# attribute is set in `id=`. `auto_id` is bypassed.
>> Car.last
=> #<Car:0x00007f774d9e8dd0 auto_id: 1, id: "99">
# NOTE: Other methods are broken whenever `id` is used
>> Car.last.to_key
=> ["99"]
>> Car.last.to_gid
=> #<GlobalID:0x00007f774f4def68 #uri=#<URI::GID gid://stackoverflow/Car/99>>
A better way is to not touch id methods:
class Car < ApplicationRecord
self.primary_key = "auto_id"
def id_attribute= value
_write_attribute("id", value)
end
def id_attribute
_read_attribute("id")
end
end
>> car = Car.create(id_attribute: "99")
=> #<Car:0x00007fb1e44d9458 auto_id: 2, id: "99">
>> car.id_attribute
=> "99"
>> car.id
=> 2
>> car.auto_id
=> 2

When I run a schema migration before a data migration, with ActiveRecord, data does not properly update in DB

As of now, I have a users table with columns
id, name, email, status
status field is an integer type with values 1 and 2 representing an Active and Inactive user, respectively.
I would like to change the status field to a string type and migrate the data -- Convert 1 to "Active" and 2 to "Inactive"
I generated 2 migration files with rails g migration
user.rb
class User < ApplicationRecord
module Status
ACTIVE = 'Active'.freeze
INACTIVE = 'Inactive'.freeze
ALL = [ACTIVE, INACTIVE].freeze
end
validates :status, presence: true
validates :status, inclusion: Status::ALL
end
db/migrate/20190906115523_update_user_status_type.rb
def UpdateUserStatusType < ActiveRecord::Migration[5.2]
def up
change_column :users, :status, :string, default: User::Status::ACTIVE,
end
def down
User.where(status: User::Status::ACTIVE).update_all(status: 1)
User.where(status: User::Status::INACTIVE).update_all(status: 2)
change_column :users, :status, :integer, default: 1
end
end
db/migrate/20190906115828_update_user_statuses.rb
def UpdateUserStatuses < ActiveRecord::Migration[5.2]
def data
User.where(status: 1).update_all(status: User::Status::ACTIVE)
User.where(status: 2).update_all(status: User::Status::INACTIVE)
end
end
After running rails db:migrate
Expected: Each user's status should be converted to either "Active" or "Inactive" after migrations are finished.
Actual Results: Each user's status are converted to "0" of string type.
You're assuming after the first migration runs (change_column :users, :status, :string, default: User::Status::ACTIVE) you can still fetch the old values from the status column which is not the case. When you change the type of that column to string all the integer values are invalid so I suspect your database just changes all the invalid values to be "0" instead.
If I was told to make this change to an application that is heavily used in production, I would be roll out this change in a few separate pull requests/migrations. I'd create a whole new separate column, iterate through all the users, set the value of the new column depending on what the value in the old column is, and then delete the old column. This is a much safer way to make this change.

How to use Rails 5's ActiveRecord attributes to provide a virtual column

I want to add virtual columns to some of my models, but to have their values returned by ActiveRecord statements like Product.first, so that I can use statements like Product.first.to_json to output the product, with the virtual columns, on an API request.
The values of the columns depend on other model attributes. I don't want these columns persisted to the database.
I tried this:
class Product < ApplicationRecord
def total
price + tax
end
end
but Product.first did not include the total.
class Product < ApplicationRecord
attribute :total, :decimal, default: -> { 0.0 }
end
adds a total: 0.0 to the returned object, but
class Product < ApplicationRecord
attribute :total, :decimal, default: -> { price + tax }
end
fails with messages such as
#<NameError: undefined local variable or method `price' for #<Class:0x0000557b51c2c960>>
and
class Product < ApplicationRecord
attribute :total, :decimal, default: -> { 0.0 }
def total
price + tax
end
end
still returns total: 0.0.
I'm not even sure if attribute is the right way to do this, as the docs seem to imply that it binds to a column.
To sum up:
the products table should not contain a total column.
accessing Product through ActiveRecord should return a Product object that includes a total key with a computed value based on other attributes of the model.
Is this even possible?
I really don't want to have to replace every to_json call with a lot of code manually inserting these virtual columns…
You can use methods option
class Product < ApplicationRecord
def total
price + tax
end
end
Product.first.to_json(methods: :total)
Override as_json in your model to include your method.
This won't include total in your retrieved Product object, but it will include it when calling .to_json on the object.
class Product < ApplicationRecord
attribute :total, :decimal, default: -> { 0.0 }
def total
price + tax
end
def as_json(options = {})
super(methods: [:total])
end
end
A virtual/generated column (assuming MySQL/MariaDB) in your database would solve what you need. Because it is generated from data from other columns, you can't write to it and it is only updated during read operations. There is the option to persist the data, but that's not the question here.
In my example, I want to add a virtual column "age" to my People database that is the difference between person.birthday and curdate().
I generate the column:
rails generate migration AddAgeToPeople age:virtual
Then I edit the migration file so that add_column :people, :age, :virtual
becomes
class AddAgeToPeople < ActiveRecord::Migration[5.2]
def change
add_column :people, :age, :int, as: "timestampdiff(year, birthday, curdate())"
end
end
The end result would be SQL that looks like:
ALTER TABLE people ADD COLUMN age GENERATED ALWAYS AS (timestampdiff(year, birthday, curdate()));
| Field | Type | Null | Key | Default | Extra |
| age | int(11) | YES | | NULL | VIRTUAL GENERATED |
The end result is an attribute in the model that I can interact with normally (read only though)

Rails store numbers with 2 decimal places after comma in database

I am doing PDFs for invoices in my system and I would like to be able to store numbers with two decimal places in the database. I am using MoneyRails gem for dealing with currencies, I have setup precision: 10 and scale: 2 on the database level (I use postgres as my DB) but I am getting only 1 decimal place after comma. Why?
class AddPrecisionToInvoices < ActiveRecord::Migration[5.2]
def self.up
change_column :invoices, :total_net_amount_cents, :decimal, precision: 10, scale: 2, default: 0.00
change_column :invoices, :total_gross_amount_cents, :decimal, precision: 10, scale: 2, default: 0.00
end
def self.down
change_column :invoices, :total_net_amount_cents, :bigint
change_column :invoices, :total_gross_amount_cents, :bigint
end
end
invoice.rb
monetize :total_net_amount_cents
monetize :total_gross_amount_cents
In rails console,
invoice.total_gross_amount_cents = Money.new(20_000_00)
invoice.total_gross_amount.to_f #=> 2000.0
Is it possible to store numbers with two decimal places in DB, like 20,000.00?
I don't want to display the PDF in a view so I want to be able to drop the number into my DB as I got it from params from my front-end application without further formatting it in a view.
You can try following, (using in model)
ActiveSupport::NumberHelper::number_to_delimited('%.2f' % '3423432.43234', delimiter: ",", separator: ".")
# => "3,423,432.43"
Here, in above input 3423432.43234 is provided as string, you can provide it as number also.
You can directly use number_with_delimiter in view
The money-rails gem requires monetize columns to be numeric in the database. However, it comes with some helper methods that you could use to re-format as you wish in your model:
# inside Invoice model
require "money-rails/helpers/action_view_extension"
class Invoice < ApplicationRecord
include MoneyRails::ActionViewExtension
# ...
def total_gross_amount_formatted
humanized_money total_gross_amount
end
end
Then in your PDF you can just reference the new formatted attribute:
#invoice_instance.total_gross_amount_formatted

GraphQL gem with Rails, can't seem to find correct type definition?

I've defined a UserType as such:
class Types::UserType < GraphQL::Schema::Object
field :id, ID, null: false
field :username, String, null: false
field :full_name, String, null: true
end
Each of these fields exists on the Rails model, and pre 1.8 upgrade of the GraphQL Gem, I was able to use full_name in queries just fine.
When I run the query:
query {
users {
username
id
full_name
}
}
I get: "message": "Field 'full_name' doesn't exist on type 'User'",
If I remove full_name, I get the data I expect. In what way am I approaching this incorrectly? For reference, my QueryType is defined as:
class Types::QueryType < GraphQL::Schema::Object
# Add root-level fields here.
# They will be entry points for queries on your schema.
field :users, [UserType, null: true], null: false do
argument :id, Integer, required: false
end
def users(**args)
args[:id]
if args[:id]
User.where(id: args[:id])
else
User.all
end
end
end
I believe the issue is that full_name should be fullName in your query. With 1.8.x the fields in the schema are auto-camalized.
Field and argument names should be underscored as a convention. They will be converted to camelCase in the underlying GraphQL type and be camelCase in the schema itself.
-- http://graphql-ruby.org/type_definitions/objects.html

Resources