How do I dynamically set object attributes from a YAML file? - ruby

I have a YAML file which maps a bunch of properties to a class. I'd like to be able to loop through all of the properties from my YAML file and set them on an object dynamically.
How can I do this?
Here's the gist of what I have so far:
YAML contents:
my_obj:
:username: 'myuser'
:password: 'mypass'
...
Ruby:
settings = YAML::load_file SETTINGS_FILE
settings = settings['my_obj']
settings.each do |s|
#psuedo-code
#example: my_obj.username = settings[:username] if my_obj.has_property?(:username)
#my_obj.[s] = settings[:s] if my_obj.has_property?(:s)
end
I'm not sure if doing this is necessarily a best practice, but there are a lot of properties and I thought this would be a cleaner way than manually setting each property directly.

Depending on what you actually need, you may be able to use Ruby’s default YAML dump and load behaviour. When dumping an arbitrary object to YAML, Psych (the Yaml parser/emitter in Ruby) will look at all the instance variables of the object and write out a Yaml mapping with them. For example:
class MyObj
def initialize(name, password)
#name = name
#password = password
end
end
my_obj = MyObj.new('myuser', 'mypass')
puts YAML.dump my_obj
will print out:
--- !ruby/object:MyObj
name: myuser
password: mypass
You can then load this back with YAML.load and you will get an instance of MyObj with the same instance variables as your original.
You can override or customise this behaviour by implementing encode_with or init_with methods on your class.

As per your sample yaml file content,you would get the below Hash.
require 'yaml'
str = <<-end
my_obj:
:username: 'myuser'
:password: 'mypass'
end
settings = YAML.load(str)
# => {"my_obj"=>{:username=>"myuser", :password=>"mypass"}}
settings['my_obj'][:username] # => "myuser"
settings['my_obj'][:password] # => "mypass"

You'll see this used a lot in Ruby gems and Rails. Here's a dummy class:
require 'yaml'
class MyClass
attr_accessor :username, :password
def initialize(params)
#username, #password = params.values_at(*[:username, :password])
end
end
We can load the data in using YAML's load_file:
data = YAML.load_file('test.yaml') # => {"my_obj"=>{:username=>"myuser", :password=>"mypass"}}
Passing the structure just read, which in this case is a hash, and which, oddly enough, or maybe even magically, is what the class initializer takes, creates an instance of the class:
my_class = MyClass.new(data['my_obj'])
We can see the instance variables are initialized correctly from the contents of the YAML file:
my_class.username # => "myuser"
my_class.password # => "mypass"

This appears to be working. It allows me to set attributes on my_obj dynamically from the YAML settings hash.
settings.each do |key, value|
my_obj.instance_variable_set("##{key}", value) if my_obj.method_defined?("#{key}")
end
my_obj has attr_accessor :username, :password, etc.
The snippet above appears to let me set these attributes dynamically using instance_variable_set, while also ensuring that my_obj has the attribute I'm trying to assign with method_defined?.

Related

Ruby Access Class Variables Easily

I've created a class that I'm using to store configuration data. Currently the class looks like this:
class Configed
##username = "test#gmail.com"
##password = "password"
##startpage = "http://www.example.com/login"
##nextpage = "http://www.example.com/product"
##loginfield = "username"
##passwordfield = "password"
##parser = "button"
##testpage = "http://www.example.com/product/1"
##button1 = "button1"
def self.username
##username
end
def self.password
##password
end
def self.startpage
##startpage
end
def self.nextpage
##nextpage
end
def self.loginfield
##loginfield
end
def self.passwordfield
##passwordfield
end
def self.parser
##parser
end
def self.testpage
##testpage
end
def self.button1
##button1
end
end
To access the variables I'm using:
# Config file
require_relative 'Configed'
# Parse config
startpage = Configed.startpage
loginfield = Configed.loginfield
passwordfield = Configed.passwordfield
username = Configed.username
password = Configed.password
nextpage = Configed.nextpage
parser = Configed.parser
testpage = Configed.testpage
This is not very modular. Adding additional configuration data needs to be referenced in three places.
Is there a better way of accomplishing this?
You can make class level instance variables...
class Configed
#username = "test#gmail.com"
#password = "password"
#startpage = "http://www.example.com/login"
# ...
class << self
attr_reader :username, :password, :startpage # ...
end
end
It's somewhat more compact, and still gives you
username = Configed.username
# ...
NOTE: there's a lot of good ideas in #philomory 's answer that deserves consideration. The use of YAML in particular would allow you to set up different constants for different environemnts test, development, production etc, and you can load the current environment's configuration options into an OpenStruct created in an initializer. Makes for a more flexible solution.
There are a lot of potential improvements. First of all, no reason to use class variables if you don't want their weird specific inheritance-related behavior, and no reason to use a class at all if you're not going to instantiate it.
You could use a module:
module Configed
module_function
def username
'username'
end
# etc
end
Configed.username
But frankly, you're almost certainly better off using a hash:
Config = {
username: 'username'
# etc
}.freeze
Config[:username]
or, if you prefer method-style access, an OpenStruct:
require 'openstruct' # from standard library
Config = OpenStruct.new(
username: 'username'
# etc
).freeze
Config.username
If they need to be modifiable, just don't freeze them. Also, typically a constant which is not a class or a module (such as a hash) would have a name in ALL_CAPS, e.g. CONFIGED, but, that's a stylistic decision with no actual impact on the code.
Your question refers to 'parsing' the config, but of course, you're not; the config data in your setup (and in my examples so far) is just Ruby code. If you'd rather load it from a non-code file, there's always YAML:
config.yaml:
username: username
password: password
config.rb:
require 'yaml' # from Standard Library
Configed = YAML.load_file('config.yaml')
Configed['username']
or JSON:
config.json:
{
"username": "username",
"password": "password"
}
config.rb:
require 'json' # from Standard Library
Configed = JSON.parse(File.read('config.json'))
Configed['username']

