-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
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
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.