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)]