Skip to content

Instantly share code, notes, and snippets.

@maxim
Last active May 11, 2023 16:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save maxim/4dc1765d9f81ac849325d166d14d2dfa to your computer and use it in GitHub Desktop.
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
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
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">
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"]
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
@soulcutter
Copy link

In 0-proof-of-concept.rb:

keyword :size, default: 'string'

my brain is stuck on the default being a string. I’m sure that isn’t what is intended to be demonstrated. Or is there something about that?

@maxim
Copy link
Author

maxim commented May 8, 2023

@soulcutter hahah, I was thinking of removing it. It's a non-obvious demo that even a default runs through the guard (as per first usage example):

Foo.new(word: 'abcdef') # => ArgumentError: size must be an integer

Edited the gist for clarity.

@maxim
Copy link
Author

maxim commented May 8, 2023

@soulcutter would love to explain if still isn't clear. Please let me know.

@soulcutter
Copy link

That definitely helps!

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