Skip to content

Instantly share code, notes, and snippets.

@stevenjackson
Forked from searls/market_research.rb
Last active October 9, 2016 02:12
Show Gist options
  • Save stevenjackson/0fa50732046432d646c555b0b54ae1d2 to your computer and use it in GitHub Desktop.
Save stevenjackson/0fa50732046432d646c555b0b54ae1d2 to your computer and use it in GitHub Desktop.
Was chatting with @mfeathers about retaining Ruby's chained Enumerable style, but finding a way to inject names that reflects the application domain (as opposed to just littering functional operations everywhere, which may be seen as a sort of Primitive Obsession)
module FunctionalQuery
def initialize(collection)
@collection = collection
end
def result
@collection
end
def average_by_attr(attr)
map {|key, value| [key, Wrapper.new(value).average(attr)] }
end
def average(attr)
map {|el| el[attr]}.reduce(:+).to_f / size
end
def transform_keys
@collection = {}.tap do |transformed|
@collection.each_key do |key|
transformed[yield(key)] = @collection[key]
end
end
self
end
def method_missing(method, *args, &block)
if @collection.respond_to?(method)
response = @collection.send(method, *args, &block)
if response.is_a? Enumerable
@collection = response
self
else
response
end
else
super
end
end
class Wrapper
include FunctionalQuery
end
end
# A little toy file demonstrating how to build chainable
# data transformations that reveal some amount of intent
# through named extracted methods.
#
# Kudos to @mfeathers for giving me the idea to try this
#
# Copyright Test Double, LLC, 2016. All Rights Reserved.
require_relative "marketing_refinements"
require_relative "marketing_query"
require_relative "marketing_transform"
class MarketResearch
# Vanilla / Anonymous / Primitive approach to chaining
def income_by_smoking(data)
Hash[
data.reject {|p| p[:income] < 10_000 }.
group_by { |p| p[:smoker] }.
map { |(is_smoker, people)|
[
is_smoker ? :smokers : :non_smokers,
people.map {|p| p[:income]}.reduce(:+).to_f / people.size
]
}
]
end
# Refined approach to tacking named domain abstractions onto Array/Hash
using MarketingRefinements
def income_by_smoking_fancy(data)
data.exclude_incomes_under(10_000).
separate_people_by(:smoker).
average_income_by_smoking
end
#Wrap data and use method_missing to delegate to Array/Hash
def income_by_smoking_query(data)
MarketingQuery.new(data).exclude_incomes_under(10_000).
separate_people_by(:smoker).
average_income_by_smoking.
result
end
#Declarative DSL style
def income_by_smoking_transform(data)
MarketingTransform.new(data).exclude_incomes_under(10_000).
separate_people_by(:smoker).
average_income_by_smoking.
result
end
end
DATA = [
{age: 19, smoker: false, income: 10_000, education: :high_school},
{age: 49, smoker: true, income: 120_000, education: :bachelors},
{age: 55, smoker: false, income: 400_000, education: :masters},
{age: 23, smoker: true, income: 10_000, education: :bachelors},
{age: 70, smoker: false, income: 70_000, education: :phd },
{age: 34, smoker: false, income: 90_000, education: :masters},
{age: 90, smoker: true, income: 0, education: :high_school},
]
original_result = MarketResearch.new.income_by_smoking(DATA)
fancy_result = MarketResearch.new.income_by_smoking_fancy(DATA)
query_result = MarketResearch.new.income_by_smoking_query(DATA)
transform_result = MarketResearch.new.income_by_smoking_transform(DATA)
puts <<-MSG
Original result: #{original_result}
Fancy result: #{fancy_result}
Query result: #{query_result}
Transform result: #{transform_result}
MSG
require_relative 'functional_query'
class MarketingQuery
include FunctionalQuery
def exclude_incomes_under(min)
reject {|p| p[:income] < min }
end
def separate_people_by(attribute)
group_by { |p| p[attribute] }
end
def average_income_by_smoking
transform_keys { |is_smoker|
is_smoker ? :smokers : :non_smokers
}.average_by_attr(:income).to_h
end
end
module MarketingRefinements
refine Array do
# Domain-specific
def exclude_incomes_under(min)
reject {|p| p[:income] < min }
end
def separate_people_by(attribute)
group_by { |p| p[attribute] }
end
# General-purpose
def average(attr)
map {|el| el[attr]}.reduce(:+).to_f / size
end
end
refine Hash do
# Domain-specific
def average_income_by_smoking
Hash[
transform_keys { |is_smoker|
is_smoker ? :smokers : :non_smokers
}.map {|key, people|
[key, people.average(:income)]
}
]
end
# General-purpose
def transform_keys
{}.tap do |result|
self.each_key do |key|
result[yield(key)] = self[key]
end
end
end
end
end
require_relative 'transformer'
class MarketingTransform
include Transformer
transform :exclude_incomes_under, -> (min) { reject {|p| p[:income] < min } }
transform :separate_people_by, -> (attribute) { group_by { |p| p[attribute] } }
transform :average_income_by_smoking, -> do
transform_keys { |is_smoker| is_smoker ? :smokers : :non_smokers }.
average_by_attr(:income).
to_h
end
end
module Transformer
def initialize(collection)
@collection = collection
end
def self.included(cls)
cls.extend Transforms
end
def result
@collection
end
def average_by_attr(attr)
map {|key, value| [key, Wrapper.new(value).average(attr)] }
end
def average(attr)
map {|el| el[attr]}.reduce(:+).to_f / size
end
def transform_keys
@collection = {}.tap do |transformed|
@collection.each_key do |key|
transformed[yield(key)] = @collection[key]
end
end
self
end
def method_missing(method, *args, &block)
if @collection.respond_to?(method)
response = @collection.send(method, *args, &block)
if response.is_a? Enumerable
@collection = response
self
else
response
end
else
super
end
end
module Transforms
def transform(name, body)
define_method(name) do |*args|
instance_exec(*args, &body)
end
end
end
class Wrapper
include Transformer
end
end
@stevenjackson
Copy link
Author

stevenjackson commented Oct 9, 2016

MarketingQuery: Attempt to wrap collection to avoid refinements. Original idea was a first-class collection, but it didn't feel like one by the end.

@stevenjackson
Copy link
Author

MarketingTransform: Slight tweak to try declarative style...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment