Is there a way to automatically parse string parameters representing dates in Rails? Or, some convention or clever way?
Doing the parsing manually by just doing DateTime.parse(..) in controllers, even if it's in a callback doesn't look very elegant.
There's also another case I'm unsure how to handle: If a date field in a model is nullable, I would like to return an error if the string I receive is not correct (say: the user submits 201/801/01). This also has to be done in the controller and I don't find a clever way to verify that on the model as a validation.
If you're using ActiveRecord to back your model, your dates and time fields are automatically parsed before being inserted.
# migration
class CreateMydates < ActiveRecord::Migration[5.2]
def change
create_table :mydates do |t|
t.date :birthday
t.timestamps
end
end
end
# irb
irb(main):003:0> m = Mydate.new(birthday: '2018-01-09')
=> #<Mydate id: nil, birthday: "2018-01-09", created_at: nil, updated_at: nil>
irb(main):004:0> m.save
=> true
irb(main):005:0> m.reload
irb(main):006:0> m.birthday
=> Tue, 09 Jan 2018
So it comes down to validating the date format, which you can do manually with a regex, or you can call Date.parse and check for an exception:
class Mydate < ApplicationRecord
validate :check_date_format
def check_date_format
begin
Date.parse(birthday)
rescue => e
errors.add(:birthday, "Bad Date Format")
end
end
end
Related
I'm struggling to make my model callback functions behave properly :(
Using Rails 5.2.4.1, Ruby 2.6.3 and pg ~> 0.21
I have a model "Batch" that I want to have automatically calculate and update its own "Value" attribute once its "Price" and "Quantity" values are greater than zero.
def change
create_table :batches do |t|
t.references :product, foreign_key: true, null: false
t.references :currency, foreign_key: true
t.string :batch_number, null: false
t.string :status, null: false, default: "pending"
t.integer :quantity, null: false, default: 0
t.integer :price, null: false, default: 0
t.integer :value, null: false, default: 0
t.timestamps
end
end
end
In my seed file I create some Batch instances with specified Quantity and then Price, and leave the value to default to 0 (this is to be added later when creating an Order instance):
batch1 = Batch.new(
product_id: Product.last.id,
batch_number: "0001-0001-0001",
quantity: 1800
)
if batch1.valid?
batch1.save
p batch1
else
p first_batch1.errors.messages
end
batch1.price = 3
batch1.save
Then my troubles begin...
I've tried a few approaches similar to the below:
after_find :calculate_value
def calculate_value
self.value = price * quantity if value != price * quantity
end
I'm not sure if I'm missing something very obvious here, but the value never seems to update.
I've tried adding save into the method but it doesn't seem to work either. I'm finding some of the other behaviours with saving in these callbacks very strange.
For example, I assign a Currency to a Batch through a join table with this instance method:
after_find :assign_currency
def assign_currency
self.currency_id = currency.id unless currency.nil?
# save
end
If I uncomment that "save" (or make it "self.save") then the seed file creates the Batches but then fails to create the join table, returning {:batch=>["must exist"]}. Yet in the console, the batch does:
[#<Batch:0x00007fb874ad0aa0
id: 1,
product_id: 1,
batch_number: "0001-0001-0001",
status: "pending",
quantity: 1800,
currency_id: nil,
price: 0,
value: 0,
created_at: Thu, 09 Jan 2020 00:38:42 UTC +00:00,
updated_at: Thu, 09 Jan 2020 00:38:42 UTC +00:00>,
I'm still new to rails so would be very grateful for any advice or suggestions whatsoever! This feels like it should be simple and it's driving me crazy...
I'd recommend using a before_validation callback instead of an after_find. The reason I'd recommend that is because in an after_find, the value column will be populated only when the object is loaded using finder (.find, .find_or_create), and hence, the you would not be able to validate the value column before saving. In fact, during initial save, the value column will be empty.
The order of execution of callbacks is as follows:
before_validation
after_validation
before_save
around_save
before_create
around_create
after_create
after_save
after_commit/after_rollback
So, in your case, this could work:
before_validation :calculate_value, if: :price_or_quantity_changed?
validates :value, presence: true # This can be added because the before_validation callback will ensure that value is present
def calculate_value
self.value = price * quantity if value != price * quantity
end
private
def price_or_quantity_changed?
self.price_changed? || self.quantity_changed?
end
_changed? methods are from the ActiveModel::Dirty module which helps us keep track of values that have changed in a record.
However, if you would want to use after_find, I think this StackOverflow answer may help you understand it more.
You could use after_find after defining it as a method as follows:
def after_find
<your code to set value>
end
after_initialize & after_find callbacks order in Active Record object life cycle?
how about this in your model?
after_find :calculate_value
def calculate_value
self.value = self.price * self.quantity if self.value != self.price * self.quantity
end
I have a failing test for a GET request — the issue lies with the way ActiveRecord's timestamps are formatted in the response. The RSpec test expects the data format to be Mon, 29 Dec 2014 13:37:09 UTC +00:00,, but it instead receives 2014-12-29T13:37:09Z. Is there any way to change the formatting of the ActiveRecord timestamp, or what the RSpec test expects?
Edit:
Here's the failing test:
describe 'GET /v1/tasks/:id' do
it 'returns an task by :id' do
task = create(:task)
get "/v1/tasks/#{task.id}"
expect(response_json).to eq(
{
'id' => task.id,
'title' => task.title,
'note' => task.note,
'due_date' => task.due_date,
'created_at' => task.created_at,
'updated_at' => task.updated_at
}
)
end
end
The test fails on created_at and updated_at. response_json is a helper method that parses the JSON response.
Here's where the test fails:
expected: {"id"=>1, "title"=>"MyString", "note"=>"MyText", "due_date"=>Mon, 29 Dec 2014, "created_at"=>Mon, 29 Dec 2014 13:37:09 UTC +00:00, "updated_at"=>Mon, 29 Dec 2014 13:37:09 UTC +00:00}
got: {"id"=>1, "title"=>"MyString", "note"=>"MyText", "due_date"=>"2014-12-29", "created_at"=>"2014-12-29T13:37:09Z", "updated_at"=>"2014-12-29T13:37:09Z"}
The solution was embarrassing simple — I had specify that the dates should be formatted using ISO 8601 using the to_formatted_s() method. Below is the correct, passing version:
require 'spec_helper'
describe 'GET /v1/tasks/:id' do
it 'returns an task by :id' do
task = create(:task)
get "/v1/tasks/#{task.id}"
expect(response_json).to eq(
{
'id' => task.id,
'title' => task.title,
'note' => task.note,
'due_date' => task.due_date.to_formatted_s(:iso8601),
'created_at' => task.created_at.to_formatted_s(:iso8601),
'updated_at' => task.updated_at.to_formatted_s(:iso8601)
}
)
end
end
The problem is that the date columns are being 'jsonified' in a format that you may or may not have specified. This value is of a different format and a string; therefore matching it with a date value fails.
To resolve this you could either adjust how the created_at value is 'jsonified' (format it) or you could just do this in your test:
columns = %w{id title note due_date created_at updated_at}
# get task as json string:
task_json_string = task.to_json(only: columns)
# turn json into hash
task_json = ActiveSupport::JSON.decode(task_json_string)
json_attributes = task_json.values.first
expected_hash = columns.each_with_object({}) do |attr, hash|
# ensuring that the order of keys is as specified by 'columns'
hash[attr] = json_attributes[attr]
end
expect(response_json).to eq expected_hash
That should work (minor debugging might be required though).
To go the former route, add an instance method like the following to your Task model:
def as_json( *args )
super(*args).tap do |hash|
attributes_hash = hash.values.first
%w{created_at updated_at}.each do |attr|
if attributes_hash.has_key?(attr)
# Use strftime time instead of to_s to specify a format:
attributes_hash[attr] = self.send(attr).to_s
end
end
end
end
Then in your test your task json could simply be:
task_json = task.as_json.values.first
Or after adding the add_json method and keeping the to_s there, in your test all you will need to do is add a .to_s to your date method e.g. task.created_at.to_s; the values will match.
Thereby removing the object-to-json-to-hash step and doing object-to-hash directly.
The following Date and DateTime formatting should correct the problem.
{
'id' => task.id,
'title' => task.title,
'note' => task.note,
'due_date' => task.due_date.strftime("%Y-%m-%d %H:%M:%S %Z"),
'created_at' =>task.created_at.strftime("%Y-%m-%d %H:%M:%S %Z"),
'updated_at' =>task.updated_at.strftime("%Y-%m-%d %H:%M:%S %Z")
}
EDIT
Correction on date format.
As #Humza mentioned, the problem stems from how values are "jsonified".
tl;dr -- just change your spec to expect 'created_at' => task.created_at.as_json
The Long Version
In ActiveSupport (5.1.4), all values are run through ActiveSupport::JSON::Encoding#jsonify:
def jsonify(value)
case value
when String
EscapedString.new(value)
when Numeric, NilClass, TrueClass, FalseClass
value.as_json
when Hash
Hash[value.map { |k, v| [jsonify(k), jsonify(v)] }]
when Array
value.map { |v| jsonify(v) }
else
jsonify value.as_json
end
end
As you can see, there's no special case for ActiveSupport::TimeWithZone, it just calls ActiveSupport::TimeWithZone#as_json. So let's see what that looks like:
[1] pry(#<DataModelRowSerializer>)> show-source ActiveSupport::TimeWithZone#as_json
From: /Users/stevehull/.rbenv/versions/2.4.2/lib/ruby/gems/2.4.0/gems/activesupport-5.1.4/lib/active_support/time_with_zone.rb # line 165:
Owner: ActiveSupport::TimeWithZone
Visibility: public
Number of lines: 7
def as_json(options = nil)
if ActiveSupport::JSON::Encoding.use_standard_json_time_format
xmlschema(ActiveSupport::JSON::Encoding.time_precision)
else
%(#{time.strftime("%Y/%m/%d %H:%M:%S")} #{formatted_offset(false)})
end
end
So if you wanted to, you could expect 'created_at' => task.created_at.xmlschema(ActiveSupport::JSON::Encoding.time_precision), however it's much more compact to use as_json ;)
I have a standard model with a few fields that are saved to a DB, and I need 1 field that doesn't have to be saved.
I tried attr_accessor but that doesn't cover it. Using Attr_accessor I can set and get the field, but it is not part of the model. If I add the models to an array and then see what is in the virtual field is not part of it. I also tried to add the field :headerfield to attr_accessible but that didn't change anything.
How can I get a field that is part of the model but not saved to the database?
The model
class Mapping < ActiveRecord::Base
attr_accessible :internalfield, :sourcefield
attr_accessor :headerfield
end
console output:
1.9.3-p194 :001 > m = Mapping.new
=> #<Mapping id: nil, internalfield: nil, sourcefield: nil, created_at: nil, updated_at: nil, data_set_id: nil>
1.9.3-p194 :002 > m.headerfield = "asef"
=> "asef"
1.9.3-p194 :003 > m
=> #<Mapping id: nil, internalfield: nil, sourcefield: nil, created_at: nil, updated_at: nil, data_set_id: nil>
Because ActiveRecord::Base has custom implementations for the standard serializiation methods (including to_s and as_json), you will never see your model attributes that do not have backing database columns unless you intervene in some way.
You can render it to JSON using the following:
render json: my_object, methods: [:virtual_attr1, :virtual_attr2]
Or you can use the as_json serializer directly:
my_object.as_json(methods: [:virtual_attr1, :virtual_attr2])
The return you see in the console is nothing else but the value of to_s. For this case, code should be better than natural language, take a look in the following code and see if you understand
class A
end
=> nil
A.new
=> #<A:0xb73d1528>
A.new.to_s
=> "#<A:0xb73d1528>"
class A
def to_s
"foobar"
end
end
=> nil
A.new
=> ble
A.new.to_s
=> "ble"
You can see this output because ActiveRecord::Base defines a method to_s that take into account only the attributes that are defined in the database, not the attr_accessor methods, maybe using the attributes call.
I have the following rails model:
class Product < ActiveRecord::Base
end
class CreateProducts < ActiveRecord::Migration
def self.up
create_table :products do |t|
t.decimal :price
t.timestamps
end
end
def self.down
drop_table :products
end
end
But when I do the following in the rails console:
ruby-1.9.2-p180 :001 > product = Product.new
=> #<Product id: nil, price: nil, created_at: nil, updated_at: nil>
ruby-1.9.2-p180 :002 > product.price = 'a'
=> "a"
ruby-1.9.2-p180 :003 > product.save
=> true
ruby-1.9.2-p180 :004 > p product
#<Product id: 2, price: #<BigDecimal:39959f0,'0.0',9(9)>, created_at: "2011-05-18 02:48:10", updated_at: "2011-05-18 02:48:10">
=> #<Product id: 2, price: #<BigDecimal:3994ca8,'0.0',9(9)>, created_at: "2011-05-18 02:48:10", updated_at: "2011-05-18 02:48:10">
As you can see, I wrote 'a' and it saved 0.0 in the database. Why is that? This is particularly annoying because it bypasses my validations e.g.:
class Product < ActiveRecord::Base
validates :price, :format => /\d\.\d/
end
anything that is invalid gets cast to 0.0 if you call to_f on it
"a".to_f #=> 0.0
you would need to check it with validations in the model
validates_numericality_of :price # at least in rails 2 i think
i dont know what validating by format does, so i cant help you there, but try to validate that it is a number, RegExs are only checked against strings, so if the database is a number field it might be messing up
:format is for stuff like email addresses, logins, names, etc to check for illegeal characters and such
You need to re-look at what is your real issue is. It is a feature of Rails that a string is auto-magically converted into either the appropriate decimal value or into 0.0 otherwise.
What's happening
1) You can store anything into an ActiveRecord field. It is then converted into the appropriate type for database.
>> product.price = "a"
=> "a"
>> product.price
=> #<BigDecimal:b63f3188,'0.0',4(4)>
>> product.price.to_s
=> "0.0"
2) You should use the correct validation to make sure that only valid data is stored. Is there anything wrong with storing the value 0? If not, then you don't need a validation.
3) You don't have to validate that a number will be stored in the database. Since you declared the db field to be a decimal field, it will ONLY hold decimals (or null if you let the field have null values).
4) Your validation was a string-oriented validation. So the validation regexp changed the 0.0 BigDecimal into "0.0" and it passed your validation. Why do you think that your validation was bypassed?
5) Why, exactly, are you worried about other programmers storing strings into your price field?
Are you trying to avoid products being set to zero price by mistake? There are a couple of ways around that. You could check the value as it comes in (before it is converted to a decimal) to see if its format is right. See AR Section "Overwriting default accessors"
But I think that would be messy and error prone. You'd have to set the record's Error obj from a Setter, or use a flag. And simple class checking wouldn't work, remember that form data always comes in as a string.
Recommended Instead, make the user confirm that they meant to set the price to 0 for the product by using an additional AR-only field (a field that is not stored in the dbms).
Eg
attr_accessor :confirm_zero_price
# Validate that when the record is created, the price
# is either > 0 or (price is <= 0 && confirm_zero_price)
validates_numericality_of :price, :greater_than => 0,
:unless => Proc.new { |s| s.confirm_zero_price},
:on => :create
Notes The above is the sort of thing that is VERY important to include in your tests.
Also I've had similar situations in the past. As a result of my experiences, I now record, in the database, the name of the person who said that the value should indeed be $0 (or negative) and let them have a 255 char reason field for their justification. Saves a lot of time later on when people are wondering what was the reason.
I have form fields where the user enters in:
percents: 50.5%
money: $144.99
dates: Wednesday, Jan 12th, 2010
...
The percent and money type attributes are saved as decimal fields with ActiveRecord, and the dates are datetime or date fields.
It's easy to convert between formats in javascript, and you could theoretically convert them to the activerecord acceptable format onsubmit, but that's not a decent solution.
I would like to do something override the accessors in ActiveRecord so when they are set it converts them from any string to the appropriate format, but that's not the best either.
What I don't want is to have to run them through a separate processor object, which would require something like this in a controller:
def create
# params == {:product => {:price => "$144.99", :date => "Wednesday, Jan 12, 2011", :percent => "12.9%"}}
formatted_params = Product.format_params(params[:product])
# format_params == {:product => {:price => 144.99, :date => Wed, 12 Jan 2011, :percent => 12.90}}
#product = Product.new(format_params)
#product.save
# ...
end
I would like for it to be completely transparent. Where is the hook in ActiveRecord so I can do this the Rails Way?
Update
I am just doing this for now: https://gist.github.com/727494
class Product < ActiveRecord::Base
format :price, :except => /\$/
end
product = Product.new(:price => "$199.99")
product.price #=> #<BigDecimal:10b001ef8,'0.19999E3',18(18)>
You could override the setter or getter.
Overriding the setter:
class Product < ActiveRecord::Base
def price=(price)
self[:price] = price.to_s.gsub(/[^0-9\.]/, '')
end
end
Overriding the getter:
class Product < ActiveRecord::Base
def price
self[:price].to_s.gsub(/[^0-9\.]/, ''))
end
end
The difference is that the latter method still stores anything the user entered, but retrieves it formatted, while the first one, stores the formatted version.
These methods will be used when you call Product.new(...) or update_attributes, etc...
You can use a before validation hook to normalize out your params such as before_validation
class Product < ActiveRecord::Base
before_validation :format_params
.....
def format_params
self.price = price.gsub(/[^0-9\.]/, "")
....
end
Use monetize gem for parsing numbers.
Example
Monetize.parse(val).amount