ree_lib 1.3.8 → 1.3.10
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 +4 -4
- data/Gemfile.lock +2 -2
- data/lib/ree_lib/packages/ree_dao/package/ree_dao/dataset_extensions.rb +15 -13
- data/lib/ree_lib/packages/ree_dao/spec/ree_dao/db_load_performance_mode_spec.rb +190 -0
- data/lib/ree_lib/packages/ree_dto/package/ree_dto/dto/dto_class_methods.rb +14 -0
- data/lib/ree_lib/packages/ree_dto/spec/ree_dto/build_from_attrs_spec.rb +48 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/db_load_compiler.rb +145 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/mapper.rb +25 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/mapper_strategy.rb +1 -3
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/abstract_type.rb +9 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/any.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/bool.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/date.rb +5 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/date_time.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/float.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/integer.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/rational.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/string.rb +4 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/types/time.rb +5 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper/wrappers/abstract_wrapper.rb +5 -0
- data/lib/ree_lib/packages/ree_mapper/package/ree_mapper.rb +6 -0
- data/lib/ree_lib/packages/ree_mapper/spec/ree_mapper/benchmarks/db_load_benchmark_spec.rb +218 -0
- data/lib/ree_lib/packages/ree_mapper/spec/ree_mapper/db_load_performance_mode_spec.rb +339 -0
- data/lib/ree_lib/packages/ree_roda/package/ree_roda/plugins/ree_routes.rb +8 -2
- data/lib/ree_lib/packages/ree_roda/package/ree_roda/services/build_swagger_from_routes.rb +13 -2
- data/lib/ree_lib/packages/ree_routes/package/ree_routes/dsl.rb +17 -0
- data/lib/ree_lib/packages/ree_routes/package/ree_routes/route.rb +11 -1
- data/lib/ree_lib/packages/ree_routes/package/ree_routes/route_builder.rb +13 -0
- data/lib/ree_lib/version.rb +1 -1
- metadata +6 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'benchmark'
|
|
3
|
+
|
|
4
|
+
package_require "ree_mapper"
|
|
5
|
+
package_require "ree_dto/entity_dsl"
|
|
6
|
+
|
|
7
|
+
# Run with:
|
|
8
|
+
# REE_BENCHMARK=1 bundle exec rspec ... db_load_benchmark_spec.rb
|
|
9
|
+
# Contracts state is fixed at method definition time, so measure both setups
|
|
10
|
+
# in separate processes: a plain run (contracts on) and NO_CONTRACTS=1.
|
|
11
|
+
RSpec.public_send(ENV["REE_BENCHMARK"] ? :describe : :xdescribe, 'db_load performance mode benchmark') do
|
|
12
|
+
link :build_mapper_factory, from: :ree_mapper
|
|
13
|
+
link :build_mapper_strategy, from: :ree_mapper
|
|
14
|
+
|
|
15
|
+
after { Ree.disable_db_load_performance_mode }
|
|
16
|
+
|
|
17
|
+
ROWS_COUNT = 10_000
|
|
18
|
+
REPS = 10
|
|
19
|
+
|
|
20
|
+
class DbLoadBenchDto
|
|
21
|
+
include ReeDto::DSL
|
|
22
|
+
|
|
23
|
+
build_dto do
|
|
24
|
+
field :id, Integer
|
|
25
|
+
field :num2, Integer
|
|
26
|
+
field :num3, Integer
|
|
27
|
+
field :num4, Integer
|
|
28
|
+
field :num5, Integer
|
|
29
|
+
field :name, String
|
|
30
|
+
field :s2, String
|
|
31
|
+
field :s3, String
|
|
32
|
+
field :s4, String
|
|
33
|
+
field :s5, String
|
|
34
|
+
field :active, Bool
|
|
35
|
+
field :flag2, Bool
|
|
36
|
+
field :score, Float
|
|
37
|
+
field :score2, Float
|
|
38
|
+
field :born_on, Date
|
|
39
|
+
field :born2, Date
|
|
40
|
+
field :seen_at, Time
|
|
41
|
+
field :seen2, Time
|
|
42
|
+
field :registered_at, DateTime
|
|
43
|
+
field :payload, Any
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class DbLoadBenchEntity
|
|
48
|
+
include ReeDto::EntityDSL
|
|
49
|
+
|
|
50
|
+
properties(
|
|
51
|
+
id: Nilor[Integer], num2: Nilor[Integer], num3: Nilor[Integer],
|
|
52
|
+
num4: Nilor[Integer], num5: Nilor[Integer],
|
|
53
|
+
name: Nilor[String], s2: Nilor[String], s3: Nilor[String],
|
|
54
|
+
s4: Nilor[String], s5: Nilor[String],
|
|
55
|
+
active: Nilor[Bool], flag2: Nilor[Bool],
|
|
56
|
+
score: Nilor[Float], score2: Nilor[Float],
|
|
57
|
+
born_on: Nilor[Date], born2: Nilor[Date],
|
|
58
|
+
seen_at: Nilor[Time], seen2: Nilor[Time],
|
|
59
|
+
registered_at: Nilor[DateTime], payload: Any,
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
wide_blk = Proc.new do
|
|
64
|
+
integer :id
|
|
65
|
+
integer :num2
|
|
66
|
+
integer :num3
|
|
67
|
+
integer :num4
|
|
68
|
+
integer :num5
|
|
69
|
+
string :name
|
|
70
|
+
string :s2
|
|
71
|
+
string :s3
|
|
72
|
+
string :s4
|
|
73
|
+
string :s5
|
|
74
|
+
bool :active
|
|
75
|
+
bool :flag2
|
|
76
|
+
float :score
|
|
77
|
+
float :score2
|
|
78
|
+
date :born_on
|
|
79
|
+
date :born2
|
|
80
|
+
time :seen_at
|
|
81
|
+
time :seen2
|
|
82
|
+
date_time :registered_at
|
|
83
|
+
any :payload
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
mixed_blk = Proc.new do
|
|
87
|
+
integer :id
|
|
88
|
+
string :name
|
|
89
|
+
bool :active
|
|
90
|
+
date :born_on
|
|
91
|
+
hash :meta, null: true do
|
|
92
|
+
date :day
|
|
93
|
+
integer :num
|
|
94
|
+
end
|
|
95
|
+
array :nums, integer, null: true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_mapper_with_mode(mode_on, dto, blk)
|
|
99
|
+
mode_on ? Ree.enable_db_load_performance_mode : Ree.disable_db_load_performance_mode
|
|
100
|
+
|
|
101
|
+
build_mapper_factory(
|
|
102
|
+
strategies: [
|
|
103
|
+
build_mapper_strategy(method: :db_load, dto: dto, always_optional: true)
|
|
104
|
+
]
|
|
105
|
+
).call.use(:db_load, dto: dto, &blk)
|
|
106
|
+
ensure
|
|
107
|
+
Ree.disable_db_load_performance_mode
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def wide_rows
|
|
111
|
+
Array.new(ROWS_COUNT) do |i|
|
|
112
|
+
{
|
|
113
|
+
id: i, num2: i, num3: i, num4: i, num5: i,
|
|
114
|
+
name: "name-#{i}", s2: "a", s3: "b", s4: "c", s5: "d",
|
|
115
|
+
active: true, flag2: false,
|
|
116
|
+
score: 1.5, score2: 2.5,
|
|
117
|
+
born_on: Date.new(2020, 1, 1), born2: Date.new(2021, 2, 2),
|
|
118
|
+
seen_at: Time.at(i).utc, seen2: Time.at(i * 2).utc,
|
|
119
|
+
registered_at: DateTime.new(2020, 1, 1, 12, 0, 0),
|
|
120
|
+
payload: { x: i },
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def mixed_rows
|
|
126
|
+
Array.new(ROWS_COUNT) do |i|
|
|
127
|
+
{
|
|
128
|
+
id: i, name: "name-#{i}", active: true, born_on: Date.new(2020, 1, 1),
|
|
129
|
+
meta: { day: "2020-01-01", num: i }, # string date: the JSONB guard-fallback path
|
|
130
|
+
nums: [1, 2, 3],
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def report_alloc(label, rows, mapper)
|
|
136
|
+
GC.start
|
|
137
|
+
allocated_before = GC.stat(:total_allocated_objects)
|
|
138
|
+
gc_before = GC.count
|
|
139
|
+
rows.each { mapper.db_load(_1) }
|
|
140
|
+
allocated = GC.stat(:total_allocated_objects) - allocated_before
|
|
141
|
+
gc_runs = GC.count - gc_before
|
|
142
|
+
puts format("%-28s %10d objects, %3d GC runs per %d rows", label, allocated, gc_runs, rows.size)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'wide primitive row: generic vs compiled' do
|
|
146
|
+
rows = wide_rows
|
|
147
|
+
|
|
148
|
+
pairs = {
|
|
149
|
+
"hash dto" => [
|
|
150
|
+
build_mapper_with_mode(false, Hash, wide_blk),
|
|
151
|
+
build_mapper_with_mode(true, Hash, wide_blk),
|
|
152
|
+
],
|
|
153
|
+
"ree_dto" => [
|
|
154
|
+
build_mapper_with_mode(false, DbLoadBenchDto, wide_blk),
|
|
155
|
+
build_mapper_with_mode(true, DbLoadBenchDto, wide_blk),
|
|
156
|
+
],
|
|
157
|
+
"entity_dsl" => [
|
|
158
|
+
build_mapper_with_mode(false, DbLoadBenchEntity, wide_blk),
|
|
159
|
+
build_mapper_with_mode(true, DbLoadBenchEntity, wide_blk),
|
|
160
|
+
],
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
puts "\ncontracts: #{Ree::Contracts.no_contracts? ? 'OFF (NO_CONTRACTS)' : 'ON'}"
|
|
164
|
+
|
|
165
|
+
Benchmark.bmbm do |x|
|
|
166
|
+
pairs.each do |name, (generic, compiled)|
|
|
167
|
+
x.report("generic #{name}") { REPS.times { rows.each { generic.db_load(_1) } } }
|
|
168
|
+
x.report("compiled #{name}") { REPS.times { rows.each { compiled.db_load(_1) } } }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
puts "\nallocations:"
|
|
173
|
+
pairs.each do |name, (generic, compiled)|
|
|
174
|
+
report_alloc("generic #{name}", rows, generic)
|
|
175
|
+
report_alloc("compiled #{name}", rows, compiled)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'mixed row with nested hash and array: generic vs compiled' do
|
|
180
|
+
rows = mixed_rows
|
|
181
|
+
generic = build_mapper_with_mode(false, DbLoadBenchDto, mixed_blk)
|
|
182
|
+
compiled = build_mapper_with_mode(true, DbLoadBenchDto, mixed_blk)
|
|
183
|
+
|
|
184
|
+
Benchmark.bmbm do |x|
|
|
185
|
+
x.report("generic mixed") { REPS.times { rows.each { generic.db_load(_1) } } }
|
|
186
|
+
x.report("compiled mixed") { REPS.times { rows.each { compiled.db_load(_1) } } }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
puts "\nallocations:"
|
|
190
|
+
report_alloc("generic mixed", rows, generic)
|
|
191
|
+
report_alloc("compiled mixed", rows, compiled)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Results (2026-08-16, Ruby 4.0.1, 10_000 rows x 10 reps, real seconds):
|
|
196
|
+
#
|
|
197
|
+
# contracts: ON
|
|
198
|
+
# generic compiled speedup
|
|
199
|
+
# wide (20 fields) hash 60.53s 0.41s ~149x
|
|
200
|
+
# wide ree_dto 74.76s 0.43s ~174x
|
|
201
|
+
# wide entity_dsl 63.78s 0.36s ~175x
|
|
202
|
+
# mixed (jsonb-like) 37.23s 5.43s ~6.9x
|
|
203
|
+
#
|
|
204
|
+
# allocations per 10_000 rows:
|
|
205
|
+
# wide hash 15_820_000 -> 10_000 wide ree_dto 18_350_000 -> 20_000
|
|
206
|
+
# wide entity_dsl 15_820_000 -> 10_000 mixed 8_930_000 -> 1_190_000
|
|
207
|
+
# GC runs: 40-46 -> 0 (wide), 72 -> 9 (mixed)
|
|
208
|
+
#
|
|
209
|
+
# contracts: OFF (NO_CONTRACTS=1)
|
|
210
|
+
# generic compiled speedup
|
|
211
|
+
# wide (20 fields) hash 2.54s 0.42s ~6.0x
|
|
212
|
+
# wide ree_dto 2.77s 0.43s ~6.4x
|
|
213
|
+
# wide entity_dsl 2.43s 0.39s ~6.2x
|
|
214
|
+
# mixed (jsonb-like) 2.52s 1.21s ~2.1x
|
|
215
|
+
#
|
|
216
|
+
# allocations per 10_000 rows:
|
|
217
|
+
# wide hash 220_000 -> 10_000 wide ree_dto 260_000 -> 20_000
|
|
218
|
+
# wide entity_dsl 220_000 -> 10_000 mixed 360_000 -> 200_000
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
package_require "ree_mapper"
|
|
3
|
+
package_require "ree_dto/entity_dsl"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "db_load performance mode" do
|
|
6
|
+
link :build_mapper_factory, from: :ree_mapper
|
|
7
|
+
link :build_mapper_strategy, from: :ree_mapper
|
|
8
|
+
|
|
9
|
+
after do
|
|
10
|
+
Ree.disable_db_load_performance_mode
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class PerfModeDto
|
|
14
|
+
include ReeDto::DSL
|
|
15
|
+
|
|
16
|
+
build_dto do
|
|
17
|
+
field :id, Integer
|
|
18
|
+
field :name, String
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class PerfModeEntity
|
|
23
|
+
include ReeDto::EntityDSL
|
|
24
|
+
|
|
25
|
+
properties(
|
|
26
|
+
id: Nilor[Integer],
|
|
27
|
+
name: Nilor[String],
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_factory(dto:, always_optional: true)
|
|
32
|
+
build_mapper_factory(
|
|
33
|
+
strategies: [
|
|
34
|
+
build_mapper_strategy(method: :db_load, dto: dto, always_optional: always_optional)
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# builds the same mapper twice: with the mode off (generic path) and on
|
|
40
|
+
# (compiled path), so examples can assert identical behavior
|
|
41
|
+
def build_pair(dto: Hash, always_optional: true, register_as: nil, &blk)
|
|
42
|
+
Ree.disable_db_load_performance_mode
|
|
43
|
+
generic = build_factory(dto: dto, always_optional: always_optional)
|
|
44
|
+
.call(register_as: register_as).use(:db_load, dto: dto, &blk)
|
|
45
|
+
|
|
46
|
+
Ree.enable_db_load_performance_mode
|
|
47
|
+
compiled = build_factory(dto: dto, always_optional: always_optional)
|
|
48
|
+
.call(register_as: register_as).use(:db_load, dto: dto, &blk)
|
|
49
|
+
Ree.disable_db_load_performance_mode
|
|
50
|
+
|
|
51
|
+
[generic, compiled]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def expect_same_result(generic, compiled, input, **opts)
|
|
55
|
+
generic_result = begin; generic.db_load(input, **opts); rescue => e; e; end
|
|
56
|
+
compiled_result = begin; compiled.db_load(input, **opts); rescue => e; e; end
|
|
57
|
+
|
|
58
|
+
if generic_result.is_a?(Exception)
|
|
59
|
+
expect(compiled_result.class).to eq(generic_result.class)
|
|
60
|
+
expect(compiled_result.message).to eq(generic_result.message)
|
|
61
|
+
else
|
|
62
|
+
expect(compiled_result).to eq(generic_result)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
compiled_result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
wide_blk = Proc.new do
|
|
69
|
+
integer :id
|
|
70
|
+
string :name
|
|
71
|
+
bool :active
|
|
72
|
+
float :score, null: true
|
|
73
|
+
date :born_on, null: true
|
|
74
|
+
time :seen_at, null: true
|
|
75
|
+
date_time :registered_at, null: true
|
|
76
|
+
rational :ratio, null: true
|
|
77
|
+
any :payload, null: true
|
|
78
|
+
integer :renamed, from: :src
|
|
79
|
+
string? :note, default: "-"
|
|
80
|
+
integer? :maybe
|
|
81
|
+
integer :admin_count, role: :admin
|
|
82
|
+
hash :meta, null: true do
|
|
83
|
+
date :day
|
|
84
|
+
integer :num
|
|
85
|
+
end
|
|
86
|
+
array :nums, integer, null: true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def wide_row
|
|
90
|
+
{
|
|
91
|
+
id: 1,
|
|
92
|
+
name: "n",
|
|
93
|
+
active: true,
|
|
94
|
+
score: 1.5,
|
|
95
|
+
born_on: Date.new(2020, 1, 1),
|
|
96
|
+
seen_at: Time.at(0).utc,
|
|
97
|
+
registered_at: DateTime.new(2020, 1, 1, 12, 0, 0),
|
|
98
|
+
ratio: Rational(1, 2),
|
|
99
|
+
payload: { x: 1 },
|
|
100
|
+
src: 5,
|
|
101
|
+
note: "hi",
|
|
102
|
+
maybe: 2,
|
|
103
|
+
meta: { day: "2020-01-01", num: 3 },
|
|
104
|
+
nums: [1, 2],
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "compiles db_load when the mode is on and keeps the generic fallback" do
|
|
109
|
+
_generic, compiled = build_pair(&wide_blk)
|
|
110
|
+
|
|
111
|
+
expect(compiled.class.method_defined?(:__generic_db_load, false)).to be true
|
|
112
|
+
expect(compiled.class.const_defined?(:DB_LOAD_FIELDS, false)).to be true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "does not compile when the mode is off" do
|
|
116
|
+
generic, _compiled = build_pair(&wide_blk)
|
|
117
|
+
|
|
118
|
+
expect(generic.class.method_defined?(:__generic_db_load, false)).to be false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
context "parity with the generic path" do
|
|
122
|
+
it "loads a full row identically" do
|
|
123
|
+
generic, compiled = build_pair(&wide_blk)
|
|
124
|
+
result = expect_same_result(generic, compiled, wide_row)
|
|
125
|
+
|
|
126
|
+
expect(result[:id]).to eq(1)
|
|
127
|
+
expect(result[:renamed]).to eq(5)
|
|
128
|
+
expect(result[:meta][:day]).to eq(Date.new(2020, 1, 1)) # JSONB-style string is still cast
|
|
129
|
+
expect(result[:nums]).to eq([1, 2])
|
|
130
|
+
expect(result.key?(:admin_count)).to be false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "still coerces values that fail the type guard" do
|
|
134
|
+
generic, compiled = build_pair(&wide_blk)
|
|
135
|
+
result = expect_same_result(generic, compiled, wide_row.merge(id: "5", score: 2))
|
|
136
|
+
|
|
137
|
+
expect(result[:id]).to eq(5)
|
|
138
|
+
expect(result[:score]).to eq(2.0)
|
|
139
|
+
expect(result[:score]).to be_a(Float)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "raises the same error for nil in a null: false field" do
|
|
143
|
+
generic, compiled = build_pair(&wide_blk)
|
|
144
|
+
error = expect_same_result(generic, compiled, wide_row.merge(name: nil))
|
|
145
|
+
|
|
146
|
+
expect(error).to be_a(ReeMapper::TypeError)
|
|
147
|
+
expect(error.message).to include("`name`")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "raises the same error for garbage that fails coercion" do
|
|
151
|
+
generic, compiled = build_pair(&wide_blk)
|
|
152
|
+
error = expect_same_result(generic, compiled, wide_row.merge(id: "abc"))
|
|
153
|
+
|
|
154
|
+
expect(error).to be_a(ReeMapper::CoercionError)
|
|
155
|
+
expect(error.message).to include("`id`")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "raises the same error from a nested mapper with the field path" do
|
|
159
|
+
generic, compiled = build_pair(&wide_blk)
|
|
160
|
+
error = expect_same_result(generic, compiled, wide_row.merge(meta: { day: "not a date", num: 1 }))
|
|
161
|
+
|
|
162
|
+
expect(error).to be_a(ReeMapper::CoercionError)
|
|
163
|
+
expect(error.message).to include("`meta[day]`")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "assigns nil without casting for null: true fields" do
|
|
167
|
+
generic, compiled = build_pair(&wide_blk)
|
|
168
|
+
result = expect_same_result(
|
|
169
|
+
generic, compiled,
|
|
170
|
+
wide_row.merge(score: nil, born_on: nil, meta: nil, nums: nil, payload: nil)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
expect(result[:score]).to be_nil
|
|
174
|
+
expect(result[:meta]).to be_nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "applies defaults and skips missing optional fields" do
|
|
178
|
+
generic, compiled = build_pair(&wide_blk)
|
|
179
|
+
row = wide_row
|
|
180
|
+
row.delete(:note)
|
|
181
|
+
row.delete(:maybe)
|
|
182
|
+
result = expect_same_result(generic, compiled, row)
|
|
183
|
+
|
|
184
|
+
expect(result[:note]).to eq("-")
|
|
185
|
+
expect(result.key?(:maybe)).to be false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "skips missing fields when the strategy is always_optional" do
|
|
189
|
+
generic, compiled = build_pair(&wide_blk)
|
|
190
|
+
result = expect_same_result(generic, compiled, { id: 1 })
|
|
191
|
+
|
|
192
|
+
expect(result).to eq({ id: 1, note: "-" })
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "raises for missing required fields when the strategy is not always_optional" do
|
|
196
|
+
generic, compiled = build_pair(always_optional: false) do
|
|
197
|
+
integer :id
|
|
198
|
+
string? :name
|
|
199
|
+
end
|
|
200
|
+
error = expect_same_result(generic, compiled, { name: "n" })
|
|
201
|
+
|
|
202
|
+
expect(error).to be_a(ReeMapper::TypeError)
|
|
203
|
+
expect(error.message).to include("`id` is missing (required field)")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "supports string-keyed input" do
|
|
207
|
+
generic, compiled = build_pair(&wide_blk)
|
|
208
|
+
result = expect_same_result(generic, compiled, { "id" => 1, "name" => "n" })
|
|
209
|
+
|
|
210
|
+
expect(result[:id]).to eq(1)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it "falls back to the generic path for non-Hash input" do
|
|
214
|
+
generic, compiled = build_pair(&wide_blk)
|
|
215
|
+
input = OpenStruct.new(id: 1, name: "n")
|
|
216
|
+
result = expect_same_result(generic, compiled, input)
|
|
217
|
+
|
|
218
|
+
expect(result[:id]).to eq(1)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "falls back to the generic path for role calls" do
|
|
222
|
+
generic, compiled = build_pair(&wide_blk)
|
|
223
|
+
result = expect_same_result(generic, compiled, wide_row.merge(admin_count: 7), role: :admin)
|
|
224
|
+
|
|
225
|
+
expect(result[:admin_count]).to eq(7)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it "falls back to the generic path for only/except calls" do
|
|
229
|
+
generic, compiled = build_pair(&wide_blk)
|
|
230
|
+
result = expect_same_result(generic, compiled, wide_row, only: [:id, :name])
|
|
231
|
+
|
|
232
|
+
expect(result.keys).to contain_exactly(:id, :name)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it "respects field-level only filters on nested mappers" do
|
|
236
|
+
generic, compiled = build_pair do
|
|
237
|
+
integer :id
|
|
238
|
+
hash :meta, null: true, only: [:num] do
|
|
239
|
+
date :day, null: true
|
|
240
|
+
integer :num
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
result = expect_same_result(generic, compiled, { id: 1, meta: { day: "2020-01-01", num: 3 } })
|
|
244
|
+
|
|
245
|
+
expect(result[:meta]).to eq({ num: 3 })
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
context "dto outputs" do
|
|
250
|
+
it "builds ree_dto entities" do
|
|
251
|
+
generic, compiled = build_pair(dto: PerfModeDto) do
|
|
252
|
+
integer :id
|
|
253
|
+
string :name
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
generic_dto = generic.db_load({ id: 1, name: "n" })
|
|
257
|
+
compiled_dto = compiled.db_load({ id: 1, name: "n" })
|
|
258
|
+
|
|
259
|
+
expect(compiled_dto).to be_a(PerfModeDto)
|
|
260
|
+
expect(compiled_dto.id).to eq(generic_dto.id)
|
|
261
|
+
expect(compiled_dto.name).to eq(generic_dto.name)
|
|
262
|
+
expect(compiled_dto.attrs).to eq(generic_dto.attrs)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it "builds entity_dsl objects via instance variables" do
|
|
266
|
+
generic, compiled = build_pair(dto: PerfModeEntity) do
|
|
267
|
+
integer :id
|
|
268
|
+
string :name
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
generic_entity = generic.db_load({ id: 1, name: "n" })
|
|
272
|
+
compiled_entity = compiled.db_load({ id: 1, name: "n" })
|
|
273
|
+
|
|
274
|
+
expect(compiled_entity).to be_a(PerfModeEntity)
|
|
275
|
+
expect(compiled_entity.id).to eq(generic_entity.id)
|
|
276
|
+
expect(compiled_entity.name).to eq(generic_entity.name)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "keeps the generic path for struct dto" do
|
|
280
|
+
Ree.enable_db_load_performance_mode
|
|
281
|
+
mapper = build_factory(dto: Struct).call.use(:db_load, dto: Struct) do
|
|
282
|
+
integer :id
|
|
283
|
+
end
|
|
284
|
+
Ree.disable_db_load_performance_mode
|
|
285
|
+
|
|
286
|
+
expect(mapper.class.method_defined?(:__generic_db_load, false)).to be false
|
|
287
|
+
expect(mapper.db_load({ id: 1 }).id).to eq(1)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
context "registered (frozen) mappers" do
|
|
292
|
+
it "compiles mappers registered via register_as" do
|
|
293
|
+
generic, compiled = build_pair(register_as: :perf_mode_frozen, &wide_blk)
|
|
294
|
+
|
|
295
|
+
expect(compiled.frozen?).to be true
|
|
296
|
+
expect(compiled.class.method_defined?(:__generic_db_load, false)).to be true
|
|
297
|
+
expect_same_result(generic, compiled, wide_row)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
describe "guard/cast identity invariant" do
|
|
302
|
+
{
|
|
303
|
+
ReeMapper::Integer => [1, -5, 10**20],
|
|
304
|
+
ReeMapper::String => ["s", ""],
|
|
305
|
+
ReeMapper::Bool => [true, false],
|
|
306
|
+
ReeMapper::Float => [1.5, -0.0],
|
|
307
|
+
ReeMapper::Date => [Date.new(2020, 1, 1)],
|
|
308
|
+
ReeMapper::Time => [Time.at(0).utc],
|
|
309
|
+
ReeMapper::DateTime => [DateTime.new(2020, 1, 1)],
|
|
310
|
+
ReeMapper::Rational => [Rational(1, 2)],
|
|
311
|
+
ReeMapper::Any => [1, "s", nil, [1], { a: 1 }],
|
|
312
|
+
}.each do |type_class, values|
|
|
313
|
+
it "#{type_class}: guard implies db_load is identity" do
|
|
314
|
+
type = type_class.new
|
|
315
|
+
check = type.db_load_trusted_check("v")
|
|
316
|
+
|
|
317
|
+
values.each do |v|
|
|
318
|
+
expect(eval(check)).to be(true), "guard rejected #{v.inspect} for #{type_class}"
|
|
319
|
+
expect(type.db_load(v)).to equal(v)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
it "guards reject values that db_load would convert" do
|
|
325
|
+
{
|
|
326
|
+
ReeMapper::Integer.new => "5",
|
|
327
|
+
ReeMapper::Float.new => 2,
|
|
328
|
+
ReeMapper::Date.new => DateTime.new(2020, 1, 1),
|
|
329
|
+
ReeMapper::Time.new => DateTime.new(2020, 1, 1),
|
|
330
|
+
ReeMapper::DateTime.new => Time.at(0).utc,
|
|
331
|
+
ReeMapper::Rational.new => "1/2",
|
|
332
|
+
ReeMapper::Bool.new => 1,
|
|
333
|
+
}.each do |type, v|
|
|
334
|
+
check = type.db_load_trusted_check("v")
|
|
335
|
+
expect(eval(check)).to be(false), "guard accepted #{v.inspect} for #{type.class}"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
@@ -17,7 +17,8 @@ class Roda
|
|
|
17
17
|
|
|
18
18
|
module ClassMethods
|
|
19
19
|
def ree_routes(routes, swagger_title: "", swagger_description: "",
|
|
20
|
-
swagger_version: "", swagger_url: "", api_url: ""
|
|
20
|
+
swagger_version: "", swagger_url: "", api_url: "",
|
|
21
|
+
swagger_include_internal: nil)
|
|
21
22
|
@ree_routes ||= []
|
|
22
23
|
@ree_routes += routes
|
|
23
24
|
|
|
@@ -27,12 +28,17 @@ class Roda
|
|
|
27
28
|
opts[:ree_routes_swagger_url] = swagger_url
|
|
28
29
|
opts[:ree_routes_api_url] = api_url
|
|
29
30
|
|
|
31
|
+
# nil keeps the global default (Ree.hide_internal_swagger_endpoints);
|
|
32
|
+
# pass true/false to force one app to differ from it.
|
|
33
|
+
swagger_opts = swagger_include_internal.nil? ? {} : { include_internal: swagger_include_internal }
|
|
34
|
+
|
|
30
35
|
opts[:ree_routes_swagger] = ReeRoda::BuildSwaggerFromRoutes.new.call(
|
|
31
36
|
@ree_routes,
|
|
32
37
|
opts[:ree_routes_swagger_title],
|
|
33
38
|
opts[:ree_routes_swagger_description],
|
|
34
39
|
opts[:ree_routes_swagger_version],
|
|
35
|
-
opts[:ree_routes_api_url]
|
|
40
|
+
opts[:ree_routes_api_url],
|
|
41
|
+
**swagger_opts
|
|
36
42
|
)
|
|
37
43
|
|
|
38
44
|
build_routes_proc
|
|
@@ -7,8 +7,19 @@ class ReeRoda::BuildSwaggerFromRoutes
|
|
|
7
7
|
link "ree_swagger/dto/endpoint_dto", -> { EndpointDto }
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
contract(
|
|
11
|
-
|
|
10
|
+
contract(
|
|
11
|
+
ArrayOf[ReeRoutes::Route], String, String, String, String,
|
|
12
|
+
Ksplat[include_internal?: Bool] => Hash
|
|
13
|
+
)
|
|
14
|
+
def call(routes, title, description, version, api_url, **opts)
|
|
15
|
+
# Defaults to the global switch (see Ree.hide_internal_swagger_endpoints),
|
|
16
|
+
# so one codebase can serve a public and an internal document.
|
|
17
|
+
include_internal = opts.fetch(:include_internal) {
|
|
18
|
+
!Ree.hide_internal_swagger_endpoints?
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
routes = routes.select(&:public?) if !include_internal
|
|
22
|
+
|
|
12
23
|
endpoints = routes.map do |route|
|
|
13
24
|
method_decorator = Ree::Contracts.get_method_decorator(
|
|
14
25
|
route.action.klass, :call, scope: :instance
|
|
@@ -24,6 +24,8 @@ module ReeRoutes
|
|
|
24
24
|
include Ree::Contracts::Core
|
|
25
25
|
include Ree::Contracts::ArgContracts
|
|
26
26
|
|
|
27
|
+
ADMIN_PATH_SEGMENT = "admin"
|
|
28
|
+
|
|
27
29
|
def routes(name, &proc)
|
|
28
30
|
raise ArgumentError.new("block is required") if !block_given?
|
|
29
31
|
|
|
@@ -87,6 +89,14 @@ module ReeRoutes
|
|
|
87
89
|
raise ArgumentError.new("action, summary and auth scope should be provided for #{builder.get_route.inspect}")
|
|
88
90
|
end
|
|
89
91
|
|
|
92
|
+
if builder.get_route.visibility.nil? && admin_path?(path)
|
|
93
|
+
raise ArgumentError.new(
|
|
94
|
+
"admin route #{path} must declare visibility explicitly: " \
|
|
95
|
+
"`visibility :internal` to keep it out of generated swagger, " \
|
|
96
|
+
"or `visibility :public` if it is intentionally documented"
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
90
100
|
route = builder.get_route
|
|
91
101
|
|
|
92
102
|
@dsl.link(route.action.name, from: route.action.package_name)
|
|
@@ -98,6 +108,13 @@ module ReeRoutes
|
|
|
98
108
|
@routes << route
|
|
99
109
|
route
|
|
100
110
|
end
|
|
111
|
+
|
|
112
|
+
# Forgetting `visibility` on an admin route is invisible in review and
|
|
113
|
+
# silently publishes it, so make it a boot-time failure instead.
|
|
114
|
+
contract String => Bool
|
|
115
|
+
def admin_path?(path)
|
|
116
|
+
path.split("/").include?(ADMIN_PATH_SEGMENT)
|
|
117
|
+
end
|
|
101
118
|
end
|
|
102
119
|
end
|
|
103
120
|
end
|
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
class ReeRoutes::Route
|
|
4
4
|
attr_accessor :summary, :request_method, :serializer, :respond_to,
|
|
5
|
-
:sections, :action, :route, :warden_scopes, :path, :override
|
|
5
|
+
:sections, :action, :route, :warden_scopes, :path, :override,
|
|
6
|
+
:visibility
|
|
7
|
+
|
|
8
|
+
# Routing always includes every route; visibility only affects generated docs.
|
|
9
|
+
def public?
|
|
10
|
+
visibility.nil? || visibility == :public
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def internal?
|
|
14
|
+
!public?
|
|
15
|
+
end
|
|
6
16
|
|
|
7
17
|
def valid?
|
|
8
18
|
!action.nil? && !summary.nil? && !warden_scopes.nil? && !warden_scopes.empty?
|
|
@@ -5,6 +5,8 @@ require_relative "route"
|
|
|
5
5
|
class ReeRoutes::RouteBuilder
|
|
6
6
|
Redirect = Struct.new(:path, :code)
|
|
7
7
|
|
|
8
|
+
VISIBILITIES = [:public, :internal].freeze
|
|
9
|
+
|
|
8
10
|
def initialize
|
|
9
11
|
@route = ReeRoutes::Route.new
|
|
10
12
|
@route.respond_to = :json
|
|
@@ -42,6 +44,17 @@ class ReeRoutes::RouteBuilder
|
|
|
42
44
|
@route.summary = str
|
|
43
45
|
end
|
|
44
46
|
|
|
47
|
+
contract Symbol => Symbol
|
|
48
|
+
def visibility(value)
|
|
49
|
+
if !VISIBILITIES.include?(value)
|
|
50
|
+
raise ArgumentError.new(
|
|
51
|
+
"visibility should be one of #{VISIBILITIES.inspect}, got #{value.inspect}"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@route.visibility = value
|
|
56
|
+
end
|
|
57
|
+
|
|
45
58
|
contract Symbol, Symbol => nil
|
|
46
59
|
def serializer(name, from:)
|
|
47
60
|
object = Ree.container.packages_facade.get_object(from, name)
|
data/lib/ree_lib/version.rb
CHANGED