trailblazer 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +1 -1
- data/.travis.yml +5 -0
- data/CHANGES.md +3 -0
- data/Gemfile +4 -0
- data/README.md +417 -16
- data/Rakefile +14 -0
- data/THOUGHTS +12 -0
- data/TODO.md +6 -0
- data/doc/Trb-The-Stack.png +0 -0
- data/doc/trb.jpg +0 -0
- data/gemfiles/Gemfile.rails +7 -0
- data/gemfiles/Gemfile.rails.lock +99 -0
- data/lib/trailblazer.rb +2 -0
- data/lib/trailblazer/autoloading.rb +5 -0
- data/lib/trailblazer/operation.rb +124 -0
- data/lib/trailblazer/operation/controller.rb +76 -0
- data/lib/trailblazer/operation/crud.rb +61 -0
- data/lib/trailblazer/operation/representer.rb +18 -0
- data/lib/trailblazer/operation/responder.rb +24 -0
- data/lib/trailblazer/operation/uploaded_file.rb +77 -0
- data/lib/trailblazer/operation/worker.rb +96 -0
- data/lib/trailblazer/version.rb +1 -1
- data/test/crud_test.rb +115 -0
- data/test/fixtures/apotomo.png +0 -0
- data/test/fixtures/cells.png +0 -0
- data/test/operation_test.rb +334 -0
- data/test/rails/controller_test.rb +175 -0
- data/test/rails/fake_app/app-cells/.gitkeep +0 -0
- data/test/rails/fake_app/cells.rb +21 -0
- data/test/rails/fake_app/config.rb +3 -0
- data/test/rails/fake_app/controllers.rb +101 -0
- data/test/rails/fake_app/models.rb +13 -0
- data/test/rails/fake_app/rails_app.rb +57 -0
- data/test/rails/fake_app/song/operations.rb +63 -0
- data/test/rails/fake_app/views/bands/show.html.erb +1 -0
- data/test/rails/fake_app/views/songs/new.html.erb +1 -0
- data/test/rails/test_helper.rb +4 -0
- data/test/responder_test.rb +77 -0
- data/test/test_helper.rb +15 -0
- data/test/uploaded_file_test.rb +85 -0
- data/test/worker_test.rb +116 -0
- data/trailblazer.gemspec +10 -2
- metadata +160 -23
@@ -0,0 +1,24 @@
|
|
1
|
+
module Trailblazer::Operation::Responder
|
2
|
+
# TODO: test me.
|
3
|
+
def self.included(base)
|
4
|
+
base.extend ClassMethods
|
5
|
+
end
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
def model_name
|
9
|
+
model_class.model_name
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
extend Forwardable
|
14
|
+
def_delegators :@model, :to_param, :destroyed?, :persisted?
|
15
|
+
|
16
|
+
def errors
|
17
|
+
return [] if @valid
|
18
|
+
[1]
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_json(*)
|
22
|
+
self.class.representer_class.new(model).to_json
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'trailblazer/operation'
|
2
|
+
require 'action_dispatch/http/upload'
|
3
|
+
require 'tempfile'
|
4
|
+
|
5
|
+
module Trailblazer
|
6
|
+
# TODO: document:
|
7
|
+
# to_hash
|
8
|
+
# from_hash
|
9
|
+
# initialize/tmp_dir
|
10
|
+
class Operation::UploadedFile
|
11
|
+
def initialize(uploaded, options={})
|
12
|
+
@uploaded = uploaded
|
13
|
+
@options = options
|
14
|
+
@tmp_dir = options[:tmp_dir]
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_hash
|
18
|
+
path = persist!
|
19
|
+
|
20
|
+
hash = {
|
21
|
+
:filename => @uploaded.original_filename,
|
22
|
+
:type => @uploaded.content_type,
|
23
|
+
:tempfile_path => path
|
24
|
+
}
|
25
|
+
|
26
|
+
cleanup!
|
27
|
+
|
28
|
+
hash
|
29
|
+
end
|
30
|
+
|
31
|
+
# Returns a ActionDispatch::Http::UploadedFile as if the upload was in the same request.
|
32
|
+
def self.from_hash(hash)
|
33
|
+
suffix = File.extname(hash[:filename])
|
34
|
+
|
35
|
+
# we need to create a Tempfile to make Http::UploadedFile work.
|
36
|
+
tmp = Tempfile.new(["bla", suffix]) # always force file suffix to avoid problems with imagemagick etc.
|
37
|
+
file = File.open(hash[:tempfile_path])# doesn't close automatically :( # fixme: introduce strategy (Tempfile:=>slow, File:=> hopefully less memory footprint)
|
38
|
+
tmp.write(file.read) # DISCUSS: We need Tempfile.new(<File>) to avoid this slow and memory-consuming mechanics.
|
39
|
+
|
40
|
+
file.close # TODO: can we test that?
|
41
|
+
File.unlink(file)
|
42
|
+
|
43
|
+
ActionDispatch::Http::UploadedFile.new(hash.merge(:tempfile => tmp))
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
attr_reader :tmp_dir
|
48
|
+
|
49
|
+
# convert Tempfile from Rails upload into persistent "temp" file so it is available in workers.
|
50
|
+
def persist!
|
51
|
+
path = @uploaded.path # original Tempfile path (from Rails).
|
52
|
+
path = path_with_tmp_dir(path)
|
53
|
+
|
54
|
+
path = path + "_trailblazer_upload"
|
55
|
+
|
56
|
+
FileUtils.mv(@uploaded.path, path) # move Rails upload file into persistent `path`.
|
57
|
+
path
|
58
|
+
end
|
59
|
+
|
60
|
+
def path_with_tmp_dir(path)
|
61
|
+
return path unless tmp_dir # if tmp_dir set, create path in it.
|
62
|
+
|
63
|
+
@with_tmp_dir = Tempfile.new(File.basename(path), tmp_dir)
|
64
|
+
@with_tmp_dir.path # use Tempfile to create nested dirs (os-dependent.)
|
65
|
+
end
|
66
|
+
|
67
|
+
def delete!(file)
|
68
|
+
file.close
|
69
|
+
file.unlink # the Rails uploaded file is already unlinked since moved.
|
70
|
+
end
|
71
|
+
|
72
|
+
def cleanup!
|
73
|
+
delete!(@uploaded.tempfile) if @uploaded.respond_to?(:tempfile) # this is Rails' uploaded file, not sure if we need to do that. in 3.2, we don't have UploadedFile#close, yet.
|
74
|
+
delete!(@with_tmp_dir) if @with_tmp_dir # we used that file to create a tmp file path below tmp_dir.
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'sidekiq/worker'
|
2
|
+
# require 'active_support/hash_with_indifferent_access'
|
3
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
4
|
+
|
5
|
+
|
6
|
+
class Trailblazer::Operation
|
7
|
+
# only kicks in when Operation::run, #run will still do it real-time
|
8
|
+
module Worker
|
9
|
+
def self.included(base)
|
10
|
+
base.send(:include, Sidekiq::Worker) # TODO: this will work with any bg gem.
|
11
|
+
base.extend(ClassMethods)
|
12
|
+
end
|
13
|
+
|
14
|
+
module ClassMethods
|
15
|
+
def run(params)
|
16
|
+
if background?
|
17
|
+
return perform_async(serializable(params))
|
18
|
+
end
|
19
|
+
|
20
|
+
new.run(params)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
def background? # TODO: make configurable.
|
25
|
+
true
|
26
|
+
# if Rails.env == "production" or Rails.env == "staging"
|
27
|
+
end
|
28
|
+
|
29
|
+
def serializable(params)
|
30
|
+
params # this is where we convert file uloads into Trailblazer::UploadedFile, etc. soon.
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
# called from Sidekiq.
|
36
|
+
def perform(params)
|
37
|
+
# the serialized params hash from Sidekiq contains a Op::UploadedFile hash.
|
38
|
+
|
39
|
+
# the following code is basically what happens in a controller.
|
40
|
+
# this is a bug in Rails, it doesn't work without requiring as/hash/ina
|
41
|
+
# params = ActiveSupport::HashWithIndifferentAccess.new_from_hash_copying_default(params) # TODO: this might make it ultra-slow as Reform converts it back to strings.
|
42
|
+
params = params.with_indifferent_access
|
43
|
+
|
44
|
+
run(deserializable(params))
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
def deserializable(params)
|
49
|
+
params # this is where we convert file uloads into Trailblazer::UploadedFile, etc. soon.
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
# Overrides ::serializable and #deserializable and handles file properties from the Contract schema.
|
54
|
+
module FileMarshaller
|
55
|
+
# NOTE: this is WIP and the implementation will be more understandable and performant soon.
|
56
|
+
def self.included(base)
|
57
|
+
base.extend ClassMethods
|
58
|
+
end
|
59
|
+
|
60
|
+
|
61
|
+
private
|
62
|
+
module ClassMethods
|
63
|
+
def file_marshaller_representer
|
64
|
+
@file_marshaller_representer ||= contract_class.schema.apply do |dfn|
|
65
|
+
dfn.delete!(:prepare)
|
66
|
+
|
67
|
+
dfn.merge!(
|
68
|
+
:getter => lambda { |*| self[dfn.name.to_sym] },
|
69
|
+
:setter => lambda { |fragment, *| self[dfn.name.to_s] = fragment }
|
70
|
+
) # FIXME: allow both sym and str.
|
71
|
+
|
72
|
+
dfn.merge!(:class => Hash) and next if dfn[:form] # nested properties need a class for deserialization.
|
73
|
+
next unless dfn[:file]
|
74
|
+
|
75
|
+
# TODO: where do we set /tmp/uploads?
|
76
|
+
dfn.merge!(
|
77
|
+
:serialize => lambda { |file, *| Trailblazer::Operation::UploadedFile.new(file, :tmp_dir => "/tmp/uploads").to_hash },
|
78
|
+
:deserialize => lambda { |object, hash, *| Trailblazer::Operation::UploadedFile.from_hash(hash) },
|
79
|
+
:class => Hash
|
80
|
+
)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def serializable(params)
|
85
|
+
file_marshaller_representer.new(params).to_hash
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# todo: do with_indifferent_access in #deserialize and call super here.
|
90
|
+
def deserializable(hash)
|
91
|
+
# self.class.file_marshaller_representer.new({}).extend(Representable::Debug).from_hash(hash)
|
92
|
+
self.class.file_marshaller_representer.new({}.with_indifferent_access).from_hash(hash)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
data/lib/trailblazer/version.rb
CHANGED
data/test/crud_test.rb
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'trailblazer/operation'
|
3
|
+
|
4
|
+
class CrudTest < MiniTest::Spec
|
5
|
+
Song = Struct.new(:title, :id) do
|
6
|
+
class << self
|
7
|
+
attr_accessor :find_result # TODO: eventually, replace with AR test.
|
8
|
+
|
9
|
+
def find(id)
|
10
|
+
find_result
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class CreateOperation < Trailblazer::Operation
|
16
|
+
include CRUD
|
17
|
+
model Song
|
18
|
+
action :create
|
19
|
+
|
20
|
+
contract do
|
21
|
+
property :title
|
22
|
+
validates :title, presence: true
|
23
|
+
end
|
24
|
+
|
25
|
+
def process(params)
|
26
|
+
validate(params[:song]) do |f|
|
27
|
+
f.sync
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
# creates model for you.
|
34
|
+
it { CreateOperation[song: {title: "Blue Rondo a la Turk"}].model.title.must_equal "Blue Rondo a la Turk" }
|
35
|
+
# exposes #model.
|
36
|
+
it { CreateOperation[song: {title: "Blue Rondo a la Turk"}].model.must_be_instance_of Song }
|
37
|
+
|
38
|
+
class ModifyingCreateOperation < CreateOperation
|
39
|
+
def process(params)
|
40
|
+
model.instance_eval { def genre; "Punkrock"; end }
|
41
|
+
|
42
|
+
validate(params[:song]) do |f|
|
43
|
+
f.sync
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# lets you modify model.
|
49
|
+
it { ModifyingCreateOperation[song: {title: "Blue Rondo a la Turk"}].model.title.must_equal "Blue Rondo a la Turk" }
|
50
|
+
it { ModifyingCreateOperation[song: {title: "Blue Rondo a la Turk"}].model.genre.must_equal "Punkrock" }
|
51
|
+
|
52
|
+
# Update
|
53
|
+
class UpdateOperation < CreateOperation
|
54
|
+
action :update
|
55
|
+
end
|
56
|
+
|
57
|
+
# finds model and updates.
|
58
|
+
it do
|
59
|
+
song = CreateOperation[song: {title: "Anchor End"}].model
|
60
|
+
Song.find_result = song
|
61
|
+
|
62
|
+
UpdateOperation[id: song.id, song: {title: "The Rip"}].model.title.must_equal "The Rip"
|
63
|
+
song.title.must_equal "The Rip"
|
64
|
+
end
|
65
|
+
|
66
|
+
# Find == Update
|
67
|
+
class FindOperation < CreateOperation
|
68
|
+
action :find
|
69
|
+
end
|
70
|
+
|
71
|
+
# finds model and updates.
|
72
|
+
it do
|
73
|
+
song = CreateOperation[song: {title: "Anchor End"}].model
|
74
|
+
Song.find_result = song
|
75
|
+
|
76
|
+
FindOperation[id: song.id, song: {title: "The Rip"}].model.title.must_equal "The Rip"
|
77
|
+
song.title.must_equal "The Rip"
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
class DefaultCreateOperation < Trailblazer::Operation
|
82
|
+
include CRUD
|
83
|
+
model Song
|
84
|
+
|
85
|
+
def process(params)
|
86
|
+
self
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# uses :create as default if not set via ::action.
|
91
|
+
it { DefaultCreateOperation[{}].model.must_equal Song.new }
|
92
|
+
|
93
|
+
# model Song, :action
|
94
|
+
class ModelUpdateOperation < CreateOperation
|
95
|
+
model Song, :update
|
96
|
+
end
|
97
|
+
|
98
|
+
# allows ::model, :action.
|
99
|
+
it do
|
100
|
+
Song.find_result = song = Song.new
|
101
|
+
ModelUpdateOperation[{id: 1, song: {title: "Mercy Day For Mr. Vengeance"}}].model.must_equal song
|
102
|
+
end
|
103
|
+
|
104
|
+
# no call to ::model raises error.
|
105
|
+
class NoModelOperation < Trailblazer::Operation
|
106
|
+
include CRUD
|
107
|
+
|
108
|
+
def process(params)
|
109
|
+
self
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# uses :create as default if not set via ::action.
|
114
|
+
it { assert_raises(RuntimeError){ NoModelOperation[{}] } }
|
115
|
+
end
|
Binary file
|
Binary file
|
@@ -0,0 +1,334 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
module Comparable
|
4
|
+
# only used for test.
|
5
|
+
def ==(b)
|
6
|
+
self.class == b.class
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
|
11
|
+
class OperationRunTest < MiniTest::Spec
|
12
|
+
class Operation < Trailblazer::Operation
|
13
|
+
# allow providing your own contract.
|
14
|
+
self.contract_class = class Contract
|
15
|
+
def initialize(*)
|
16
|
+
end
|
17
|
+
def validate(params)
|
18
|
+
return false if params == false # used in ::[] with exception test.
|
19
|
+
"local #{params}"
|
20
|
+
end
|
21
|
+
|
22
|
+
def errors
|
23
|
+
Struct.new(:to_s).new("Op just calls #to_s on Errors!")
|
24
|
+
end
|
25
|
+
|
26
|
+
include Comparable
|
27
|
+
self
|
28
|
+
end
|
29
|
+
|
30
|
+
def process(params)
|
31
|
+
model = Object
|
32
|
+
validate(params, model)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
let (:operation) { Operation.new.extend(Comparable) }
|
37
|
+
|
38
|
+
# contract is inferred from self::contract_class.
|
39
|
+
it { Operation.run(true).must_equal ["local true", operation] }
|
40
|
+
|
41
|
+
# return operation when ::call
|
42
|
+
it { Operation.call(true).must_equal operation }
|
43
|
+
it { Operation[true].must_equal operation }
|
44
|
+
|
45
|
+
# ::[] raises exception when invalid.
|
46
|
+
it do
|
47
|
+
exception = assert_raises(Trailblazer::Operation::InvalidContract) { Operation[false] }
|
48
|
+
exception.message.must_equal "Op just calls #to_s on Errors!"
|
49
|
+
end
|
50
|
+
|
51
|
+
# ::run without block returns result set.
|
52
|
+
it { Operation.run(true).must_equal ["local true", operation] }
|
53
|
+
it { Operation.run(false).must_equal [false, operation] }
|
54
|
+
|
55
|
+
# ::run with block returns operation.
|
56
|
+
# valid executes block.
|
57
|
+
it "block" do
|
58
|
+
outcome = nil
|
59
|
+
res = Operation.run(true) do
|
60
|
+
outcome = "true"
|
61
|
+
end
|
62
|
+
|
63
|
+
outcome.must_equal "true" # block was executed.
|
64
|
+
res.must_equal operation
|
65
|
+
end
|
66
|
+
|
67
|
+
# invalid doesn't execute block.
|
68
|
+
it "block, invalid" do
|
69
|
+
outcome = nil
|
70
|
+
res = Operation.run(false) do
|
71
|
+
outcome = "true"
|
72
|
+
end
|
73
|
+
|
74
|
+
outcome.must_equal nil # block was _not_ executed.
|
75
|
+
res.must_equal operation
|
76
|
+
end
|
77
|
+
|
78
|
+
# block yields operation
|
79
|
+
it do
|
80
|
+
outcome = nil
|
81
|
+
res = Operation.run(true) do |op|
|
82
|
+
outcome = op
|
83
|
+
end
|
84
|
+
|
85
|
+
outcome.must_equal operation # block was executed.
|
86
|
+
res.must_equal operation
|
87
|
+
end
|
88
|
+
|
89
|
+
# Operation#contract returns @contract
|
90
|
+
let (:contract) { Operation::Contract.new }
|
91
|
+
it { Operation[true].contract.must_equal contract }
|
92
|
+
end
|
93
|
+
|
94
|
+
|
95
|
+
class OperationTest < MiniTest::Spec
|
96
|
+
class Operation < Trailblazer::Operation
|
97
|
+
def process(params)
|
98
|
+
validate(Object, params)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# contract is retrieved from ::contract_class.
|
103
|
+
it { assert_raises(NoMethodError) { Operation.run({}) } } # TODO: if you call #validate without defining a contract, the error is quite cryptic.
|
104
|
+
|
105
|
+
# no #process method defined.
|
106
|
+
# DISCUSS: not sure if we need that.
|
107
|
+
# class OperationWithoutProcessMethod < Trailblazer::Operation
|
108
|
+
# end
|
109
|
+
|
110
|
+
# it { OperationWithoutProcessMethod[{}].must_be_kind_of OperationWithoutProcessMethod }
|
111
|
+
|
112
|
+
# #process and no validate.
|
113
|
+
class OperationWithoutValidateCall < Trailblazer::Operation
|
114
|
+
def process(params)
|
115
|
+
params || invalid!(params)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# ::run
|
120
|
+
it { OperationWithoutValidateCall.run(Object).must_equal [true, Object] }
|
121
|
+
# ::[]
|
122
|
+
it { OperationWithoutValidateCall[Object].must_equal(Object) }
|
123
|
+
# ::run with invalid!
|
124
|
+
it { OperationWithoutValidateCall.run(nil).must_equal [false, nil] }
|
125
|
+
# ::run with block, invalid
|
126
|
+
it do
|
127
|
+
OperationWithoutValidateCall.run(false) { @outcome = "true" }.must_equal false
|
128
|
+
@outcome.must_equal nil
|
129
|
+
end
|
130
|
+
# ::run with block, valid
|
131
|
+
it do
|
132
|
+
OperationWithoutValidateCall.run(true) { @outcome = "true" }.must_equal true
|
133
|
+
@outcome.must_equal "true"
|
134
|
+
end
|
135
|
+
|
136
|
+
# #validate yields contract when valid
|
137
|
+
class OperationWithValidateBlock < Trailblazer::Operation
|
138
|
+
self.contract_class = class Contract
|
139
|
+
def initialize(*)
|
140
|
+
end
|
141
|
+
|
142
|
+
def validate(params)
|
143
|
+
params
|
144
|
+
end
|
145
|
+
self
|
146
|
+
end
|
147
|
+
|
148
|
+
def process(params)
|
149
|
+
validate(params, Object.new) do |c|
|
150
|
+
@secret_contract = c
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
attr_reader :secret_contract
|
155
|
+
end
|
156
|
+
|
157
|
+
it { OperationWithValidateBlock.run(false).last.secret_contract.must_equal nil }
|
158
|
+
it('zzz') { OperationWithValidateBlock[true].secret_contract.must_equal OperationWithValidateBlock.contract_class.new.extend(Comparable) }
|
159
|
+
|
160
|
+
# manually setting @valid
|
161
|
+
class OperationWithManualValid < Trailblazer::Operation
|
162
|
+
def process(params)
|
163
|
+
@valid = false
|
164
|
+
params
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# ::run
|
169
|
+
it { OperationWithManualValid.run(Object).must_equal [false, Object] }
|
170
|
+
# ::[]
|
171
|
+
it { OperationWithManualValid[Object].must_equal(Object) }
|
172
|
+
|
173
|
+
|
174
|
+
# re-assign params
|
175
|
+
class OperationReassigningParams < Trailblazer::Operation
|
176
|
+
def process(params)
|
177
|
+
params = params[:title]
|
178
|
+
params
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# ::run
|
183
|
+
it { OperationReassigningParams.run({:title => "Day Like This"}).must_equal [true, "Day Like This"] }
|
184
|
+
|
185
|
+
|
186
|
+
# #invalid!(result)
|
187
|
+
class OperationCallingInvalid < Trailblazer::Operation
|
188
|
+
def process(params)
|
189
|
+
return 1 if params
|
190
|
+
invalid!(2)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
it { OperationCallingInvalid.run(true).must_equal [true, 1] }
|
195
|
+
it { OperationCallingInvalid.run(nil).must_equal [false, 2] }
|
196
|
+
|
197
|
+
# #invalid! without result defaults to operation instance.
|
198
|
+
class OperationCallingInvalidWithoutResult < Trailblazer::Operation
|
199
|
+
include Comparable
|
200
|
+
def process(params)
|
201
|
+
invalid!
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
it { OperationCallingInvalidWithoutResult.run(true).must_equal [false, OperationCallingInvalidWithoutResult.new] }
|
206
|
+
|
207
|
+
|
208
|
+
# calling return from #validate block leaves result true.
|
209
|
+
class OperationUsingReturnInValidate < Trailblazer::Operation
|
210
|
+
self.contract_class = class Contract
|
211
|
+
def initialize(*)
|
212
|
+
end
|
213
|
+
def validate(params)
|
214
|
+
params
|
215
|
+
end
|
216
|
+
self
|
217
|
+
end
|
218
|
+
|
219
|
+
def process(params)
|
220
|
+
validate(params, Object) do
|
221
|
+
return 1
|
222
|
+
end
|
223
|
+
2
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
it { OperationUsingReturnInValidate.run(true).must_equal [true, 1] }
|
228
|
+
it { OperationUsingReturnInValidate.run(false).must_equal [false, 2] }
|
229
|
+
|
230
|
+
|
231
|
+
# unlimited arguments for ::run and friends.
|
232
|
+
class OperationReceivingLottaArguments < Trailblazer::Operation
|
233
|
+
def process(model, params)
|
234
|
+
[model, params]
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
it { OperationReceivingLottaArguments.run(Object, {}).must_equal([true, [Object, {}]]) }
|
239
|
+
|
240
|
+
|
241
|
+
# TODO: experimental.
|
242
|
+
# ::present to avoid running #validate.
|
243
|
+
class ContractOnlyOperation < Trailblazer::Operation
|
244
|
+
self.contract_class = class Contract
|
245
|
+
def initialize(model)
|
246
|
+
@_model = model
|
247
|
+
end
|
248
|
+
attr_reader :_model
|
249
|
+
self
|
250
|
+
end
|
251
|
+
|
252
|
+
def process(params)
|
253
|
+
@object = Object # arbitrary init code.
|
254
|
+
|
255
|
+
validate(params, Object) do
|
256
|
+
raise "this should not be run."
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
it { ContractOnlyOperation.present({}).contract._model.must_equal Object }
|
262
|
+
|
263
|
+
end
|
264
|
+
|
265
|
+
class OperationBuilderTest < MiniTest::Spec
|
266
|
+
class Operation < Trailblazer::Operation
|
267
|
+
def process(params)
|
268
|
+
"operation"
|
269
|
+
end
|
270
|
+
|
271
|
+
class Sub < self
|
272
|
+
def process(params)
|
273
|
+
"sub:operation"
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
builds do |params|
|
278
|
+
Sub if params[:sub]
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
it { Operation.run({}).last.must_equal "operation" }
|
283
|
+
it { Operation.run({sub: true}).last.must_equal "sub:operation" }
|
284
|
+
|
285
|
+
it { Operation[{}].must_equal "operation" }
|
286
|
+
it { Operation[{sub: true}].must_equal "sub:operation" }
|
287
|
+
end
|
288
|
+
|
289
|
+
# ::contract builds Reform::Form class
|
290
|
+
class OperationInheritanceTest < MiniTest::Spec
|
291
|
+
class Operation < Trailblazer::Operation
|
292
|
+
contract do
|
293
|
+
property :title
|
294
|
+
property :band
|
295
|
+
|
296
|
+
# TODO/DISCUSS: this is needed in order to "handle" the anon forms. but in Trb, that
|
297
|
+
# doesn't really matter as AM is automatically included?
|
298
|
+
def self.name
|
299
|
+
"Song"
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
class JSON < self
|
304
|
+
# inherit Contract
|
305
|
+
contract do
|
306
|
+
property :genre, validates: {presence: true}
|
307
|
+
property :band, virtual: true
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
# inherits subclassed Contract.
|
313
|
+
it { Operation.contract_class.wont_equal Operation::JSON.contract_class }
|
314
|
+
|
315
|
+
it do
|
316
|
+
form = Operation.contract_class.new(OpenStruct.new)
|
317
|
+
form.validate({})#.must_equal true
|
318
|
+
form.errors.to_s.must_equal "{}"
|
319
|
+
|
320
|
+
form = Operation::JSON.contract_class.new(OpenStruct.new)
|
321
|
+
form.validate({})#.must_equal true
|
322
|
+
form.errors.to_s.must_equal "{:genre=>[\"can't be blank\"]}"
|
323
|
+
end
|
324
|
+
|
325
|
+
# allows overriding options
|
326
|
+
it do
|
327
|
+
form = Operation::JSON.contract_class.new(song = OpenStruct.new)
|
328
|
+
form.validate({genre: "Punkrock", band: "Osker"}).must_equal true
|
329
|
+
form.sync
|
330
|
+
|
331
|
+
song.genre.must_equal "Punkrock"
|
332
|
+
song.band.must_equal nil
|
333
|
+
end
|
334
|
+
end
|