I am using an enum in Rails and PostgreSQL. In my model tests I usually verify that my Rails validations are backed by database constraints where appropriate (e.g. presence: true in Model and null: false in DB). I do this by deliberately making the model invalid, attempting to save it without validations and making sure it raises a ActiveRecord::StatementInvalid error.
How do I test a PostgreSQL enum from MiniTest? My usual approach isn't working as everything I try to do to set my ActiveRecord model to an invalid enum value raises an ArgumentError, even using write_attribute() directly.
Is there a way to deliberately bypass the enum restrictions in Rails? Do I need to drop down out of ActiveRecord and send an AREL or SQL query direct to the database? Is there some other approach?
# Model
class SisRecord < ApplicationRecord
enum record_type: {
student: "student",
staff: "staff",
contact: "contact"
}
validates :record_type, presence: true
end
# Migration
class CreateSisRecords < ActiveRecord::Migration[7.0]
def change
create_enum :sis_record_type, %w(student staff contact)
create_table :sis_records do |t|
t.enum :record_type, enum_type: :sis_record_type, null: false
t.timestamps
end
end
end
# Test
require "test_helper"
class SisRecordTest < ActiveSupport::TestCase
test "a record_type is required" do
record = sis_records(:valid_sis_record)
record.record_type = nil
assert_not record.save, "Saved the SIS Record without a record type"
end
test "a record_type is required by the database too" do
record = sis_records(:valid_sis_record)
record.record_type = nil
assert_raises(ActiveRecord::StatementInvalid) {
record.save(validate: false)
}
end
test "record_type is restricted to accepted values" do
accepted_values = %w(student staff contact)
record = sis_records(:valid_sis_record)
assert_nothing_raised {
record.record_type = accepted_values.sample
}
assert_raises(ArgumentError) {
record.record_type = "something else"
}
end
test "record_type is restricted to accepted values by the database too" do
accepted_values = %w(student staff contact)
record = sis_records(:valid_sis_record)
record.record_type = accepted_values.sample
assert record.save, "Record didn't save despite accepted type value '#{record.record_type}'"
record.write_attribute(:record_type, "nonsense") ### <-- ArgumentError
assert_raises(ActiveRecord::StatementInvalid) {
record.save(validate: false)
}
end
end
I have an answer to my own question, but I'm still open to better answers.
I found a comment on a gist that showed how to fairly simply insert a record with Arel so for now I am using this approach:
# Just the test in question
test "record_type is restricted to accepted values by the database too" do
accepted_values = %w(student staff contact)
table = Arel::Table.new(:sis_records)
manager = Arel::InsertManager.new
manager.insert [
[table[:record_type], accepted_values.sample],
[table[:created_at], Time.now],
[table[:updated_at], Time.now],
]
assert_nothing_raised {
SisRecord.connection.insert(manager.to_sql)
}
manager.insert [
[table[:record_type], "other type"],
[table[:created_at], Time.now],
[table[:updated_at], Time.now],
]
assert_raises(ActiveRecord::StatementInvalid) {
SisRecord.connection.insert(manager.to_sql)
}
end
created_at and updated_at are required fields so we have to add a value for those.
In my real case (not the simplified version I posted above), SisRecord belongs to Person so I had to provide a valid person ID (UUID) too. I did this by grabbing an ID from my people fixtures:
manager.insert [
[table[:record_type], "other type"],
[table[:person_id], people(:valid_person).id], # <--------
[table[:created_at], Time.now],
[table[:updated_at], Time.now],
]
Related
I have a json column in my Categories table and I want to update each category record with a translation from a json file. I have built the json file so that it contains a categories array and each category has a name and a translation, like so:
{
"categories": [
{
"name": "starter",
"message": "Abs/Анти блокираща система (система против боксуване)"
},
{
"name": "alternator",
"message": "Алтернатор"
}
...
]
}
I want every category record to be updated with the language key as well as the translation from the file, like so:
{ bg: 'translation from file' }
I have this code
file = File.read('app/services/translations/files/bg.json')
data = JSON.parse(file)
language = File.basename(file, '.json')
Translations::CategoriesMigrator.call(file: data, language: language)
module Translations
class CategoriesMigrator < Service
def initialize(category_repo: Category)
#category_repo = category_repo
end
def call(file:, language:)
file['categories'].each do |category|
found_category = #category_repo.find_by(name: category['name'])
found_category.translated_categories[language] = category['message']
found_category.save
end
end
end
end
Right now I end up having all categories in a single category record. What am I doing wrong?
Update
My db migration looks like this:
class AddTranslatedCategoriesToCategories < ActiveRecord::Migration[5.1]
def change
add_column :categories, :translated_categories, :jsonb, null: false, default: {}
add_index :categories, :translated_categories, using: :gin
end
end
JSON/JSONB is a good choice when you have data that does not fit in the relational model. In most other cases its an anti-pattern since it makes it much harder to query the data and provides no data integrity or normalization.
This case is definitely the later since the underlaying structure is not dynamic. To keep track of translations we just need to know the subject, the language and the translation.
class Category
has_many :category_translations
end
# rails g model category_translation category:belongs_to locale:string text:string
class CategoryTranslation
belongs_to :category
end
You can add a compound index on category_id and locale to enforce uniqueness.
See:
https://www.2ndquadrant.com/en/blog/postgresql-anti-patterns-unnecessary-jsonhstore-dynamic-columns/
Half of the time I'll be able to make my tests run. The other half they fail because of a uniqueness violation, the source of which I am unable to locate. Right now I am in the latter half. My error is this:
ItemTest#test_valid_setup:
ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "index_blobs_on_user_id_and_item_id"
DETAIL: Key (user_id, item_id)=(1, 1) already exists.
: INSERT INTO "blobs" ("user_id", "item_id", "amount", "active", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id"
test/models/item_test.rb:15:in `setup'
I have a factories.rb:
FactoryBot.define do
factory :user, aliases: [:owner] do
email "test#test.com"
username "test"
end
factory :item do
owner
image { loads file etc... }
price 100
...
end
factory :blob, aliases: [:wanted, :collateral] do
user
item
amount 0
active true
end
end
My item_test.rb
require 'test_helper'
require 'support/database_cleaner'
class ItemTest < Minitest::Test
def setup
DatabaseCleaner.start
#create users
#user1 = FactoryBot.create(:user, email: "a#pear.com", username: "barnyard")
#user2 = FactoryBot.create(:user, email: "bo#ichi.com", username: "ponygrl")
#user3 = FactoryBot.create(:user, email: "ho#ho.com", username: "hon")
#create items
#item1 = FactoryBot.create(:item, owner: #user1)
#item2 = FactoryBot.create(:item, owner: #user2, price: 101)
#item3 = FactoryBot.create(:item, owner: #user3, price: 102)
#create blobs
#blob1 = FactoryBot.create(:blob, user: #user1, item: #item1, amount: #item1.price, active: false)
#blob2 = FactoryBot.create(:blob, user: #user2, item: #item2, amount: #item2.price, active: false)
#blob3 = FactoryBot.create(:blob, user: #user3, item: #item3, amount: #item3.price, active: false)
end
def teardown
DatabaseCleaner.clean
end
end
And then an item.rb
class Item < ApplicationRecord
after_create :create_blobs
private
def create_blobs
blob = Blob.new(user_id: self.owner.id, item_id: self.id, amount: self.price)
blob.save
end
end
A little background: A User creates an Item which in turn creates a Blob in an after_create with an amount parameter set to the value of Item's price. I cannot find out how to run an after_create in minitest, so I mocked up the Blob data in setup to inherit from an attribute of Item.
I can see that the error comes from line 15 of item_test.rb, but I'm not understanding why. I'm creating the Users, then the Items, and then ERROR the Blobs. I understand the why (I have a db level uniqueness constraint on a combination of user and item) but not the how (because from what I see, I haven't created those Blobs - there's no after_create called on Item when they're created in test), and I suspect that it has to do with the way I'm writing this.
It seems natural to me to conclude that DatabaseCleaner.start and DatabaseCleaner.clean both start and clean up old test data when the test is run and concluded, but this is obviously not the case. I started using it specifically to avoid this problem, which I was having previously. So I db:drop db:create, and db:schema:load, but once again, I have the same issue. And if it's not that, it's a uniqueness violation on a username, an email, etc......long story short, what is going on with that error?
Sorry if this is so confusing.
Edit: If I uncomment the after_create and replace all method references to the blob object created through that callback with blobs created in my test setup, the tests pass. But I really don't like doing that.
Either uncomment the after_create and reference the test objects, or remove the test objects and reference each blob by writing a method that returns the blob owned_by user and item, so that you can write #item.blob, and have it return the relevant blob.
Hi I have an array column in my model:
t.text :sphare, array: true, default: []
And I want to validate that it includes only the elements from the list ("Good", "Bad", "Neutral")
My first try was:
validates_inclusion_of :sphare, in: [ ["Good"], ["Bad"], ["Neutral"] ]
But when I wanted to create objects with more then one value in sphare ex(["Good", "Bad"] the validator cut it to just ["Good"].
My question is:
How to write a validation that will check only the values of the passed array, without comparing it to fix examples?
Edit added part of my FactoryGirl and test that failds:
Part of my FactoryGirl:
sphare ["Good", "Bad"]
and my rspec test:
it "is not valid with wrong sphare" do
expect(build(:skill, sphare: ["Alibaba"])).to_not be_valid
end
it "is valid with proper sphare" do
proper_sphare = ["Good", "Bad", "Neutral"]
expect(build(:skill, sphare: [proper_sphare.sample])).to be_valid
end
Do it this way:
validates :sphare, inclusion: { in: ["Good", "Bad", "Neutral"] }
or, you can be fancy by using the short form of creating the array of strings: %w(Good Bad Neutral):
validates :sphare, inclusion: { in: %w(Good Bad Neutral) }
See the Rails Documentation for more usage and example of inclusion.
Update
As the Rails built-in validator does not fit your requirement, you can add a custom validator in your model like following:
validate :correct_sphare_types
private
def correct_sphare_types
if self.sphare.blank?
errors.add(:sphare, "sphare is blank/invalid")
elsif self.sphare.detect { |s| !(%w(Good Bad Neutral).include? s) }
errors.add(:sphare, "sphare is invalid")
end
end
You can implement your own ArrayInclusionValidator:
# app/validators/array_inclusion_validator.rb
class ArrayInclusionValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
# your code here
record.errors.add(attribute, "#{attribute_name} is not included in the list")
end
end
In the model it looks like this:
# app/models/model.rb
class YourModel < ApplicationRecord
ALLOWED_TYPES = %w[one two three]
validates :type_of_anything, array_inclusion: { in: ALLOWED_TYPES }
end
Examples can be found here:
https://github.com/sciencehistory/kithe/blob/master/app/validators/array_inclusion_validator.rb
https://gist.github.com/bbugh/fadf8c65b7f4d3eaa55e64acfc563ab2
I'm currently trying to write an RSpec test for a validation method. This method is triggered when the record is updated, saved or created. Here is what I have so far:
product.rb (model)
class Product < ActiveRecord::Base
validate :single_product
# Detects if a product has more than one SKU when attempting to set the single product field as true
# The sku association needs to map an attribute block in order to count the number of records successfully
# The standard self.skus.count is performed using the record ID, which none of the SKUs currently have
#
# #return [boolean]
def single_product
if self.single && self.skus.map { |s| s.active }.count > 1
errors.add(:single, " product cannot be set if the product has more than one SKU.")
return false
end
end
end
products.rb (FactoryGirl test data)
FactoryGirl.define do
factory :product do
sequence(:name) { |n| "#{Faker::Lorem.word}#{Faker::Lorem.characters(8)}#{n}" }
meta_description { Faker::Lorem.characters(10) }
short_description { Faker::Lorem.characters(15) }
description { Faker::Lorem.characters(20) }
sku { Faker::Lorem.characters(5) }
sequence(:part_number) { |n| "GA#{n}" }
featured false
active false
sequence(:weighting) { |n| n }
single false
association :category
factory :product_skus do
after(:build) do |product, evaluator|
build_list(:sku, 3, product: product)
end
end
end
end
product_spec.rb (unit test)
require 'spec_helper'
describe Product do
describe "Setting a product as a single product" do
let!(:product) { build(:product_skus, single: true) }
context "when the product has more than one SKU" do
it "should raise an error" do
expect(product).to have(1).errors_on(:single)
end
end
end
end
As you can see from the singe_product method, I'm trying to trigger an error on the single attribute when the single attribute is set to true and the product has more than one associated SKU. However, when running the test the product has no associated SKUs and therefore fails the unit test shown above.
How do I build a record and generate associated SKUs which can be counted (e.g: product.skus.count) and validated before they are all created in FactoryGirl?
You could write this like
it 'should raise an error' do
product = build(:product_skus, single: true)
expect(product).not_to be_valid
end
I need to create an enumeration that I will need to initialize from a value from the querystring.
Example of what I have and what I need to do:
class UserType
NONE = 0
MEMBER = 1
ADMIN = 2
SUPER = 3
end
Now in my querystring I will have:
/users/load_by_type?type=2
Now in my controller I will get the value 2 from the querystring, I then need to have a UserType object which has the value 'MEMBER'.
How can I do this?
If my class isn't really a good enumeration hack, please advise.
How about something like this.
require 'active_record'
# set up db
ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ':memory:'
# define schema
ActiveRecord::Schema.define do
suppress_messages do
create_table :users do |t|
t.string :name
t.string :role
end
end
end
# define class
class User < ActiveRecord::Base
Roles = %w[none member admin super].map(&:freeze)
validates_inclusion_of :role, in: Roles
end
# specification
describe User do
before { User.delete_all }
let(:valid_role) { User::Roles.first }
let(:invalid_role) { valid_role.reverse }
it 'is valid if its role is in the Roles list' do
User.new.should_not be_valid
User.new(role: valid_role).should be_valid
User.new(role: invalid_role).should_not be_valid
end
let(:role) { User::Roles.first }
let(:other_role) { User::Roles.last }
it 'can find users by role' do
user_with_role = User.create! role: role
user_with_other_role = User.create! role: other_role
User.find_all_by_role(role).should == [user_with_role]
end
end
It does have the disadvantage of using an entire string (255 chars) for the enumeration method, but it also has the advantage of readability and ease of use (it would probably come in as "/users/load_by_role?role=admin"). Besides, if at some point it winds up costing too much, it should be easy to update to use a small integer.
I think I'd rather use hashes for this kind of thing, but just for fun:
class Foo
BAR = 1
STAN = 2
class << self
def [](digit)
constants.find { |const| const_get(const) == digit }
end
end
end
puts Foo[1] # BAR
puts Foo[2] # STAN