data_mapper, attr_accessor, & serialization only serializing properties not attr_accessor attributes

I'm using data_mapper/sinatra and trying to create some attributes with attr_accessor. The following example code:
require 'json'
class Person
include DataMapper::Resource
property :id, Serial
property :first_name, String
attr_accessor :last_name
end
ps = Person.new
ps.first_name = "Mike"
ps.last_name = "Smith"
p ps.to_json
produces this output:
"{\"id\":null,\"first_name\":\"Mike\"}"
Obviously I would like for it to give me both the first and last name attributes. Any ideas on how to get this to work in the way one would expect so that my json has all of the attributes?
Also, feel free to also explain why my expectation (that I'd get all of the attributes) is incorrect. I'm guessing some internal list of attributes isn't getting the attr_accessor instance variables added to it or something. But even so, why?
Datamapper has it’s own serialization library, dm-serializer, that provides a to_json method for any Datamapper resource. If you require Datamapper with require 'data_mapper' in your code, you are using the data_mapper meta-gem that requires dm-serializer as part of it’s set up.
The to_json method provided by dm-serializer only serializes the Datamapper properties of your object (i.e. those you’ve specified with property) and not the “normal” properties (that you’ve defined with attr_accessor). This is why you get id and first_name but not last_name.
In order to avoid using dm-serializer you need to explicitly require those libraries you need, rather than rely on data_mapper. You will need at least dm-core and maybe others.
The “normal” json library doesn’t include any attributes in the default to_json call on an object, it just uses the objects to_s method. So in this case, if you replace require 'data_mapper' with require 'dm-core', you will get something like "\"#<Person:0x000001013a0320>\"".
To create json representations of your own objects you need to create your own to_json method. A simple example would be to just hard code the attributes you want in the json:
def to_json
{:id => id, :first_name => first_name, :last_name => last_name}.to_json
end
You could create a method that looks at the attributes and properties of the object and create the appropriate json from that instead of hardcoding them this way.
Note that if you create your own to_json method you could still call require 'data_mapper', your to_json will replace the one provided by dm-serializer. In fact dm-serializer also adds an as_json method that you could use to create the combined to_json method, e.g.:
def to_json
as_json.merge({:last_name => last_name}).to_json
end
Thanks to Matt I did some digging and found the :method param for dm-serializer's to_json method. Their to_json method was pretty decent and was basically just a wrapper for an as_json helper method so I overwrote it by just adding a few lines:
if options[:include_attributes]
options[:methods] = [] if options[:methods].nil?
options[:methods].concat(model.attributes).uniq!
end
The completed method override looks like:
module DataMapper
module Serializer
def to_json(*args)
options = args.first
options = {} unless options.kind_of?(Hash)
if options[:include_attributes]
options[:methods] = [] if options[:methods].nil?
options[:methods].concat(model.attributes).uniq!
end
result = as_json(options)
# default to making JSON
if options.fetch(:to_json, true)
MultiJson.dump(result)
else
result
end
end
end
end
This works along with an attributes method I added to a base module I use with my models. The relevant section is below:
module Base
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def attr_accessor(*vars)
#attributes ||= []
#attributes.concat vars
super(*vars)
end
def attributes
#attributes || []
end
end
def attributes
self.class.attributes
end
end
now my original example:
require 'json'
class Person
include DataMapper::Resource
include Base
property :id, Serial
property :first_name, String
attr_accessor :last_name
end
ps = Person.new
ps.first_name = "Mike"
ps.last_name = "Smith"
p ps.to_json :include_attributes => true
Works as expected, with the new option parameter.
What I could have done to selectively get the attributes I wanted without having to do the extra work was to just pass the attribute names into the :methods param.
p ps.to_json :methods => [:last_name]
Or, since I already had my Base class:
p ps.to_json :methods => Person.attributes
Now I just need to figure out how I want to support collections.

Ruby not allowing dynamic strings as an argument

I have a class already mapped out and in a database through DataMapper and now I'm trying to make my first resource into the database.
I have a class that handles the form data and file stuff. In that class, I'm creating the first resource with #variables passed in from the params. All other args passed into this resource come from #variables that have values from the form. In this case, #url, the variable in question, is set to a value only a few lines before. Now when I put in the URL:
rec = Post.new(
# more args
:filename_ogg => #url
)
rec.save
This is the killer: Every other line of code in this file is able to access #url, through a global variable ($upload = Upload.new(file)), except for this resource creator. When it comes to saving the resource, it doesn't go through. BUT, when I replace #url with a static string like "RANDOM URL.", it works perfectly. Why?
This had been tested under both MRI 1.9.3 and JRuby 1.6.7.2 (1.9 mode) under Ubuntu 12.04:
# #{user} edited out
class Upload
attr_accessor :file, :filename, :filename_ogg, :status, :title, :desc, :url
def initialize(file)
#file = file
#filename = #file[:filename].gsub(" ", "")
#filename_ogg = "#{#filename}.ogg"
##url = "http://s3.amazonaws.com/#{user}/#{#filename_ogg}"
end
def downandup
# code
end
def convert(file, file_ogg)
# code
end
def upload(file_ogg)
# code
#url = "http://s3.amazonaws.com/#{user}/#{file_ogg}"
# title and desc are accessed through $upload.title/$upload.desc
rec = Post.new(
:title => #title,
:description => #desc,
:author_id => Random.rand(5),
:time_uploaded => Time.now,
:filename_ogg => #url,
:comments_table => Random.rand(10),
)
rec.save
end
end
The file runs through fine, but when it comes for DataMapper to put it in the database, it won't go in, but when replaced with the static string, the data gets stored.

Active Record to_json\as_json on Array of Models

First off, I am not using Rails. I am using Sinatra for this project with Active Record.
I want to be able to override either to_json or as_json on my Model class and have it define some 'default' options. For example I have the following:
class Vendor < ActiveRecord::Base
def to_json(options = {})
if options.empty?
super :only => [:id, :name]
else
super options
end
end
end
where Vendor has more attributes than just id and name. In my route I have something like the following:
#vendors = Vendor.where({})
#vendors.to_json
Here #vendors is an Array vendor objects (obviously). The returned json is, however, not invoking my to_json method and is returning all of the models attributes.
I don't really have the option of modifying the route because I am actually using a modified sinatra-rest gem (http://github.com/mikeycgto/sinatra-rest).
Any ideas on how to achieve this functionality? I could do something like the following in my sinatra-rest gem but this seems silly:
#PLURAL.collect! { |obj| obj.to_json }
Try overriding serializable_hash intead:
def serializable_hash(options = nil)
{ :id => id, :name => name }
end
More information here.
If you override as_json instead of to_json, each element in the array will format with as_json before the array is converted to JSON
I'm using the following to only expose only accessible attributes:
def as_json(options = {})
options[:only] ||= self.class.accessible_attributes.to_a
super(options)
end

Sinatra Variable Scope

Take the following code:
### Dependencies
require 'rubygems'
require 'sinatra'
require 'datamapper'
### Configuration
config = YAML::load(File.read('config.yml'))
name = config['config']['name']
description = config['config']['description']
username = config['config']['username']
password = config['config']['password']
theme = config['config']['theme']
set :public, 'views/themes/#{theme}/static'
### Models
DataMapper.setup(:default, "sqlite3://#{Dir.pwd}/marvin.db")
class Post
include DataMapper::Resource
property :id, Serial
property :name, String
property :body, Text
property :created_at, DateTime
property :slug, String
end
class Page
include DataMapper::Resource
property :id, Serial
property :name, String
property :body, Text
property :slug, String
end
DataMapper.auto_migrate!
### Controllers
get '/' do
#posts = Post.get(:order => [ :id_desc ])
haml :"themes/#{theme}/index"
end
get '/:year/:month/:day/:slug' do
year = params[:year]
month = params[:month]
day = params[:day]
slug = params[:slug]
haml :"themes/#{theme}/post.haml"
end
get '/:slug' do
haml :"themes/#{theme}/page.haml"
end
get '/admin' do
haml :"admin/index.haml"
end
I want to make name, and all those variables available to the entire script, as well as the views. I tried making them global variables, but no dice.
Might not be the "cleanest" way to do it, but setting them as options should work:
--> http://www.sinatrarb.com/configuration.html :)
setting:
set :foo, 'bar'
getting:
"foo is set to " + settings.foo
Make them constants. They should be anyway shouldn't they? They're not going to change.
Make a constant by writing it in all caps.
Read this article on Ruby Variable Scopes if you have any more issues.
http://www.techotopia.com/index.php/Ruby_Variable_Scope
Another clean option may be a config class, where the init method loads the YAML and then sets up the variables.
Have fun. #reply me when you've finished your new blog (I'm guessing this is what this is for).
From the Sinatra README:
Accessing Variables in Templates
Templates are evaluated within the same context as route handlers. Instance variables set in route handlers are direcly accessible by templates:
get '/:id' do
#foo = Foo.find(params[:id])
haml '%h1= #foo.name'
end
Or, specify an explicit Hash of local variables:
get '/:id' do
foo = Foo.find(params[:id])
haml '%h1= foo.name', :locals => { :foo => foo }
end
This is typically used when rendering templates as partials from within other templates.
A third option would be to set up accessors for them as helper methods. (Which are also available throughout the application and views.)
what also works:
##foo = "bar"
But don't forget to restart the server after this change

Resources