-
-
Save maxim/4dc1765d9f81ac849325d166d14d2dfa to your computer and use it in GitHub Desktop.
Portrayal::Contracts (proof of concept). Portrayal gem is here: http://github.com/maxim/portrayal
This file contains hidden or 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
class Foo | |
extend Portrayal | |
keyword :word | |
keyword :size, default: 'oops-not-an-integer' # Contract-violating default also gets blocked. | |
public :word=, :size= | |
contract('word must be String >5 chars') { String === word && word.size > 5 } | |
contract('size must be an integer') { Integer === size } | |
contract('size must equal length of word') { size == word.length } | |
end |
This file contains hidden or 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
Foo.portrayal.list_contracts # => ["word must be String >5 chars", "size must be an integer", "size must equal length of word"] | |
Foo.new(word: 'abcdef') # => ArgumentError: size must be an integer <===== size's default "oops-not-an-integer" causes this | |
Foo.new(word: 'abcdef', size: 'x') # => ArgumentError: size must be an integer | |
Foo.new(word: 'abcdef', size: 1) # => ArgumentError: size must equal length of word | |
f = Foo.new(word: 'abcdef', size: 6) # => #<Foo @size=6, @word="abcdef"> | |
f.word = 'abc' # => ArgumentError: word must be String >5 chars | |
f.word = 'abcdefg' # => ArgumentError: size must equal length of word | |
f.word # => 'abcdef' | |
f.size = 1 # => ArgumentError: size must equal length of word | |
f.size # => 6 | |
f.word = 'xoxoxo' # => "xoxoxo" | |
f.update(word: 'abc', size: 1) # => ["word must be String >5 chars", "size must equal length of word"] | |
f.update(word: '123456', size: 1) # => ["size must equal length of word"] | |
f.update(word: '1234567', size: 7) # => nil | |
f # => #<Foo @size=7, @word="1234567"> |
This file contains hidden or 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 CommonTypes | |
def int(name) | |
contract("#{name} must be an integer") { Integer === send(name) } | |
name | |
end | |
def odd(name) | |
contract("#{name} must be odd") { send(name).odd? } | |
name | |
end | |
def md5(name) | |
contract("#{name} must be an md5 string") { | |
string = send(name) | |
String === string && string.match?(/\A[a-f0-9]{32}\z/) | |
} | |
name | |
end | |
end | |
class Bar | |
extend Portrayal | |
extend CommonTypes | |
md5 keyword :token | |
odd int keyword :number # <- they're composable too! | |
end | |
Bar.new(token: "foo", number: 'x') # => ArgumentError: token must be an md5 string | |
Bar.new(token: "1ddcb92ade31c8fbd370001f9b29a7d9", number: 'x') # => ArgumentError: number must be an integer | |
Bar.new(token: "1ddcb92ade31c8fbd370001f9b29a7d9", number: 2) # => ArgumentError: number must be odd | |
Bar.new(token: "1ddcb92ade31c8fbd370001f9b29a7d9", number: 3) # => #<Bar @number=3, @token="1ddcb92ade31c8fbd370001f9b29a7d9"> | |
Bar.portrayal.list_contracts | |
=> ["token must be an md5 string", "number must be an integer", "number must be odd"] |
This file contains hidden or 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 Portrayal::Contracts | |
class Contract | |
attr_reader :name, :block | |
def initialize(name, blk); @name, @block = name, blk end | |
def enforce(obj); raise ArgumentError, name unless pass?(obj) end | |
def failure(obj); name unless pass?(obj) end | |
def pass?(obj); !!obj.instance_exec(&@block) end | |
def initialize_dup(src); @name, @block = src.name.dup, src.block.dup end | |
end | |
module SchemaExt | |
attr_reader :contracts, :contract_module | |
def initialize(*); @contracts = []; @contract_module = Module.new; super end | |
def collect_failures(obj); contracts.map { |c| c.failure(obj) }.compact end | |
def add_contract(name, blk); @contracts << Contract.new(name, blk) end | |
def list_contracts; contracts.map(&:name) end | |
def set_changes(obj, hash) | |
obj.instance_variable_set(:@__portrayal_cskip, true) | |
hash.each { |k, v| obj.send("#{k}=", v) } | |
ensure | |
obj.remove_instance_variable(:@__portrayal_cskip) | |
end | |
def initialize_dup(other) | |
@contracts = other.contracts.map(&:dup) | |
@contract_module = other.contract_module.dup | |
super | |
end | |
def enforce_contracts(obj) | |
return if obj.instance_variable_get(:@__portrayal_cskip) | |
contracts.each { |c| c.enforce(obj) } | |
end | |
def update_contract_module! | |
@contract_module.module_eval(render_contract_module_code) | |
end | |
def try_changes(obj, changes) | |
sandbox = obj.dup | |
sandbox.class.portrayal.set_changes(sandbox, changes) | |
sandbox.class.portrayal.collect_failures(sandbox) | |
end | |
def render_contract_module_code | |
writers = keywords.map { |k| | |
<<-RUBY | |
protected def #{k}=(v) | |
return super if @__portrayal_cskip | |
failures = self.class.portrayal.try_changes(self, #{k}: v) | |
return super if failures.empty? | |
raise ArgumentError, failures[0] | |
end | |
RUBY | |
}.join("\n") | |
<<-RUBY | |
def initialize(*, **) | |
super | |
self.class.portrayal.enforce_contracts(self) | |
end | |
def update(changes) | |
failures = self.class.portrayal.try_changes(self, changes) | |
return failures unless failures.empty? | |
self.class.portrayal.set_changes(self, changes) | |
nil | |
end | |
#{writers} | |
RUBY | |
end | |
end | |
module ClassExt | |
def inherited(c); super(c); c.include(c.portrayal.contract_module) end | |
def contract(name, &block); portrayal.add_contract(name, block) end | |
def keyword(*, **) | |
name = super | |
include portrayal.contract_module | |
portrayal.update_contract_module! | |
name | |
end | |
end | |
::Portrayal::Schema.prepend(SchemaExt) | |
::Portrayal.prepend(ClassExt) | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
That definitely helps!