├── lib ├── kashmir │ ├── version.rb │ ├── inline_dsl.rb │ ├── extensions.rb │ ├── plugins │ │ ├── active_record_representation.rb │ │ ├── null_caching.rb │ │ ├── ar.rb │ │ ├── ar_relation.rb │ │ ├── memory_caching.rb │ │ └── memcached_caching.rb │ ├── dsl.rb │ ├── patches │ │ └── active_record.rb │ ├── caching.rb │ ├── representable.rb │ └── representation.rb └── kashmir.rb ├── Gemfile ├── test ├── test_helper.rb ├── ar_test_helper.rb ├── support │ ├── schema.rb │ ├── factories.rb │ └── ar_models.rb ├── activerecord_tricks_test.rb ├── inline_dsl_test.rb ├── activerecord_test.rb ├── dsl_test.rb ├── kashmir_test.rb └── caching_test.rb ├── Rakefile ├── LICENSE ├── kashmir.gemspec └── README.md /lib/kashmir/version.rb: -------------------------------------------------------------------------------- 1 | module Kashmir 2 | VERSION = "0.1.6" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in kashmir.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'mocha/test_unit' 3 | require 'logger' 4 | 5 | require 'kashmir' 6 | 7 | Kashmir.init({ 8 | logger: Logger.new("/dev/null") 9 | }) 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rake/testtask' 3 | 4 | desc 'Test the kashmir gem.' 5 | task :default => :test 6 | 7 | Rake::TestTask.new(:test) do |test| 8 | test.libs << 'test' 9 | test.test_files = FileList['test/**/*_test.rb'] 10 | test.verbose = true 11 | end 12 | -------------------------------------------------------------------------------- /lib/kashmir/inline_dsl.rb: -------------------------------------------------------------------------------- 1 | module Kashmir 2 | module InlineDsl 3 | 4 | def self.build(&definitions) 5 | inline_representer = Class.new do 6 | include Kashmir::Dsl 7 | end 8 | 9 | inline_representer.class_eval(&definitions) if block_given? 10 | inline_representer 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/kashmir/extensions.rb: -------------------------------------------------------------------------------- 1 | module SymbolizeHelper 2 | def self.symbolize_recursive(hash) 3 | {}.tap do |h| 4 | hash.each { |key, value| h[key.to_sym] = map_value(value) } 5 | end 6 | end 7 | 8 | def self.map_value(thing) 9 | case thing 10 | when Hash 11 | symbolize_recursive(thing) 12 | when Array 13 | thing.map { |v| map_value(v) } 14 | else 15 | thing 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/kashmir/plugins/active_record_representation.rb: -------------------------------------------------------------------------------- 1 | module Kashmir 2 | class ActiveRecordRepresentation < Representation 3 | 4 | def present_value(value, arguments, level=1, skip_cache=false) 5 | if value.is_a?(Kashmir) || value.is_a?(Kashmir::ArRelation) 6 | return value.represent(arguments, level, skip_cache) 7 | end 8 | 9 | if value.respond_to?(:represent) 10 | value.represent(arguments, level, skip_cache) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/ar_test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'minitest/around' 3 | 4 | require 'active_record' 5 | 6 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 7 | 8 | require 'support/schema' 9 | require 'support/ar_models' 10 | require 'support/factories' 11 | 12 | 13 | class Minitest::Test 14 | def around(&block) 15 | ActiveRecord::Base.transaction do 16 | block.call 17 | raise ActiveRecord::Rollback 18 | end 19 | end 20 | end 21 | 22 | def track_queries 23 | selects = [] 24 | queries_collector = lambda do |name, start, finish, id, payload| 25 | selects << payload 26 | end 27 | 28 | ActiveRecord::Base.connection.clear_query_cache 29 | ActiveSupport::Notifications.subscribed(queries_collector, 'sql.active_record') do 30 | yield 31 | end 32 | 33 | selects.map { |sel| sel[:sql] } 34 | end 35 | -------------------------------------------------------------------------------- /test/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :recipes, force: true do |t| 3 | t.column :title, :string 4 | t.column :num_steps, :integer 5 | t.column :chef_id, :integer 6 | end 7 | 8 | create_table :ingredients, force: true do |t| 9 | t.column :name, :string 10 | t.column :quantity, :string 11 | end 12 | 13 | create_table :recipes_ingredients, force: true do |t| 14 | t.column :recipe_id, :integer 15 | t.column :ingredient_id, :integer 16 | end 17 | 18 | create_table :chefs, force: true do |t| 19 | t.column :name, :string 20 | end 21 | 22 | create_table :restaurants, force: true do |t| 23 | t.column :name, :string 24 | t.column :owner_id, :integer 25 | t.column :current_customer_count, :integer 26 | end 27 | 28 | create_table :ratings, force: true do |t| 29 | t.column :value, :string 30 | t.column :restaurant_id, :integer 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/kashmir/plugins/null_caching.rb: -------------------------------------------------------------------------------- 1 | require 'kashmir/extensions' 2 | 3 | module Kashmir 4 | module Caching 5 | class Null 6 | 7 | def from_cache(definitions, instance) 8 | nil 9 | end 10 | 11 | def bulk_from_cache(definitions, instances) 12 | [] 13 | end 14 | 15 | def store_presenter(definitions, representation, instance, black_list=[], ttl=0) 16 | end 17 | 18 | def bulk_write(representation_definition, representations, objects, ttl) 19 | end 20 | 21 | def presenter_key(definition_name, instance) 22 | "presenter:#{instance.class}:#{instance.id}:#{definition_name}" 23 | end 24 | 25 | def get(key) 26 | end 27 | 28 | def set(key, value) 29 | end 30 | 31 | def clear(definition, instance) 32 | end 33 | 34 | def flush! 35 | end 36 | 37 | def keys 38 | [] 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/kashmir/dsl.rb: -------------------------------------------------------------------------------- 1 | module Kashmir 2 | module Dsl 3 | 4 | def self.included(klass) 5 | klass.extend ClassMethods 6 | end 7 | 8 | module ClassMethods 9 | 10 | def prop(name) 11 | definitions << name 12 | end 13 | 14 | def props(*names) 15 | names.each do |name| 16 | prop(name) 17 | end 18 | end 19 | 20 | def group(name, fields) 21 | definition = Hash.new 22 | definition[name] = fields 23 | definitions << definition 24 | end 25 | 26 | def embed(name, representer) 27 | group(name, representer.definitions) 28 | end 29 | 30 | def inline(name, &inline_representer) 31 | representer = Kashmir::InlineDsl.build(&inline_representer) 32 | embed(name, representer) 33 | end 34 | 35 | def definitions 36 | @definitions ||= [] 37 | @definitions 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/kashmir.rb: -------------------------------------------------------------------------------- 1 | require "kashmir/version" 2 | require "kashmir/representation" 3 | require "kashmir/dsl" 4 | require "kashmir/inline_dsl" 5 | require "kashmir/plugins/ar" 6 | require "kashmir/caching" 7 | require "kashmir/representable" 8 | require 'logger' 9 | 10 | module Kashmir 11 | 12 | class << self 13 | 14 | def included(klass) 15 | klass.extend Representable::ClassMethods 16 | klass.send(:include, Representable) 17 | 18 | if klass.ancestors.include?(::ActiveRecord::Base) 19 | klass.send(:include, Kashmir::AR) 20 | end 21 | end 22 | 23 | def init(options={}) 24 | if client = options[:cache_client] 25 | @caching = client 26 | end 27 | 28 | if logger = options[:logger] 29 | @logger = logger 30 | end 31 | end 32 | 33 | def logger 34 | @logger ||= defined?(Rails) ? Rails.logger : Logger.new(STDOUT) 35 | end 36 | 37 | def caching 38 | @caching ||= Kashmir::Caching::Null.new 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 IFTTT 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/kashmir/plugins/ar.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | require "kashmir/plugins/active_record_representation" 3 | require "kashmir/plugins/ar_relation" 4 | require "kashmir/patches/active_record" 5 | 6 | module ActiveRecord 7 | # = Active Record Relation 8 | class Relation 9 | include Kashmir::ArRelation 10 | end 11 | end 12 | 13 | # TODO: extract kashmir array module 14 | class Array 15 | include Kashmir::ArRelation 16 | end 17 | 18 | module Kashmir 19 | module AR 20 | 21 | def self.included(klass) 22 | klass.extend ClassMethods 23 | end 24 | 25 | module ClassMethods 26 | 27 | def rep(field, options={}) 28 | if reflection_names.include?(field) 29 | return activerecord_rep(field, options) 30 | end 31 | 32 | super 33 | end 34 | 35 | def reflection_names 36 | if self.respond_to?(:reflections) 37 | return reflections.keys.map(&:to_sym) 38 | end 39 | 40 | [] 41 | end 42 | 43 | def activerecord_rep(field, options) 44 | representation = ActiveRecordRepresentation.new(field, options) 45 | definitions[field] = representation 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/support/factories.rb: -------------------------------------------------------------------------------- 1 | module TestData 2 | def self.create_tom 3 | AR::Chef.create(name: 'Tom').tap do |tom| 4 | AR::Recipe.create(title: 'Pastrami Sandwich', chef: tom).tap do |r| 5 | r.ingredients.create(name: 'Pastrami', quantity: 'a lot') 6 | r.ingredients.create(name: 'Cheese', quantity: '1 slice') 7 | end 8 | 9 | AR::Recipe.create(title: 'Belly Burger', chef: tom).tap do |r| 10 | r.ingredients.create(name: 'Pork Belly', quantity: 'plenty') 11 | r.ingredients.create(name: 'Green Apple', quantity: '2 slices') 12 | end 13 | 14 | AR::Restaurant.create(name: 'Chef Tom Belly Burgers', owner: tom) do |res| 15 | res.create_rating(value: '3 stars') 16 | end 17 | end 18 | end 19 | 20 | def self.create_netto 21 | AR::Chef.create(name: 'Netto').tap do |netto| 22 | AR::Recipe.create(title: 'Turkey Sandwich', chef: netto).tap do |r| 23 | r.ingredients.create(name: 'Turkey', quantity: 'a lot') 24 | r.ingredients.create(name: 'Cheese', quantity: '1 slice') 25 | end 26 | 27 | AR::Recipe.create(title: 'Cheese Burger', chef: netto).tap do |r| 28 | r.ingredients.create(name: 'Patty', quantity: '1') 29 | r.ingredients.create(name: 'Cheese', quantity: '2 slices') 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/kashmir/patches/active_record.rb: -------------------------------------------------------------------------------- 1 | # We have to reopen Preloader to allow for it 2 | # to accept any random attribute name as a preloadable association. 3 | # 4 | # This allows us to send any arbitrary Hash to Preloader, without 5 | # requiring it to be an ActiveRecord relation in advance. 6 | # 7 | 8 | module ArV4Patch 9 | def grouped_records(association, records_41 = records) 10 | super(association, records_41.select { |record| record.class.reflect_on_association(association) }) 11 | end 12 | end 13 | 14 | module ArV3Patch 15 | def self.included(klass) 16 | klass.instance_eval do 17 | remove_method :records_by_reflection 18 | end 19 | end 20 | 21 | def records_by_reflection(association) 22 | grouped = records.group_by do |record| 23 | reflection = record.class.reflections[association] 24 | 25 | unless reflection 26 | next 27 | end 28 | 29 | reflection 30 | end 31 | 32 | ## This takes out the unexisting relations 33 | grouped.delete(nil) 34 | grouped 35 | end 36 | end 37 | 38 | module ActiveRecord 39 | module Associations 40 | class Preloader 41 | if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("4.0.2") 42 | prepend ::ArV4Patch 43 | else 44 | include ::ArV3Patch 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/kashmir/plugins/ar_relation.rb: -------------------------------------------------------------------------------- 1 | module Kashmir 2 | module ArRelation 3 | 4 | def represent(representation_definition=[], level=1, skip_cache=false) 5 | cached_presenters = Kashmir::Caching.bulk_from_cache(representation_definition, self) 6 | 7 | to_load = [] 8 | self.zip(cached_presenters).each do |record, cached_presenter| 9 | if cached_presenter.nil? 10 | to_load << record 11 | end 12 | end 13 | 14 | if to_load.any? 15 | if ActiveRecord::VERSION::STRING >= "4.0.2" 16 | ActiveRecord::Associations::Preloader.new.preload(to_load, representation_definition) 17 | else 18 | ActiveRecord::Associations::Preloader.new(to_load, representation_definition).run 19 | end 20 | end 21 | 22 | to_load_representations = to_load.map do |subject| 23 | subject.represent(representation_definition, level, skip_cache) if subject.respond_to?(:represent) 24 | end 25 | 26 | if rep = to_load.first and rep.is_a?(Kashmir) and rep.cacheable? 27 | Kashmir::Caching.bulk_write(representation_definition, to_load_representations, to_load, level * 60) 28 | end 29 | 30 | cached_presenters.compact + to_load_representations 31 | end 32 | 33 | def represent_with(&block) 34 | map do |subject| 35 | subject.represent_with(&block) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /kashmir.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'kashmir/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "kashmir" 8 | spec.version = Kashmir::VERSION 9 | spec.authors = ["IFTTT", "Netto Farah"] 10 | spec.email = ["ops@ifttt.com", "nettofarah@gmail.com"] 11 | spec.summary = %q{Kashmir is a DSL for quickly defining contracts to decorate your models.} 12 | spec.description = %q{ 13 | Kashmir helps you easily define decorators/representers/presenters for ruby objects. 14 | Optionally, Kashmir will also cache these views for faster lookups. 15 | } 16 | spec.homepage = "http://ifttt.github.io/" 17 | spec.license = "MIT" 18 | 19 | spec.files = `git ls-files -z`.split("\x0") 20 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 21 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 22 | spec.require_paths = ["lib"] 23 | 24 | spec.add_development_dependency "bundler", "~> 1.7" 25 | spec.add_development_dependency "rake", "~> 10.0" 26 | spec.add_development_dependency "minitest", "~> 5.0" 27 | spec.add_development_dependency "minitest-around", "~> 0.3" 28 | spec.add_development_dependency "mocha", "~> 1.1" 29 | spec.add_development_dependency "sqlite3", "1.3.10" 30 | 31 | spec.add_development_dependency "activerecord", "~> 4.2" 32 | end 33 | -------------------------------------------------------------------------------- /test/support/ar_models.rb: -------------------------------------------------------------------------------- 1 | module AR 2 | class Recipe < ActiveRecord::Base 3 | include Kashmir 4 | 5 | belongs_to :chef 6 | has_many :recipes_ingredients 7 | has_many :ingredients, through: :recipes_ingredients 8 | 9 | representations do 10 | rep :title 11 | rep :chef 12 | rep :ingredients 13 | end 14 | end 15 | 16 | class Ingredient < ActiveRecord::Base 17 | include Kashmir 18 | 19 | representations do 20 | rep :name 21 | rep :quantity 22 | end 23 | end 24 | 25 | class RecipesIngredient < ActiveRecord::Base 26 | belongs_to :recipe 27 | belongs_to :ingredient 28 | end 29 | 30 | class Restaurant < ActiveRecord::Base 31 | include Kashmir 32 | 33 | belongs_to :owner, class_name: 'Chef' 34 | has_one :rating 35 | 36 | representations do 37 | rep :name 38 | rep :owner 39 | rep :rating 40 | 41 | rep :current_customer_count, cacheable: false 42 | end 43 | end 44 | 45 | class Rating < ActiveRecord::Base 46 | include Kashmir 47 | 48 | belongs_to :restaurant 49 | 50 | representations do 51 | rep :value 52 | end 53 | end 54 | 55 | class Chef < ActiveRecord::Base 56 | include Kashmir 57 | 58 | has_many :recipes 59 | has_many :ingredients, through: :recipes 60 | has_one :restaurant, foreign_key: 'owner_id' 61 | 62 | representations do 63 | rep :name 64 | rep :recipes 65 | rep :ingredients 66 | rep :restaurant 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/kashmir/plugins/memory_caching.rb: -------------------------------------------------------------------------------- 1 | require 'kashmir/extensions' 2 | 3 | module Kashmir 4 | module Caching 5 | class Memory 6 | 7 | def from_cache(definitions, instance) 8 | key = presenter_key(definitions, instance) 9 | if cached_data = get(key) 10 | return cached_data 11 | end 12 | end 13 | 14 | def bulk_from_cache(definitions, instances) 15 | keys = instances.map do |instance| 16 | presenter_key(definitions, instance) if instance.respond_to?(:id) 17 | end 18 | 19 | keys.map do |key| 20 | get(key) 21 | end 22 | end 23 | 24 | def store_presenter(definitions, representation, instance, ttl=0) 25 | key = presenter_key(definitions, instance) 26 | set(key, representation) 27 | end 28 | 29 | def bulk_write(definitions, representations, objects, ttl) 30 | objects.each_with_index do |instance, index| 31 | store_presenter(definitions, representations[index], instance, ttl) 32 | end 33 | end 34 | 35 | def presenter_key(definition_name, instance) 36 | "presenter:#{instance.class}:#{instance.id}:#{definition_name}" 37 | end 38 | 39 | def get(key) 40 | @@cache ||= {} 41 | if data = @@cache[key] 42 | SymbolizeHelper.symbolize_recursive JSON.parse(data) 43 | end 44 | end 45 | 46 | def set(key, value) 47 | @@cache ||= {} 48 | @@cache[key] = value.to_json 49 | end 50 | 51 | def clear(definition, instance) 52 | key = presenter_key(definition, instance) 53 | @@cache ||= {} 54 | @@cache.delete(key) 55 | end 56 | 57 | def flush! 58 | @@cache = {} 59 | end 60 | 61 | def keys 62 | @@cache.keys 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/kashmir/caching.rb: -------------------------------------------------------------------------------- 1 | require 'kashmir/plugins/memory_caching' 2 | require 'kashmir/plugins/null_caching' 3 | 4 | module Kashmir 5 | module Caching 6 | 7 | def from_cache(representation_definition, object) 8 | log("read: #{log_key(object, representation_definition)}", :debug) 9 | 10 | cached_representation = Kashmir.caching.from_cache(representation_definition, object) 11 | 12 | if cached_representation 13 | log("hit: #{log_key(object, representation_definition)}") 14 | else 15 | log("miss: #{log_key(object, representation_definition)}") 16 | end 17 | 18 | cached_representation 19 | end 20 | 21 | def bulk_from_cache(representation_definition, objects) 22 | class_name = objects.length > 0 ? objects.first.class.to_s : '' 23 | log("read_multi: [#{objects.length}]#{class_name} : #{representation_definition}", :debug) 24 | Kashmir.caching.bulk_from_cache(representation_definition, objects) 25 | end 26 | 27 | def store_presenter(representation_definition, representation, object, ttl) 28 | log("write TTL: #{ttl}: #{log_key(object, representation_definition)}", :debug) 29 | Kashmir.caching.store_presenter(representation_definition, representation, object, ttl) 30 | end 31 | 32 | def bulk_write(representation_definition, representations, objects, ttl) 33 | class_name = objects.length > 0 ? objects.first.class.to_s : '' 34 | log("write_multi: TTL: #{ttl}: [#{objects.length}]#{class_name} : #{representation_definition}", :debug) 35 | Kashmir.caching.bulk_write(representation_definition, representations, objects, ttl) 36 | end 37 | 38 | def log_key(object, representation_definition) 39 | "#{object.class.name}-#{object.id}-#{representation_definition}" 40 | end 41 | 42 | def log(message, level=:info) 43 | Kashmir.logger.send(level, ("\nKashmir::Caching #{message}\n")) 44 | end 45 | 46 | module_function :from_cache, :bulk_from_cache, :bulk_write, :store_presenter, :log_key, :log 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/activerecord_tricks_test.rb: -------------------------------------------------------------------------------- 1 | require 'ar_test_helper' 2 | 3 | describe 'ActiveRecord performance tricks' do 4 | 5 | before(:all) do 6 | TestData.create_tom 7 | TestData.create_netto 8 | end 9 | 10 | describe "Query preload to avoid N+1 queries" do 11 | 12 | it 'tries to preload records whenever possible' do 13 | selects = track_queries do 14 | AR::Chef.all.each do |chef| 15 | chef.recipes.to_a 16 | end 17 | end 18 | # SELECT * FROM chefs 19 | # SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" = ? 20 | # SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" = ? 21 | assert_equal 3, selects.size 22 | 23 | selects = track_queries do 24 | AR::Chef.all.represent([:recipes]) 25 | end 26 | # SELECT "chefs".* FROM "chefs" 27 | # SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" IN (1, 2) 28 | assert_equal 2, selects.size 29 | end 30 | 31 | it 'preloads queries per each level in the tree' do 32 | selects = track_queries do 33 | AR::Chef.all.each do |chef| 34 | chef.recipes.each do |recipe| 35 | recipe.ingredients.to_a 36 | end 37 | end 38 | end 39 | # SELECT "chefs".* FROM "chefs" 40 | # (2x) SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" = ? 41 | # (4x) SELECT "ingredients".* FROM "ingredients" INNER JOIN "recipes_ingredients" ... 42 | assert_equal 7, selects.size 43 | 44 | selects = track_queries do 45 | AR::Chef.all.represent([ :recipes => [ :ingredients => [:name] ] ]) 46 | end 47 | # SELECT "chefs".* FROM "chefs" 48 | # SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" IN (1, 2) 49 | # SELECT "recipes_ingredients".* FROM "recipes_ingredients" WHERE "recipes_ingredients"."recipe_id" IN (1, 2, 3, 4) 50 | # SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."id" IN (1, 2, 3, 4, 5, 6, 7, 8) 51 | assert_equal 4, selects.size 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/kashmir/plugins/memcached_caching.rb: -------------------------------------------------------------------------------- 1 | module Kashmir 2 | module Caching 3 | class Memcached 4 | 5 | attr_reader :client 6 | 7 | def initialize(client, default_ttl = 3600) 8 | @client = client 9 | @default_ttl = default_ttl 10 | end 11 | 12 | def from_cache(definitions, instance) 13 | key = presenter_key(definitions, instance) 14 | if cached_data = get(key) 15 | return cached_data 16 | end 17 | end 18 | 19 | def bulk_from_cache(definitions, instances) 20 | keys = instances.map do |instance| 21 | presenter_key(definitions, instance) if instance.respond_to?(:id) 22 | end.compact 23 | 24 | 25 | # TODO improve this 26 | # Creates a hash with all the keys (sorted by the array sort order) 27 | # and points everything to null 28 | # ex: [a, b, c] -> { a: nil, b: nil, c: nil } 29 | results = Hash[keys.map {|x| [x, nil]}] 30 | 31 | # Get results from memcached 32 | # This will ONLY return cache hits as a Hash 33 | # ex: { a: cached_a, b: cached_b } note that C is not here 34 | from_cache = client.get_multi(keys) 35 | 36 | # This assigns each one of the cached values to its keys 37 | # preserving cache misses (that still point to nil) 38 | from_cache.each_pair do |key, value| 39 | results[key] = JSON.parse(value, symbolize_names: true) 40 | end 41 | 42 | # returns the cached results in the same order as requested. 43 | # this will also return nil values for cache misses 44 | results.values 45 | end 46 | 47 | def bulk_write(definitions, representations, instances, ttl) 48 | client.multi do 49 | instances.each_with_index do |instance, index| 50 | key = presenter_key(definitions, instance) 51 | set(key, representations[index], ttl) 52 | end 53 | end 54 | end 55 | 56 | def store_presenter(definitions, representation, instance, ttl=0) 57 | key = presenter_key(definitions, instance) 58 | set(key, representation, ttl) 59 | end 60 | 61 | def presenter_key(definition_name, instance) 62 | "#{instance.class}:#{instance.id}:#{definition_name}" 63 | end 64 | 65 | def get(key) 66 | if data = client.get(key) 67 | JSON.parse(data, symbolize_names: true) 68 | end 69 | end 70 | 71 | def set(key, value, ttl=nil) 72 | client.set(key, value.to_json, ttl || @default_ttl) 73 | end 74 | 75 | def clear(definition, instance) 76 | key = presenter_key(definition, instance) 77 | client.delete(key) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/kashmir/representable.rb: -------------------------------------------------------------------------------- 1 | module Kashmir 2 | module Representable 3 | 4 | def represent(representation_definition=[], level=1, skip_cache=false) 5 | if !skip_cache && cacheable? and cached_presenter = Kashmir::Caching.from_cache(representation_definition, self) 6 | return cached_presenter 7 | end 8 | 9 | representation = {} 10 | 11 | (representation_definition + base_representation).each do |representation_definition| 12 | key, arguments = parse_definition(representation_definition) 13 | 14 | unless self.class.definitions.keys.include?(key) 15 | raise "#{self.class.to_s}##{key} is not defined as a representation" 16 | end 17 | 18 | represented_document = self.class.definitions[key].run_for(self, arguments, level) 19 | representation = representation.merge(represented_document) 20 | end 21 | 22 | if !skip_cache 23 | cache!(representation_definition.dup, representation.dup, level) 24 | end 25 | 26 | representation 27 | end 28 | 29 | def cache!(representation_definition, representation, level=1) 30 | return unless cacheable? 31 | 32 | (cache_black_list & representation_definition).each do |field_name| 33 | representation_definition = representation_definition - [ field_name ] 34 | representation.delete(field_name) 35 | end 36 | 37 | Kashmir::Caching.store_presenter(representation_definition, representation, self, level * 60) 38 | end 39 | 40 | def cache_black_list 41 | self.class.definitions.values.reject(&:should_cache?).map(&:field) 42 | end 43 | 44 | def cacheable? 45 | respond_to?(:id) 46 | end 47 | 48 | def base_representation 49 | self.class.definitions.values.select(&:is_base?).map(&:field) 50 | end 51 | 52 | def represent_with(&block) 53 | definitions = Kashmir::InlineDsl.build(&block).definitions 54 | represent(definitions) 55 | end 56 | 57 | def parse_definition(representation_definition) 58 | if representation_definition.is_a?(Symbol) 59 | [ representation_definition, [] ] 60 | elsif representation_definition.is_a?(Hash) 61 | [ representation_definition.keys.first, representation_definition.values.flatten ] 62 | end 63 | end 64 | 65 | module ClassMethods 66 | 67 | def representations(&definitions) 68 | @definitions = {} 69 | class_eval(&definitions) 70 | end 71 | 72 | def base(fields) 73 | fields.each do |field| 74 | rep(field, { is_base: true }) 75 | end 76 | end 77 | 78 | def rep(field, options={}) 79 | representation = Representation.new(field, options) 80 | definitions[field] = representation 81 | end 82 | 83 | def definitions 84 | @definitions ||= {} 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/inline_dsl_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Kashmir::InlineDsl do 4 | 5 | module InlineDSLTesting 6 | class Recipe < OpenStruct 7 | include Kashmir 8 | 9 | representations do 10 | rep(:num_steps) 11 | rep(:title) 12 | rep(:chef) 13 | end 14 | end 15 | 16 | class Chef < OpenStruct 17 | include Kashmir 18 | 19 | representations do 20 | rep(:full_name) 21 | rep(:award) 22 | end 23 | 24 | def full_name 25 | "#{first_name} #{last_name}" 26 | end 27 | end 28 | 29 | class Award < OpenStruct 30 | include Kashmir 31 | 32 | representations do 33 | base([:name, :year]) 34 | end 35 | end 36 | end 37 | 38 | before do 39 | @award = InlineDSLTesting::Award.new(name: 'Best Chef', year: 2015) 40 | @chef = InlineDSLTesting::Chef.new(first_name: 'Netto', last_name: 'Farah', award: @award) 41 | @brisket = InlineDSLTesting::Recipe.new(title: 'BBQ Brisket', num_steps: 2, chef: @chef) 42 | end 43 | 44 | it 'creates an inline representer' do 45 | inline_representer = Kashmir::InlineDsl.build do 46 | prop :num_steps 47 | prop :title 48 | end 49 | 50 | assert_equal inline_representer.definitions, [:num_steps, :title] 51 | 52 | cooked_brisket = @brisket.represent(inline_representer.definitions) 53 | assert_equal cooked_brisket, { title: 'BBQ Brisket', num_steps: 2 } 54 | end 55 | 56 | describe 'Reduced syntax' do 57 | 58 | describe '#represent_with' do 59 | it 'works with flat representers' do 60 | cooked_brisket = @brisket.represent_with do 61 | prop :title 62 | prop :num_steps 63 | end 64 | 65 | assert_equal cooked_brisket, { title: 'BBQ Brisket', num_steps: 2 } 66 | end 67 | 68 | it 'works with nested representers' do 69 | cooked_brisket = @brisket.represent_with do 70 | prop :title 71 | prop :num_steps 72 | 73 | inline :chef do 74 | prop :full_name 75 | end 76 | end 77 | 78 | assert_equal cooked_brisket, { 79 | title: 'BBQ Brisket', 80 | num_steps: 2, 81 | chef: { 82 | full_name: 'Netto Farah' 83 | } 84 | } 85 | end 86 | end 87 | 88 | it 'works with multi level nested representers' do 89 | cooked_brisket = @brisket.represent_with do 90 | prop :title 91 | inline :chef do 92 | prop :full_name 93 | inline :award 94 | end 95 | end 96 | 97 | assert_equal cooked_brisket, { 98 | title: 'BBQ Brisket', 99 | chef: { 100 | full_name: 'Netto Farah', 101 | award: { 102 | name: 'Best Chef', year: 2015 103 | } 104 | } 105 | } 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/activerecord_test.rb: -------------------------------------------------------------------------------- 1 | require 'ar_test_helper' 2 | 3 | # see support/ar_models for model definitions 4 | 5 | describe 'ActiveRecord integration' do 6 | 7 | before(:each) do 8 | @tom = TestData.create_tom 9 | @pastrami_sandwich = AR::Recipe.find_by_title('Pastrami Sandwich') 10 | @belly_burger = AR::Recipe.find_by_title('Belly Burger') 11 | @restaurant = AR::Restaurant.find_by_name('Chef Tom Belly Burgers') 12 | end 13 | 14 | it 'represents ar objects' do 15 | ps = @pastrami_sandwich.represent_with do 16 | prop :title 17 | end 18 | 19 | assert_equal ps, { title: 'Pastrami Sandwich' } 20 | end 21 | 22 | describe 'ActiveRecord::Relation' do 23 | it 'represents relations' do 24 | recipes = AR::Recipe.all.represent_with do 25 | prop :title 26 | end 27 | 28 | assert_equal recipes, [ 29 | { title: 'Pastrami Sandwich' }, 30 | { title: 'Belly Burger' } 31 | ] 32 | end 33 | 34 | it 'represents nested relations' do 35 | recipes = AR::Recipe.all.represent_with do 36 | prop :title 37 | inline :ingredients do 38 | prop :name 39 | end 40 | end 41 | 42 | assert_equal recipes, [ 43 | { 44 | title: 'Pastrami Sandwich', 45 | ingredients: [ { name: 'Pastrami' }, { name: 'Cheese' } ] 46 | }, 47 | { 48 | title: 'Belly Burger', 49 | ingredients: [ {name: 'Pork Belly'}, { name: 'Green Apple' } ] 50 | } 51 | ] 52 | end 53 | end 54 | 55 | describe 'belongs_to' do 56 | it 'works for basic relations' do 57 | ps = @pastrami_sandwich.represent_with do 58 | prop :title 59 | inline :chef do 60 | prop :name 61 | end 62 | end 63 | 64 | assert_equal ps, { 65 | title: 'Pastrami Sandwich', 66 | chef: { 67 | name: 'Tom' 68 | } 69 | } 70 | end 71 | 72 | it 'works with custom names' do 73 | r = @restaurant.represent_with do 74 | prop :name 75 | inline :owner do 76 | prop :name 77 | end 78 | end 79 | 80 | assert_equal r, { 81 | name: 'Chef Tom Belly Burgers', 82 | owner: { 83 | name: 'Tom' 84 | } 85 | } 86 | end 87 | end 88 | 89 | describe 'has_many' do 90 | it 'works for basic associations' do 91 | t = @tom.represent_with do 92 | prop :name 93 | inline :recipes do 94 | prop :title 95 | end 96 | end 97 | 98 | assert_equal t, { 99 | name: 'Tom', 100 | recipes: [ 101 | { title: 'Pastrami Sandwich' }, 102 | { title: 'Belly Burger'} 103 | ] 104 | } 105 | end 106 | 107 | it 'works with :through associations' do 108 | tom_with_ingredients = @tom.reload.represent_with do 109 | prop :name 110 | inline :ingredients do 111 | prop :name 112 | prop :quantity 113 | end 114 | end 115 | 116 | assert_equal tom_with_ingredients, { 117 | name: 'Tom', 118 | ingredients: [ 119 | { name: 'Pastrami' , quantity: 'a lot' }, 120 | { name: 'Cheese' , quantity: '1 slice' }, 121 | { name: 'Pork Belly' , quantity: 'plenty' }, 122 | { name: 'Green Apple' , quantity: '2 slices' } 123 | ] 124 | } 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/kashmir/representation.rb: -------------------------------------------------------------------------------- 1 | module Kashmir 2 | class Representation 3 | 4 | attr_reader :field 5 | 6 | def initialize(field, options) 7 | @field = field 8 | @options = options 9 | end 10 | 11 | def run_for(instance, arguments, level=1) 12 | representation = {} 13 | 14 | value = read_value(instance, @field) 15 | if primitive?(value) 16 | representation[@field] = value 17 | else 18 | if value.is_a?(Hash) 19 | representation[@field] = new_hash 20 | else 21 | representation[@field] = present_value(value, arguments, level) 22 | end 23 | end 24 | 25 | representation 26 | end 27 | 28 | def is_base? 29 | @options.has_key?(:is_base) and !!@options[:is_base] 30 | end 31 | 32 | def should_cache? 33 | if @options.has_key?(:cacheable) 34 | return !!@options[:cacheable] 35 | end 36 | 37 | true 38 | end 39 | 40 | def present_value(value, arguments, level=1, skip_cache=false) 41 | 42 | if value.is_a?(Kashmir) 43 | return value.represent(arguments, level + 1, skip_cache) 44 | end 45 | 46 | if value.is_a?(Hash) 47 | return present_hash(value, arguments, level + 1, skip_cache) 48 | end 49 | 50 | if value.is_a?(Array) 51 | return present_array(value, arguments, level + 1, skip_cache) 52 | end 53 | 54 | if value.respond_to?(:represent) 55 | return value.represent(arguments, level + 1, skip_cache) 56 | end 57 | end 58 | 59 | def present_array(value, arguments, level=1, skip_cache=false) 60 | cached_presenters = Kashmir::Caching.bulk_from_cache(arguments, value) 61 | 62 | uncached = [] 63 | value.zip(cached_presenters).each do |record, cached_presenter| 64 | if cached_presenter.nil? 65 | uncached << record 66 | end 67 | end 68 | 69 | uncached_representations = uncached.map do |element| 70 | if primitive?(element) 71 | element 72 | else 73 | present_value(element, arguments, level, true) 74 | end 75 | end 76 | 77 | if rep = uncached.first and rep.is_a?(Kashmir) and rep.cacheable? 78 | Kashmir::Caching.bulk_write(arguments, uncached_representations, uncached, level * 60) 79 | end 80 | 81 | cached_presenters.compact + uncached_representations 82 | end 83 | 84 | def present_hash(value, arguments, level=1, skip_cache=false) 85 | new_hash = {} 86 | value.each_pair do |key, value| 87 | args = if arguments.is_a?(Hash) 88 | arguments[key.to_sym] 89 | else 90 | arg = arguments.find do |arg| 91 | (arg.is_a?(Hash) && arg.has_key?(key.to_sym)) || arg == key.to_sym 92 | end 93 | if arg.is_a?(Hash) 94 | arg = arg[key.to_sym] 95 | end 96 | 97 | arg 98 | end 99 | new_hash[key] = primitive?(value) ? value : present_value(value, args || [], level, skip_cache) 100 | end 101 | new_hash 102 | end 103 | 104 | def read_value(instance, field) 105 | if instance.respond_to?(field) 106 | instance.send(field) 107 | else 108 | instance.instance_variable_get("@#{field}") 109 | end 110 | end 111 | 112 | def primitive?(field_value) 113 | [Numeric, String, Date, Time, TrueClass, FalseClass, Symbol].any? do |type| 114 | field_value.is_a?(type) 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/dsl_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Kashmir do 4 | 5 | module DSLTesting 6 | class Recipe < OpenStruct 7 | include Kashmir 8 | 9 | representations do 10 | rep(:num_steps) 11 | rep(:title) 12 | rep(:chef) 13 | rep(:ingredients) 14 | end 15 | end 16 | 17 | class Chef < OpenStruct 18 | include Kashmir 19 | 20 | representations do 21 | rep(:full_name) 22 | end 23 | 24 | def full_name 25 | "#{first_name} #{last_name}" 26 | end 27 | end 28 | 29 | class SimpleRecipeRepresenter 30 | include Kashmir::Dsl 31 | 32 | prop :num_steps 33 | prop :title 34 | end 35 | 36 | class ChefRepresenter 37 | include Kashmir::Dsl 38 | 39 | prop :full_name 40 | end 41 | 42 | class RecipeWithChefRepresenter 43 | include Kashmir::Dsl 44 | 45 | prop :title 46 | embed :chef, ChefRepresenter 47 | end 48 | end 49 | 50 | before do 51 | @chef = DSLTesting::Chef.new(first_name: 'Netto', last_name: 'Farah') 52 | @brisket = DSLTesting::Recipe.new(title: 'BBQ Brisket', num_steps: 2, chef: @chef) 53 | end 54 | 55 | it 'translates to representation definitions' do 56 | definitions = DSLTesting::SimpleRecipeRepresenter.definitions 57 | assert_equal definitions, [:num_steps, :title] 58 | end 59 | 60 | it 'generates the same representations as hardcoded definitions' do 61 | cooked_brisket = @brisket.represent([:num_steps, :title]) 62 | assert_equal cooked_brisket, { title: 'BBQ Brisket', num_steps: 2 } 63 | assert_equal cooked_brisket, @brisket.represent(DSLTesting::SimpleRecipeRepresenter.definitions) 64 | end 65 | 66 | it 'generates nested representations' do 67 | brisket_with_chef = @brisket.represent([:title, { :chef => [:full_name] }]) 68 | assert_equal brisket_with_chef, { 69 | title: 'BBQ Brisket', 70 | chef: { 71 | full_name: 'Netto Farah' 72 | } 73 | } 74 | 75 | assert_equal brisket_with_chef, @brisket.represent(DSLTesting::RecipeWithChefRepresenter.definitions) 76 | end 77 | 78 | describe 'Nested inline representations' do 79 | 80 | module DSLTesting 81 | class RecipeWithInlineChefRepresenter 82 | include Kashmir::Dsl 83 | 84 | prop :title 85 | 86 | inline :chef do 87 | prop :full_name 88 | end 89 | end 90 | 91 | class Ingredient < OpenStruct 92 | include Kashmir 93 | 94 | representations do 95 | rep(:name) 96 | rep(:quantity) 97 | end 98 | end 99 | end 100 | 101 | it 'generates nested representations' do 102 | brisket_with_chef = @brisket.represent([:title, { :chef => [:full_name] }]) 103 | assert_equal brisket_with_chef, { 104 | title: 'BBQ Brisket', 105 | chef: { 106 | full_name: 'Netto Farah' 107 | } 108 | } 109 | 110 | assert_equal brisket_with_chef, @brisket.represent(DSLTesting::RecipeWithInlineChefRepresenter.definitions) 111 | end 112 | end 113 | 114 | describe 'Collections' do 115 | module DSLTesting 116 | class RecipeWithIngredientsRepresenter 117 | include Kashmir::Dsl 118 | 119 | prop :title 120 | 121 | inline :ingredients do 122 | prop :name 123 | prop :quantity 124 | end 125 | end 126 | end 127 | 128 | it 'generates nested collections' do 129 | @brisket.ingredients = [ 130 | DSLTesting::Ingredient.new(name: 'Beef', quantity: '8oz'), 131 | DSLTesting::Ingredient.new(name: 'BBQ Sauce', quantity: 'plenty!'), 132 | ] 133 | 134 | cooked_brisket = { 135 | title: 'BBQ Brisket', 136 | ingredients: [ 137 | { name: 'Beef', quantity: '8oz' }, 138 | { name: 'BBQ Sauce', quantity: 'plenty!' } 139 | ] 140 | } 141 | 142 | assert_equal cooked_brisket, @brisket.represent(DSLTesting::RecipeWithIngredientsRepresenter.definitions) 143 | end 144 | end 145 | 146 | describe "Kashmir.props" do 147 | 148 | module DSLTesting 149 | 150 | class MultiPropRecipeRepresenter 151 | include Kashmir::Dsl 152 | 153 | props :num_steps, :title 154 | end 155 | end 156 | 157 | it 'allows the client to specify many props at once' do 158 | definitions = DSLTesting::MultiPropRecipeRepresenter.definitions 159 | assert_equal definitions, [:num_steps, :title] 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /test/kashmir_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe Kashmir do 4 | 5 | class Recipe < OpenStruct 6 | include Kashmir 7 | 8 | representations do 9 | base([:title, :preparation_time]) 10 | rep(:num_steps) 11 | rep(:chef) 12 | end 13 | 14 | def num_steps 15 | steps.size 16 | end 17 | end 18 | 19 | class Chef < OpenStruct 20 | include Kashmir 21 | 22 | representations do 23 | base([:name]) 24 | end 25 | end 26 | 27 | before(:each) do 28 | @recipe = Recipe.new(title: 'Beef stew', preparation_time: 60) 29 | @chef = Chef.new(name: 'Netto') 30 | end 31 | 32 | it 'renders basic attribute representations' do 33 | assert_equal @recipe.represent, { title: 'Beef stew', preparation_time: 60 } 34 | end 35 | 36 | it 'renders basic calculated representations' do 37 | @recipe.steps = ['chop', 'cook'] 38 | assert_equal @recipe.represent([:num_steps]), { 39 | title: 'Beef stew', 40 | preparation_time: 60, 41 | num_steps: 2 42 | } 43 | end 44 | 45 | it 'renders nested representations' do 46 | @recipe.chef = @chef 47 | representation = @recipe.represent([:chef]) 48 | assert_equal representation[:chef], { name: 'Netto' } 49 | end 50 | end 51 | 52 | describe 'Complex Representations' do 53 | class BBQRecipe < OpenStruct 54 | include Kashmir 55 | 56 | representations do 57 | base([:title]) 58 | rep(:chef) 59 | end 60 | end 61 | 62 | class BBQChef < OpenStruct 63 | include Kashmir 64 | 65 | representations do 66 | base([:name]) 67 | rep(:restaurant) 68 | end 69 | end 70 | 71 | class BBQRestaurant < OpenStruct 72 | include Kashmir 73 | 74 | representations do 75 | base([:name]) 76 | rep(:rating) 77 | end 78 | end 79 | 80 | before do 81 | @bbq_joint = BBQRestaurant.new(name: "Netto's BBQ Joint", rating: '5 stars') 82 | @netto = BBQChef.new(name: 'Netto', restaurant: @bbq_joint) 83 | @brisket = BBQRecipe.new(title: 'BBQ Brisket', chef: @netto) 84 | end 85 | 86 | it 'works with base representations' do 87 | assert_equal @netto.represent, { name: 'Netto' } 88 | assert_equal @brisket.represent, { title: 'BBQ Brisket' } 89 | assert_equal @bbq_joint.represent, { name: "Netto's BBQ Joint" } 90 | end 91 | 92 | it 'works with simple nesting' do 93 | representation = @netto.represent([:restaurant]) 94 | assert_equal representation, { 95 | name: 'Netto', 96 | restaurant: { name: "Netto's BBQ Joint" } 97 | } 98 | end 99 | 100 | it 'allows clients to customize nested representations' do 101 | representation = @netto.represent [{ :restaurant => [:rating] }] 102 | 103 | assert_equal representation, { 104 | name: 'Netto', 105 | restaurant: { name: "Netto's BBQ Joint", rating: '5 stars' } 106 | } 107 | end 108 | 109 | it 'allows clients to create complex representation trees' do 110 | representation = @brisket.represent([ 111 | :chef => [ 112 | { :restaurant => [ :rating ] } 113 | ] 114 | ]) 115 | 116 | assert_equal representation, { 117 | title: 'BBQ Brisket', 118 | chef: { 119 | name: 'Netto', 120 | restaurant: { 121 | name: "Netto's BBQ Joint", 122 | rating: '5 stars' 123 | } 124 | } 125 | } 126 | end 127 | end 128 | 129 | describe 'Collections' do 130 | 131 | class Ingredient < OpenStruct 132 | include Kashmir 133 | 134 | representations do 135 | rep(:name) 136 | rep(:quantity) 137 | end 138 | end 139 | 140 | class ClassyRecipe < OpenStruct 141 | include Kashmir 142 | 143 | representations do 144 | rep(:title) 145 | rep(:ingredients) 146 | end 147 | end 148 | 149 | before(:each) do 150 | @omelette = ClassyRecipe.new(title: 'Omelette Du Fromage') 151 | @omelette.ingredients = [ 152 | Ingredient.new(name: 'Egg', quantity: 2), 153 | Ingredient.new(name: 'Cheese', quantity: 'a lot!') 154 | ] 155 | end 156 | 157 | it 'represents collections' do 158 | nice_omelette = @omelette.represent([:title, { :ingredients => [ :name, :quantity ]}]) 159 | 160 | assert_equal nice_omelette, { 161 | title: 'Omelette Du Fromage', 162 | ingredients: [ 163 | { name: 'Egg', quantity: 2 }, 164 | { name: 'Cheese', quantity: 'a lot!' } 165 | ] 166 | } 167 | end 168 | end 169 | 170 | describe "Hashes" do 171 | 172 | class RecipeCategories < OpenStruct 173 | include Kashmir 174 | 175 | representations do 176 | rep :categories 177 | end 178 | end 179 | 180 | before(:each) do 181 | @omelette = ClassyRecipe.new(title: 'Omelette Du Fromage') 182 | @scrambled_eggs = ClassyRecipe.new(title: 'Scrambled Eggs') 183 | 184 | @brisket = ClassyRecipe.new(title: 'Briksket') 185 | @ribs = ClassyRecipe.new(title: 'BBQ Ribs') 186 | 187 | @categories = [ 188 | { name: 'Breakfast', recipes: [ @omelette, @scrambled_eggs] }, 189 | { name: 'Dinner', recipes: [ @brisket, @ribs ] } 190 | ] 191 | end 192 | 193 | it 'represents hashes' do 194 | representation = RecipeCategories.new(categories: @categories).represent([{ 195 | categories: [ 196 | :name, 197 | recipes: [:title] 198 | ] 199 | }]) 200 | 201 | assert_equal representation, { 202 | categories: [ 203 | { 204 | name: "Breakfast", recipes: [ 205 | { title: "Omelette Du Fromage" }, { title: "Scrambled Eggs" } 206 | ] 207 | }, 208 | { 209 | name: "Dinner", recipes: [ 210 | { title: "Briksket" }, { title: "BBQ Ribs" } 211 | ] 212 | } 213 | ] 214 | } 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /test/caching_test.rb: -------------------------------------------------------------------------------- 1 | require 'ar_test_helper' 2 | 3 | describe 'Caching' do 4 | 5 | before(:all) do 6 | 7 | Kashmir.init( 8 | cache_client: Kashmir::Caching::Memory.new 9 | ) 10 | 11 | TestData.create_tom 12 | @restaurant = AR::Restaurant.find_by_name('Chef Tom Belly Burgers') 13 | end 14 | 15 | after(:all) do 16 | Kashmir.init( 17 | cache_client: Kashmir::Caching::Null.new 18 | ) 19 | end 20 | 21 | before(:each) do 22 | Kashmir.caching.flush! 23 | end 24 | 25 | def from_cache(definition, model) 26 | Kashmir.caching.from_cache(definition, model) 27 | end 28 | 29 | describe 'flat data' do 30 | it 'loads the same exact data from cache' do 31 | representation = @restaurant.represent([:name]) 32 | @restaurant.reload 33 | cached_representation = @restaurant.represent([:name]) 34 | assert_equal representation, cached_representation 35 | end 36 | 37 | it 'stores the data in cache' do 38 | representation = @restaurant.represent([:name]) 39 | cached_representation = from_cache([:name], @restaurant) 40 | assert_equal representation, cached_representation 41 | end 42 | end 43 | 44 | describe 'references to other representations' do 45 | it 'does not perform the same query twice' do 46 | selects = track_queries do 47 | @restaurant.represent([:name, :owner]) 48 | end 49 | assert_equal 1, selects.size 50 | 51 | # clear active record cache for this instance 52 | @restaurant.reload 53 | selects = track_queries do 54 | @restaurant.represent([:name, :owner]) 55 | end 56 | assert_equal 0, selects.size 57 | end 58 | 59 | it 'loads the same exact data from cache' do 60 | representation = @restaurant.represent([:name, :owner]) 61 | @restaurant.reload 62 | cached_representation = @restaurant.represent([:name, :owner]) 63 | assert_equal representation, cached_representation 64 | end 65 | 66 | it 'stores the data in cache at every level' do 67 | representation = @restaurant.represent([:name, owner: [:name] ]) 68 | cached_restaurant_with_chef = from_cache([:name, owner: [ :name ]], @restaurant) 69 | 70 | assert_equal representation, cached_restaurant_with_chef 71 | 72 | chef = @restaurant.owner 73 | cached_chef = from_cache([:name], chef) 74 | 75 | assert_equal cached_chef, representation[:owner] 76 | end 77 | end 78 | 79 | describe 'nesting' do 80 | before(:all) do 81 | @chef = @restaurant.owner 82 | @chef.reload 83 | end 84 | 85 | it 'caches at every level' do 86 | representation = @chef.represent([:name, :restaurant =>[ :name, :rating =>[ :value ]]]) 87 | fully_cached_chef = from_cache([:name, :restaurant =>[ :name, :rating =>[ :value ]]], @chef) 88 | assert_equal representation, fully_cached_chef 89 | 90 | fully_cached_restaurant = from_cache([ :name, :rating => [:value] ], @restaurant) 91 | assert_equal representation[:restaurant], fully_cached_restaurant 92 | 93 | cached_rating = from_cache([:value], @restaurant.rating) 94 | assert_equal representation[:restaurant][:rating], cached_rating 95 | 96 | assert_equal 3, Kashmir.caching.keys.size 97 | end 98 | 99 | it 'tries to hit the cache at every level' do 100 | selects = track_queries do 101 | @chef.represent([:name, :restaurant =>[ :name, :rating =>[ :value ]]]) 102 | end 103 | # SELECT "restaurants".* FROM "restaurants" WHERE "restaurants"."owner_id" = ? LIMIT 1 104 | # SELECT "ratings".* FROM "ratings" WHERE "ratings"."restaurant_id" = ? LIMIT 1 105 | assert_equal selects.size, 2 106 | 107 | @chef.reload 108 | selects = track_queries do 109 | @chef.represent([:name, :restaurant =>[ :name, :rating =>[ :value ]]]) 110 | end 111 | assert_equal selects.size, 0 112 | end 113 | 114 | it 'tries to fill holes in the cache graph' do 115 | definition = [:name, :restaurant =>[ :name, :rating =>[ :value ]]] 116 | @chef.represent(definition) 117 | Kashmir.caching.clear(definition, @chef) 118 | 119 | assert_equal 2, Kashmir.caching.keys.size 120 | 121 | @chef.reload 122 | selects = track_queries do 123 | @chef.represent(definition) 124 | end 125 | # ratings is still cached 126 | # SELECT "restaurants".* FROM "restaurants" WHERE "restaurants"."owner_id" = ? LIMIT 1 127 | assert_equal selects.size, 1 128 | end 129 | end 130 | 131 | describe 'collections' do 132 | it 'caches every item' do 133 | AR::Recipe.all.represent([:title]) 134 | 135 | cached_keys = %w( 136 | presenter:AR::Recipe:1:[:title] 137 | presenter:AR::Recipe:2:[:title] 138 | ) 139 | 140 | assert_equal cached_keys, Kashmir.caching.keys 141 | end 142 | 143 | it 'presents from cache' do 144 | selects = track_queries do 145 | AR::Recipe.all.represent([:title, :ingredients => [:name]]) 146 | end 147 | # SELECT "recipes_ingredients".* FROM "recipes_ingredients" WHERE "recipes_ingredients"."recipe_id" IN (1, 2) 148 | # SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."id" IN (1, 2, 3, 4) 149 | assert_equal 3, selects.size 150 | 151 | selects = track_queries do 152 | AR::Recipe.all.represent([:title, :ingredients => [:name]]) 153 | end 154 | # SELECT "recipes".* FROM "recipes" 155 | assert_equal 1, selects.size 156 | 157 | cache_keys = [ 158 | "presenter:AR::Ingredient:1:[:name]", 159 | "presenter:AR::Ingredient:2:[:name]", 160 | "presenter:AR::Recipe:1:[:title, {:ingredients=>[:name]}]", 161 | "presenter:AR::Ingredient:3:[:name]", 162 | "presenter:AR::Ingredient:4:[:name]", 163 | "presenter:AR::Recipe:2:[:title, {:ingredients=>[:name]}]" 164 | ] 165 | 166 | assert_equal cache_keys, Kashmir.caching.keys 167 | end 168 | end 169 | 170 | describe 'misc' do 171 | 172 | it 'does not cache already cached results' do 173 | @restaurant.represent([:name]) 174 | assert_equal 1, Kashmir.caching.keys.size 175 | 176 | Kashmir.caching.expects(:store_presenter).never 177 | @restaurant.represent([:name]) 178 | end 179 | 180 | describe 'skipping cache' do 181 | # see current_customer_count in test/support/ar_models.rb 182 | 183 | before(:each) do 184 | @restaurant.update_attributes(current_customer_count: 10) 185 | @representation = @restaurant.represent([:name, :current_customer_count]) 186 | end 187 | 188 | it 'still renders everything normally' do 189 | assert_equal @representation, { name: 'Chef Tom Belly Burgers', current_customer_count: 10 } 190 | end 191 | 192 | it 'does not include that field in the key' do 193 | assert_equal %w(presenter:AR::Restaurant:1:[:name]), Kashmir.caching.keys 194 | end 195 | 196 | it 'does not insert value in the cached results' do 197 | assert_nil Kashmir.caching.from_cache([:name, :current_customar_count], @restaurant) 198 | assert_equal Kashmir.caching.from_cache([:name], @restaurant), { name: 'Chef Tom Belly Burgers' } 199 | end 200 | end 201 | 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Open Source at IFTTT](http://ifttt.github.io/images/open-source-ifttt.svg)](http://ifttt.github.io) 2 | 3 | ![Kashmir](https://raw.githubusercontent.com/IFTTT/kashmir/images/images/kashmirbanner.jpg?token=AAIf5wn0aFvxx1oNOO6GVw7SO4vENFW4ks5VuSaLwA%3D%3D "Kashmir") 4 | 5 | Kashmir is a Ruby DSL that makes serializing and caching objects a snap. 6 | 7 | Kashmir allows you to describe representations of Ruby objects. It will generate hashes from these Ruby objects using the representation and dependency tree that you specify. 8 | 9 | `Kashmir::ActiveRecord` will also optimize and try to balance `ActiveRecord` queries so your application hits the database as little as possible. 10 | 11 | `Kashmir::Caching` builds a dependency tree for complex object representations and caches each level of this tree separately. Kashmir will do so by creating cache views of each level as well as caching a complete tree. 12 | The caching engine is smart enough to fill holes in the cache tree with fresh data from your data store. 13 | 14 | Combine `Kashmir::Caching` + `Kashmir::ActiveRecord` for extra awesomeness. 15 | 16 | ### Example: 17 | 18 | For example, a `Person` with `name` and `age` attributes: 19 | ```ruby 20 | class Person 21 | include Kashmir 22 | 23 | def initialize(name, age) 24 | @name = name 25 | @age = age 26 | end 27 | 28 | representations do 29 | rep :name 30 | rep :age 31 | end 32 | end 33 | ``` 34 | could be represented as: 35 | ``` 36 | { name: 'Netto Farah', age: 26 } 37 | ``` 38 | 39 | Representing an object is as simple as: 40 | 41 | 1. Add `include Kashmir` to the target class. 42 | 2. Whitelist all the fields you want to include in a representation. 43 | 44 | ```ruby 45 | # Add fields and methods you want to be visible to Kashmir 46 | representations do 47 | rep(:name) 48 | rep(:age) 49 | end 50 | ``` 51 | 52 | 3. Instantiate an object and `#represent` it. 53 | ```ruby 54 | # Pass in an array with all the fields you want included 55 | Person.new('Netto Farah', 26).represent([:name, :age]) 56 | => {:name=>"Netto Farah", :age=>"26"} 57 | ``` 58 | 59 | ## Installation 60 | 61 | Add this line to your application's Gemfile: 62 | 63 | ```ruby 64 | gem 'kashmir' 65 | ``` 66 | 67 | And then execute: 68 | 69 | $ bundle 70 | 71 | ## Usage 72 | Kashmir is better described with examples. 73 | 74 | ### Basic Representations 75 | 76 | #### Describing an Object 77 | Only whitelisted fields can be represented by Kashmir. 78 | This is done so sensitive fields (like passwords) cannot be accidentally exposed to clients. 79 | 80 | ``` ruby 81 | class Recipe < OpenStruct 82 | include Kashmir 83 | 84 | representations do 85 | rep(:title) 86 | rep(:preparation_time) 87 | end 88 | end 89 | ``` 90 | 91 | Instantiate a `Recipe`: 92 | ```ruby 93 | recipe = Recipe.new(title: 'Beef Stew', preparation_time: 60) 94 | ``` 95 | 96 | Kashmir automatically adds a `#represent` method to every instance of `Recipe`. 97 | `#represent` takes an `Array` with all the fields you want as part of your representation. 98 | 99 | ```ruby 100 | recipe.represent([:title, :preparation_time]) 101 | => { title: 'Beef Stew', preparation_time: 60 } 102 | ``` 103 | #### Calculated Fields 104 | You can represent any instance variable or method (basically anything that returns `true` for `respond_to?`). 105 | ``` ruby 106 | class Recipe < OpenStruct 107 | include Kashmir 108 | 109 | representations do 110 | rep(:title) 111 | rep(:num_steps) 112 | end 113 | 114 | def num_steps 115 | steps.size 116 | end 117 | end 118 | ``` 119 | 120 | ```ruby 121 | Recipe.new(title: 'Beef Stew', steps: ['chop', 'cook']).represent([:title, :num_steps]) 122 | => { title: 'Beef Stew', num_steps: 2 } 123 | ``` 124 | 125 | ### Nested Representations 126 | You can nest Kashmir objects to represent complex relationships between your objects. 127 | ```ruby 128 | class Recipe < OpenStruct 129 | include Kashmir 130 | 131 | representations do 132 | rep(:title) 133 | rep(:chef) 134 | end 135 | end 136 | 137 | class Chef < OpenStruct 138 | include Kashmir 139 | 140 | representations do 141 | base([:name]) 142 | end 143 | end 144 | ``` 145 | 146 | When you create a representation, nest hashes to create nested representations. 147 | ```ruby 148 | netto = Chef.new(name: 'Netto Farah') 149 | beef_stew = Recipe.new(title: 'Beef Stew', chef: netto) 150 | 151 | beef_stew.represent([:title, { :chef => [ :name ] }]) 152 | => { 153 | :title => "Beef Stew", 154 | :chef => { 155 | :name => 'Netto Farah' 156 | } 157 | } 158 | ``` 159 | Not happy with this syntax? Check out `Kashmir::DSL` or `Kashmir::InlineDSL` for prettier code. 160 | 161 | #### Base Representations 162 | Are you tired of repeating the same fields over and over? 163 | You can create a base representation of your objects, so Kashmir returns basic fields automatically. 164 | ```ruby 165 | class Recipe 166 | include Kashmir 167 | 168 | representations do 169 | base [:title, :preparation_time] 170 | rep :num_steps 171 | rep :chef 172 | end 173 | end 174 | ``` 175 | `base(...)` takes an array with the fields you want to return on every representation of a given class. 176 | 177 | ```ruby 178 | brisket = Recipe.new(title: 'BBQ Brisket', preparation_time: 'a long time') 179 | brisket.represent() 180 | => { :title => 'BBQ Brisket', :preparation_time => 'a long time' } 181 | ``` 182 | 183 | ### Complex Representations 184 | You can nest as many Kashmir objects as you want. 185 | ```ruby 186 | class Recipe < OpenStruct 187 | include Kashmir 188 | 189 | representations do 190 | base [:title] 191 | rep :chef 192 | end 193 | end 194 | 195 | class Chef < OpenStruct 196 | include Kashmir 197 | 198 | representations do 199 | base :name 200 | rep :restaurant 201 | end 202 | end 203 | 204 | class Restaurant < OpenStruct 205 | include Kashmir 206 | 207 | representations do 208 | base [:name] 209 | rep :rating 210 | end 211 | end 212 | ``` 213 | 214 | ```ruby 215 | bbq_joint = Restaurant.new(name: "Netto's BBQ Joint", rating: '5 Stars') 216 | netto = Chef.new(name: 'Netto', restaurant: bbq_joint) 217 | brisket = Recipe.new(title: 'BBQ Brisket', chef: netto) 218 | 219 | brisket.represent([ 220 | :chef => [ 221 | { :restaurant => [ :rating ] } 222 | ] 223 | ]) 224 | 225 | => { 226 | title: 'BBQ Brisket', 227 | chef: { 228 | name: 'Netto', 229 | restaurant: { 230 | name: "Netto's BBQ Joint", 231 | rating: '5 Stars' 232 | } 233 | } 234 | } 235 | 236 | ``` 237 | 238 | 239 | ### Collections 240 | Arrays of Kashmir objects work the same way as any other Kashmir representations. 241 | Kashmir will augment `Array` with `#represent` that will represent every item in the array. 242 | 243 | ```ruby 244 | class Ingredient < OpenStruct 245 | include Kashmir 246 | 247 | representations do 248 | rep(:name) 249 | rep(:quantity) 250 | end 251 | end 252 | 253 | class ClassyRecipe < OpenStruct 254 | include Kashmir 255 | 256 | representations do 257 | rep(:title) 258 | rep(:ingredients) 259 | end 260 | end 261 | ``` 262 | ```ruby 263 | omelette = ClassyRecipe.new(title: 'Omelette Du Fromage') 264 | omelette.ingredients = [ 265 | Ingredient.new(name: 'Egg', quantity: 2), 266 | Ingredient.new(name: 'Cheese', quantity: 'a lot!') 267 | ] 268 | ``` 269 | Just describe your `Array` representations like any regular nested representation. 270 | ```ruby 271 | omelette.represent([:title, { 272 | :ingredients => [ :name, :quantity ] 273 | } 274 | ]) 275 | ``` 276 | ```ruby 277 | => { 278 | title: 'Omelette Du Fromage', 279 | ingredients: [ 280 | { name: 'Egg', quantity: 2 }, 281 | { name: 'Cheese', quantity: 'a lot!' } 282 | ] 283 | } 284 | ``` 285 | ### `Kashmir::Dsl` 286 | Passing arrays and hashes around can be very tedious and lead to duplication. 287 | `Kashmir::Dsl` allows you to create your own representers/decorators so you can keep your logic in one place and make way more expressive. 288 | 289 | ```ruby 290 | class Recipe < OpenStruct 291 | include Kashmir 292 | 293 | representations do 294 | rep(:title) 295 | rep(:num_steps) 296 | end 297 | end 298 | 299 | class RecipeRepresenter 300 | include Kashmir::Dsl 301 | 302 | prop :title 303 | prop :num_steps 304 | end 305 | ``` 306 | 307 | All you need to do is include `Kashmir::Dsl` in any ruby class. Every call to `prop(field_name)` will translate directly into just adding an extra field in the representation array. 308 | 309 | 310 | In this case, `RecipeRepresenter` will translate directly to `[:title, :num_steps]`. 311 | 312 | ```ruby 313 | brisket = Recipe.new(title: 'BBQ Brisket', num_steps: 2) 314 | brisket.represent(RecipePresenter) 315 | 316 | => { title: 'BBQ Brisket', num_steps: 2 } 317 | ``` 318 | #### Embedded Representers 319 | It is also possible to define nested representers with `embed(:property_name, RepresenterClass)`. 320 | 321 | ```ruby 322 | class RecipeWithChefRepresenter 323 | include Kashmir::Dsl 324 | 325 | prop :title 326 | embed :chef, ChefRepresenter 327 | end 328 | 329 | class ChefRepresenter 330 | include Kashmir::Dsl 331 | 332 | prop :full_name 333 | end 334 | ``` 335 | Kashmir will inline these classes and return a raw Kashmir description. 336 | ```ruby 337 | RecipeWithChefRepresenter.definitions == [ :title, { :chef => [ :full_name ] }] 338 | => true 339 | ``` 340 | Representing the objects will work just as before. 341 | ```ruby 342 | chef = Chef.new(first_name: 'Netto', last_name: 'Farah') 343 | brisket = Recipe.new(title: 'BBQ Brisket', chef: chef) 344 | 345 | brisket.represent(RecipeWithChefRepresenter) 346 | 347 | => { 348 | title: 'BBQ Brisket', 349 | chef: { 350 | full_name: 'Netto Farah' 351 | } 352 | } 353 | ``` 354 | #### Inline Representers 355 | You don't necessarily need to define a class for every nested representation. 356 | ```ruby 357 | class RecipeWithInlineChefRepresenter 358 | include Kashmir::Dsl 359 | 360 | prop :title 361 | 362 | inline :chef do 363 | prop :full_name 364 | end 365 | end 366 | ``` 367 | Using `inline(:property_name, &block)` will work the same way as `embed`. Except that you can now define short representations using ruby blocks. Leading us to our next topic. 368 | 369 | ### `Kashmir::InlineDsl` 370 | `Kashmir::InlineDsl` sits right in between raw representations and Representers. It reads much better than arrays of hashes and provides the expressiveness of `Kashmir::Dsl` without all the ceremony. 371 | 372 | It works with every feature from `Kashmir::Dsl` and allows you to define quick inline descriptions for your `Kashmir` objects. 373 | 374 | ```ruby 375 | class Recipe < OpenStruct 376 | include Kashmir 377 | 378 | representations do 379 | rep(:title) 380 | rep(:num_steps) 381 | end 382 | end 383 | ``` 384 | Just call `#represent_with(&block)` on any `Kashmir` object and use the `Kashmir::Dsl` syntax. 385 | ```ruby 386 | brisket = Recipe.new(title: 'BBQ Brisket', num_steps: 2) 387 | 388 | brisket.represent_with do 389 | prop :title 390 | prop :num_steps 391 | end 392 | 393 | => { title: 'BBQ Brisket', num_steps: 2 } 394 | ``` 395 | 396 | #### Nested Inline Representations 397 | You can nest inline representations using `inline(:field, &block)` the same way we did with `Kashmir::Dsl`. 398 | 399 | ```ruby 400 | class Ingredient < OpenStruct 401 | include Kashmir 402 | 403 | representations do 404 | rep(:name) 405 | rep(:quantity) 406 | end 407 | end 408 | 409 | class ClassyRecipe < OpenStruct 410 | include Kashmir 411 | 412 | representations do 413 | rep(:title) 414 | rep(:ingredients) 415 | end 416 | end 417 | ``` 418 | ```ruby 419 | omelette = ClassyRecipe.new(title: 'Omelette Du Fromage') 420 | omelette.ingredients = [ 421 | Ingredient.new(name: 'Egg', quantity: 2), 422 | Ingredient.new(name: 'Cheese', quantity: 'a lot!') 423 | ] 424 | ``` 425 | Just call `#represent_with(&block)` and start nesting other inline representations. 426 | ```ruby 427 | omelette.represent_with do 428 | prop :title 429 | inline :ingredients do 430 | prop :name 431 | prop :quantity 432 | end 433 | end 434 | 435 | => { 436 | title: 'Omelette Du Fromage', 437 | ingredients: [ 438 | { name: 'Egg', quantity: 2 }, 439 | { name: 'Cheese', quantity: 'a lot!' } 440 | ] 441 | } 442 | ``` 443 | 444 | Inline representations can become lengthy and confusing over time. 445 | If you find yourself nesting more than two levels or including more than 3 or 4 fields per level consider creating Representers with `Kashmir::Dsl`. 446 | 447 | ### `Kashmir::ActiveRecord` 448 | Kashmir works just as well with ActiveRecord. `ActiveRecord::Relation`s can be used as Kashmir representations just as any other classes. 449 | 450 | Kashmir will attempt to preload every `ActiveRecord::Relation` defined as representations automatically by using `ActiveRecord::Associations::Preloader`. This will guarantee that you don't run into N+1 queries while representing collections and dependent objects. 451 | 452 | Here's an example of how Kashmir will attempt to optimize database queries: 453 | 454 | ```ruby 455 | ActiveRecord::Schema.define do 456 | create_table :recipes, force: true do |t| 457 | t.column :title, :string 458 | t.column :num_steps, :integer 459 | t.column :chef_id, :integer 460 | end 461 | 462 | create_table :chefs, force: true do |t| 463 | t.column :name, :string 464 | end 465 | end 466 | ``` 467 | ```ruby 468 | module AR 469 | class Recipe < ActiveRecord::Base 470 | include Kashmir 471 | 472 | belongs_to :chef 473 | 474 | representations do 475 | rep :title 476 | rep :chef 477 | end 478 | end 479 | 480 | class Chef < ActiveRecord::Base 481 | include Kashmir 482 | 483 | has_many :recipes 484 | 485 | representations do 486 | rep :name 487 | rep :recipes 488 | end 489 | end 490 | end 491 | ``` 492 | 493 | ```ruby 494 | AR::Chef.all.each do |chef| 495 | chef.recipes.to_a 496 | end 497 | ``` 498 | will generate 499 | ```sql 500 | SELECT * FROM chefs 501 | SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" = ? 502 | SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" = ? 503 | ``` 504 | 505 | With Kashmir: 506 | ```ruby 507 | AR::Chef.all.represent([:recipes]) 508 | ``` 509 | ```sql 510 | SELECT "chefs".* FROM "chefs" 511 | SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id" IN (1, 2) 512 | ``` 513 | 514 | For more examples, check out: https://github.com/IFTTT/kashmir/blob/master/test/activerecord_tricks_test.rb 515 | 516 | ### `Kashmir::Caching` (Experimental) 517 | Caching is the best feature in Kashmir. 518 | The `Kashmir::Caching` module will cache every level of the dependency tree Kashmir generates when representing an object. 519 | 520 | ![Dependency Tree](https://raw.githubusercontent.com/IFTTT/kashmir/images/images/kashmir.png?token=AAIf57rtAVfFPENYmWfBJ9nhZOmbFs1qks5VuVFOwA%3D%3D "Dependency Tree") 521 | 522 | As you can see in the image above, Kashmir will build a dependency tree of the representation. 523 | If you have Caching on, Kashmir will: 524 | 525 | - Build a cache key for each individual object (green) 526 | - Wrap complex dependencies into their on cache key (blue and pink) 527 | - Wrap the whole representation into one unique cache key (red) 528 | 529 | Each layer gets its own cache keys which can be expired at different times. 530 | Kashmir will also be able to fill in blanks in the dependency tree and fetch missing objects individually. 531 | 532 | Caching is turned off by default, but you can use one of the two available implementations. 533 | 534 | - [In Memory Caching] https://github.com/IFTTT/kashmir/blob/master/lib/kashmir/plugins/memory_caching.rb 535 | - [Memcached] https://github.com/IFTTT/kashmir/blob/master/lib/kashmir/plugins/memcached_caching.rb 536 | 537 | You can also build your own custom caching engine by following the `NullCaching` protocol available at: 538 | https://github.com/IFTTT/kashmir/blob/master/lib/kashmir/plugins/null_caching.rb 539 | 540 | #### Enabling `Kashmir::Caching` 541 | ##### In Memory 542 | ```ruby 543 | Kashmir.init( 544 | cache_client: Kashmir::Caching::Memory.new 545 | ) 546 | ``` 547 | 548 | ##### With Memcached 549 | ```ruby 550 | require 'kashmir/plugins/memcached_caching' 551 | 552 | client = Dalli::Client.new(url, namespace: 'kashmir', compress: true) 553 | default_ttl = 5.minutes 554 | 555 | Kashmir.init( 556 | cache_client: Kashmir::Caching::Memcached.new(client, default_ttl) 557 | ) 558 | ``` 559 | 560 | For more advanced examples, check out: https://github.com/IFTTT/kashmir/blob/master/test/caching_test.rb 561 | 562 | ## Contributing 563 | 564 | 1. Fork it ( https://github.com/[my-github-username]/kashmir/fork ) 565 | 2. Create your feature branch (`git checkout -b my-new-feature`) 566 | 3. Commit your changes (`git commit -am 'Add some feature'`) 567 | 4. Push to the branch (`git push origin my-new-feature`) 568 | 5. Create a new Pull Request 569 | 570 | 571 | --------------------------------------------------------------------------------