lib/rep.rb in rep-0.0.1 vs lib/rep.rb in rep-0.0.2
- old
+ new
@@ -1,40 +1,86 @@
-require "rep/version"
+# **Rep** is a small module to endow any class to make json quickly. It solves four problems:
+#
+# 1. Enumerating top level keys for a json structure
+# 2. Providing a convention for the value of those keys
+# 3. Defining `attr_accessor`'s that are prefilled from an options hash given to `#initialize`
+# 4. Sharing instances to help GC
+#
+# The code is available on [github](http://github.com/myobie/rep).
+
+# `Forwardable` is in the stdlib and allows ruby objects to delegate methods off to other objects. An example:
+#
+# class A
+# extend Forwardable
+# delegate [:length, :first] => :@array
+# def initialize(array = [])
+# @array = array
+# end
+# end
+#
+# A.new([1,2,3]).length # => 3
+# A.new([1,2,3]).first # => 1
+
require 'forwardable'
+
+# `JSON::generate` and `JSON::decode` are much safer to use than `Object#to_json`.
+
require 'json'
+require 'rep/version'
module Rep
+
+ # All classes that `include Rep` are extended with `Forwardable`,
+ # given some aliases, endowned with `HashieSupport` if Hashie is loaded,
+ # and given a delegate method if it doesn't already have one.
+
def self.included(klass)
klass.extend Forwardable
klass.extend ClassMethods
klass.instance_eval {
class << self
+ unless defined?(delegate)
+ def delegate(opts = {})
+ methods, object_name = opts.to_a.first
+ args = [object_name, methods].flatten
+ def_delegators *args
+ end
+ end
+
alias forward delegate
unless defined?(fields)
alias fields json_fields
end
if defined?(Hashie)
include HashieSupport
end
end
-
- unless defined?(parse_opts)
- def parse_opts(opts = {})
- # NOOP
- end
- end
}
end
+ # Since a goal is to be able to share instances, we need an easy way to reset a
+ # shared instance back to factory defaults. If you memoize any methods that are
+ # not declared as json fields, then overried this method and set any memoized
+ # variables to nil, then super.
+
def reset_for_json!
self.class.all_json_methods.each do |method_name|
instance_variable_set(:"@#{method_name}", nil)
end
end
+ # All the work of generating a hash from an instance is packaged up in one method. Since
+ # fields can be aliases in the format `{ :json_key_name => :method_name }`, there
+ # is some fancy logic to determine the `field_name` and `method_name` variables.
+ #
+ # { :one => :foo }.to_a # => [[:one, :foo]]
+ #
+ # Right now it will raise if either a field doesn't have a method to provide it's value or
+ # if there are no json fields setup for the particular set (which defaults to `:default`).
+
def to_hash(name = :default)
if fields = self.class.json_fields(name)
fields.reduce({}) do |memo, field|
field_name, method_name = field.is_a?(Hash) ? field.to_a.first : [field, field]
begin
@@ -53,10 +99,19 @@
def to_json
JSON.generate(to_hash)
end
module ClassMethods
+
+ # Defines an attr_accessor with a default value. The default for default is nil. Example:
+ #
+ # class A
+ # register_accessor :name => "No Name"
+ # end
+ #
+ # A.new.name # => "No Name"
+
def register_accessor(acc)
name, default = acc.is_a?(Hash) ? acc.to_a.first : [acc, nil]
attr_accessor name
if default
define_method name do
@@ -64,30 +119,75 @@
instance_variable_get(var_name) || instance_variable_set(var_name, default)
end
end
end
+ # Defines an `#initialize` method that accepts a Hash argument and copies some keys out into `attr_accessors`.
+ # If your class already has an `#iniatialize` method then this will overwrite it (so don't use it). `#initialize_with`
+ # does not have to be used to use any other parts of Rep.
+
def initialize_with(*args)
@initializiation_args = args
- define_singleton_method :initializiation_args do
- @initializiation_args
+ # Remember what args we normally initialize with so we can refer to them when building shared instances.
+
+ if defined?(define_singleton_method)
+ define_singleton_method :initializiation_args do
+ @initializiation_args
+ end
+ else
+ singleton = class << self; self end
+ singleton.send :define_method, :initializiation_args, lambda { @initializiation_args }
end
+ # Create an `attr_accessor` for each one. Defaults can be provided using the Hash version { :arg => :default_value }
+
args.each { |a| register_accessor(a) }
- define_method(:initialize) { |opts = {}| parse_opts(opts) }
+ define_method(:initialize) { |*args|
+ opts = args.first || {}
+ parse_opts(opts)
+ }
+ # `#parse_opts` is responsable for getting the `attr_accessor` values prefilled. Since defaults can be specified, it
+ # must negotiate Hashes and use the first key of the hash for the `attr_accessor`'s name.
+
define_method :parse_opts do |opts|
- @presidential_options = opts
+ @rep_options = opts
self.class.initializiation_args.each do |field|
name = field.is_a?(Hash) ? field.to_a.first.first : field
instance_variable_set(:"@#{name}", opts[name])
end
end
end
+ # `#json_fields` setups up some class instance variables to remember sets of top level keys for json structures. Example:
+ #
+ # class A
+ # json_fields [:one, :two, :three] => :default
+ # end
+ #
+ # A.json_fields(:default) # => [:one, :two, :three]
+ #
+ # There is a general assumption that each top level key's value is provided by a method of the same name on an instance
+ # of the class. If this is not true, a Hash syntax can be used to alias to a different method name. Example:
+ #
+ # class A
+ # json_fields [{ :one => :the_real_one_method }, :two, { :three => :some_other_three }] => :default
+ # end
+ #
+ # Once can also set multiple sets of fields. Example:
+ #
+ # class A
+ # json_fields [:one, :two, :three] => :default
+ # json_fields [:five, :two, :six] => :other
+ # end
+ #
+ # And all fields are returned by calling `#json_fields` with no args. Example:
+ #
+ # A.json_fields # => { :default => [:one, :two, :three], :other => [:five, :two, :six] }
+
def json_fields(arg = nil)
if arg.is_a?(Hash)
fields, name = arg.to_a.first
@json_fields ||= {}
@json_fields[name] = [fields].flatten
@@ -100,10 +200,13 @@
# TODO: make an exception class
raise "You can only use a Hash to set fields, a Symbol to retrieve them, or no argument to retrieve all fields for all names"
end
end
+ # `#flat_json_fields` is just a utility method to DRY up the next two methods, because their code is almost exactly the same,
+ # it is not intended for use directly and might be confusing.
+
def flat_json_fields(side = :right)
side_number = side == :right ? 1 : 0
json_fields.reduce([]) do |memo, (name, fields)|
memo + fields.map do |field|
@@ -114,24 +217,85 @@
end
end
end.uniq
end
+ # We need a way to get a flat, uniq'ed list of all the fields accross all field sets. This is that.
+
def all_json_fields
flat_json_fields(:left)
end
+ # We need a wya to get a flat, uniq'ed list of all the method names accross all field sets. This is that.
+
def all_json_methods
flat_json_fields(:right)
end
- # TODO: thread safety
+ # An easy way to save on GC is to use the same instance to turn an array of objects into hashes instead
+ # of instantiating a new object for every object in the array. Here is an example of it's usage:
+ #
+ # class BookRep
+ # initialize_with :book_model
+ # fields :title => :default
+ # forward :title => :book_model
+ # end
+ #
+ # BookRep.shared(:book_model => Book.first).to_hash # => { :title => "Moby Dick" }
+ # BookRep.shared(:book_model => Book.last).to_hash # => { :title => "Lost Horizon" }
+ #
+ # This should terrify you. If it doesn't, then this example will:
+ #
+ # book1 = BookRep.shared(:book_model => Book.first)
+ # book2 = BookRep.shared(:book_model => Book.last)
+ #
+ # boo1.object_id === book2.object_id # => true
+ #
+ # **It really is a shared object.**
+ #
+ # You really shouldn't use this method directly for anything.
+
def shared(opts = {})
- @instance ||= new
- @instance.reset_for_json!
- @instance.parse_opts(opts)
- @instance
+ @pointer = (Thread.current[:rep_shared_instances] ||= {})
+ @pointer[object_id] ||= new
+ @pointer[object_id].reset_for_json!
+ @pointer[object_id].parse_opts(opts)
+ @pointer[object_id]
end
+
+ # The fanciest thing in this entire library is this `#to_proc` method. Here is an example of it's usage:
+ #
+ # class BookRep
+ # initialize_with :book_model
+ # fields :title => :default
+ # forward :title => :book_model
+ # end
+ #
+ # Book.all.map(&BookRep) # => [{ :title => "Moby Dick" }, { :title => "Lost Horizon " }]
+ #
+ # And now I will explain how it works. Any object can have a to_proc method and when you call `#map` on an
+ # array and hand it a proc it will in turn hand each object as an argument to that proc. What I've decided
+ # to do with this object is use it the options for a shared instance to make a hash.
+ #
+ # Since I know the different initialization argumants from a call to `initialize_with`, I can infer by order
+ # which object is which option. Then I can create a Hash to give to `parse_opts` through the `shared` method.
+ # I hope that makes sense.
+ #
+ # It allows for extremely clean Rails controllers like this:
+ #
+ # class PhotosController < ApplicationController
+ # respond_to :json, :html
+ #
+ # def index
+ # @photos = Photo.paginate(page: params[:page], per_page: 20)
+ # respond_with @photos.map(&PhotoRep)
+ # end
+ #
+ # def show
+ # @photo = Photo.find(params[:id])
+ # respond_with PhotoRep.new(photo: @photo)
+ # end
+ # end
def to_proc
proc { |obj|
arr = [obj].flatten
init_args = @initializiation_args[0..(arr.length-1)]