rack-reducer 1.1.2 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +14 -8
- data/lib/rack/reducer.rb +53 -58
- data/lib/rack/reducer/middleware.rb +3 -4
- data/lib/rack/reducer/refinements.rb +8 -2
- data/lib/rack/reducer/version.rb +2 -2
- data/spec/fixtures.rb +1 -4
- data/spec/middleware_spec.rb +2 -18
- data/spec/reducer_spec.rb +26 -26
- metadata +2 -4
- data/lib/rack/reducer/reduction.rb +0 -46
- data/lib/rack/reducer/warnings.rb +0 -27
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 89560b1f9fb58a56452e6e9cbba2a9dd5e0159b938c804628750549da310c543
|
4
|
+
data.tar.gz: f7e9667193f0e4e6e5b413ceec8506919f784604d5be97746239688f3074ab8b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fa641c8d24f2bd6a52f523e447c43d1714c92db33175750b4728512bcdca9f5668a018a4be1b5a504fb3ad0188ace6e5e3911503c6a06d48cbf3d9a64aecf489
|
7
|
+
data.tar.gz: d60cbdae9d7d225024680262be9be5b8b92a923ad224452dfd7e383410e75d5ee717ac3842b9f0ed95ebb8b3789ef36bc3880794b62577f1162a6d67816df77b
|
data/README.md
CHANGED
@@ -2,6 +2,7 @@ Rack::Reducer
|
|
2
2
|
==========================================
|
3
3
|
[![Build Status](https://travis-ci.org/chrisfrank/rack-reducer.svg?branch=master)](https://travis-ci.org/chrisfrank/rack-reducer)
|
4
4
|
[![Maintainability](https://api.codeclimate.com/v1/badges/675e7a654c7e11c24b9f/maintainability)](https://codeclimate.com/github/chrisfrank/rack-reducer/maintainability)
|
5
|
+
[![Version](https://img.shields.io/gem/v/rack-reducer.svg)](https://rubygems.org/gems/rack-reducer)
|
5
6
|
|
6
7
|
Declaratively filter data via URL params, in any Rack app, with any ORM.
|
7
8
|
|
@@ -34,8 +35,8 @@ data. Here’s how you might use it in a Rails controller:
|
|
34
35
|
# app/controllers/artists_controller.rb
|
35
36
|
class ArtistsController < ApplicationController
|
36
37
|
|
37
|
-
# Step 1:
|
38
|
-
ArtistReducer = Rack::Reducer.
|
38
|
+
# Step 1: Instantiate a reducer
|
39
|
+
ArtistReducer = Rack::Reducer.new(
|
39
40
|
Artist.all,
|
40
41
|
->(name:) { where('lower(name) like ?', "%#{name.downcase}%") },
|
41
42
|
->(genre:) { where(genre: genre) },
|
@@ -91,7 +92,7 @@ class SinatraExample < Sinatra::Base
|
|
91
92
|
DB = Sequel.connect ENV['DATABASE_URL']
|
92
93
|
|
93
94
|
# dataset is a Sequel::Dataset, so filters use Sequel query methods
|
94
|
-
ArtistReducer = Rack::Reducer.
|
95
|
+
ArtistReducer = Rack::Reducer.new(
|
95
96
|
DB[:artists],
|
96
97
|
->(genre:) { where(genre: genre) },
|
97
98
|
->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
|
@@ -155,7 +156,7 @@ more sense to keep your reducers in your models instead.
|
|
155
156
|
class Artist < ApplicationRecord
|
156
157
|
# filters get instance_exec'd against the dataset you provide -- in this case
|
157
158
|
# it's `self.all` -- so filters can use query methods, scopes, etc
|
158
|
-
Reducer = Rack::Reducer.
|
159
|
+
Reducer = Rack::Reducer.new(
|
159
160
|
self.all,
|
160
161
|
->(name:) { by_name(name) },
|
161
162
|
->(genre:) { where(genre: genre) },
|
@@ -188,7 +189,7 @@ it exists, and by name otherwise.
|
|
188
189
|
|
189
190
|
```ruby
|
190
191
|
class ArtistsController < ApplicationController
|
191
|
-
ArtistReducer = Rack::Reducer.
|
192
|
+
ArtistReducer = Rack::Reducer.new(
|
192
193
|
Artist.all,
|
193
194
|
->(genre:) { where(genre: genre) },
|
194
195
|
->(sort: 'name') { order(sort.to_sym) }
|
@@ -203,8 +204,8 @@ end
|
|
203
204
|
|
204
205
|
Calling Rack::Reducer as a function
|
205
206
|
-------------------------------------------
|
206
|
-
For a slight performance penalty (~5%), you can skip
|
207
|
-
`::
|
207
|
+
For a slight performance penalty (~5%), you can skip instantiating a reducer via
|
208
|
+
`::new` and just call Rack::Reducer as a function. This can be useful when
|
208
209
|
prototyping, mostly because you don't need to think about naming anything.
|
209
210
|
|
210
211
|
```ruby
|
@@ -271,7 +272,7 @@ instead if you want to handle parameterless requests at top speed.
|
|
271
272
|
```ruby
|
272
273
|
# app/controllers/artists_controller.rb
|
273
274
|
class ArtistController < ApplicationController
|
274
|
-
# ArtistReducer = Rack::Reducer.
|
275
|
+
# ArtistReducer = Rack::Reducer.new(...etc etc)
|
275
276
|
|
276
277
|
def index
|
277
278
|
@artists = ArtistReducer.apply(request.query_parameters)
|
@@ -292,6 +293,11 @@ It is Rails-only, but it supports more than just ActiveRecord.
|
|
292
293
|
For Sinatra, Simon Courtois has a [Sinatra port of has_scope][sin_has_scope].
|
293
294
|
It depends on ActiveRecord.
|
294
295
|
|
296
|
+
Contributors
|
297
|
+
---------------
|
298
|
+
Thank you @danielpuglisi, @nicolasleger, @jeremyshearer, and @shanecav84 for
|
299
|
+
helping improve Rack::Reducer!
|
300
|
+
|
295
301
|
Contributing
|
296
302
|
-------------------------------
|
297
303
|
### Bugs
|
data/lib/rack/reducer.rb
CHANGED
@@ -1,21 +1,32 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative 'reducer/
|
3
|
+
require_relative 'reducer/refinements'
|
4
4
|
require_relative 'reducer/middleware'
|
5
|
-
require_relative 'reducer/warnings'
|
6
5
|
|
7
6
|
module Rack
|
8
|
-
# Declaratively filter data via URL params, in any Rack app.
|
9
|
-
|
10
|
-
|
7
|
+
# Declaratively filter data via URL params, in any Rack app, with any ORM.
|
8
|
+
class Reducer
|
9
|
+
using Refinements
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# make ::create an alias of ::new, for compatibility with v1
|
13
|
+
alias create new
|
14
|
+
|
15
|
+
# Call Rack::Reducer as a function instead of creating a named reducer
|
16
|
+
def call(params, dataset:, filters:)
|
17
|
+
new(dataset, *filters).apply(params)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Instantiate a Reducer that can filter `dataset` via `#apply`.
|
11
22
|
# @param [Object] dataset an ActiveRecord::Relation, Sequel::Dataset,
|
12
23
|
# or other class with chainable methods
|
13
24
|
# @param [Array<Proc>] filters An array of lambdas whose keyword arguments
|
14
25
|
# name the URL params you will use as filters
|
15
|
-
# @return Rack::Reducer::Reduction
|
16
26
|
# @example Create a reducer and use it in a Sinatra app
|
17
27
|
# DB = Sequel.connect(ENV['DATABASE_URL'])
|
18
|
-
#
|
28
|
+
#
|
29
|
+
# MyReducer = Rack::Reducer.new(
|
19
30
|
# DB[:artists],
|
20
31
|
# lambda { |name:| where(name: name) },
|
21
32
|
# lambda { |genre:| where(genre: genre) },
|
@@ -25,63 +36,47 @@ module Rack
|
|
25
36
|
# @artists = MyReducer.apply(params)
|
26
37
|
# @artists.to_json
|
27
38
|
# end
|
28
|
-
def
|
29
|
-
|
39
|
+
def initialize(dataset, *filters)
|
40
|
+
@dataset = dataset
|
41
|
+
@filters = filters
|
42
|
+
@default_filters = filters.select do |filter|
|
43
|
+
filter.required_argument_names.empty?
|
44
|
+
end
|
30
45
|
end
|
31
46
|
|
32
|
-
#
|
33
|
-
#
|
34
|
-
#
|
35
|
-
#
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
Reduction.new(dataset, *filters).apply(params)
|
48
|
-
end
|
47
|
+
# Run `@filters` against `url_params`
|
48
|
+
# @param [Hash, ActionController::Parameters, nil] url_params
|
49
|
+
# a Rack-compatible params hash
|
50
|
+
# @return `@dataset` with the matching filters applied
|
51
|
+
def apply(url_params)
|
52
|
+
if url_params.empty?
|
53
|
+
# Return early with the unfiltered dataset if no default filters exist
|
54
|
+
return @dataset if @default_filters.empty?
|
55
|
+
|
56
|
+
# Run only the default filters
|
57
|
+
filters, params = @default_filters, EMPTY_PARAMS
|
58
|
+
else
|
59
|
+
# This request really does want filtering; run a full reduction
|
60
|
+
filters, params = @filters, url_params.to_unsafe_h.symbolize_keys
|
61
|
+
end
|
49
62
|
|
50
|
-
|
51
|
-
# @deprecated
|
52
|
-
# Rack::Reducer.new will become an alias of ::create in v2.0.
|
53
|
-
# To mount middleware that will still work in 2.0, write
|
54
|
-
# "use Rack::Reducer::Middleware" instead of "use Rack::Reducer"
|
55
|
-
def self.new(app, options = {})
|
56
|
-
warn "#{caller(1..1).first}}\n#{Warnings[:new]}"
|
57
|
-
Middleware.new(app, options)
|
63
|
+
reduce(params, filters)
|
58
64
|
end
|
59
65
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
# Artist.reduce(params)
|
71
|
-
#
|
72
|
-
# @deprecated
|
73
|
-
# Rack::Reducer's mixin-style is deprecated and may be removed in 2.0.
|
74
|
-
# To keep using Rack::Reducer in your models, create a Reducer constant.
|
75
|
-
# class MyModel < ActiveRecord::Base
|
76
|
-
# MyReducer = Rack::Reducer.create(dataset, *filter_functions)
|
77
|
-
# end
|
78
|
-
# MyModel::MyReducer.call(params)
|
79
|
-
def reduces(dataset, filters:)
|
80
|
-
warn "#{caller(1..1).first}}\n#{Warnings[:reduces]}"
|
81
|
-
reducer = Reduction.new(dataset, *filters)
|
82
|
-
define_singleton_method :reduce do |params|
|
83
|
-
reducer.apply(params)
|
66
|
+
private
|
67
|
+
|
68
|
+
def reduce(params, filters)
|
69
|
+
filters.reduce(@dataset) do |data, filter|
|
70
|
+
next data unless filter.satisfies?(params)
|
71
|
+
|
72
|
+
data.instance_exec(
|
73
|
+
**params.slice(*filter.all_argument_names),
|
74
|
+
&filter
|
75
|
+
)
|
84
76
|
end
|
85
77
|
end
|
78
|
+
|
79
|
+
EMPTY_PARAMS = {}.freeze
|
80
|
+
private_constant :EMPTY_PARAMS
|
86
81
|
end
|
87
82
|
end
|
@@ -1,10 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'rack/request'
|
4
|
-
require_relative 'reduction'
|
5
4
|
|
6
5
|
module Rack
|
7
|
-
|
6
|
+
class Reducer
|
8
7
|
# Mount Rack::Reducer as middleware
|
9
8
|
# @example A microservice that filters artists
|
10
9
|
# ArtistService = Rack::Builder.new do
|
@@ -23,10 +22,10 @@ module Rack
|
|
23
22
|
def initialize(app, options = {})
|
24
23
|
@app = app
|
25
24
|
@key = options[:key] || 'rack.reduction'
|
26
|
-
@reducer = Rack::Reducer.
|
25
|
+
@reducer = Rack::Reducer.new(options[:dataset], *options[:filters])
|
27
26
|
end
|
28
27
|
|
29
|
-
# Call the next app in the middleware stack, with env[key] set
|
28
|
+
# Call the next app in the middleware stack, with `env[key]` set
|
30
29
|
# to the ouput of a reduction
|
31
30
|
def call(env)
|
32
31
|
params = Rack::Request.new(env).params
|
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Rack
|
4
|
-
|
5
|
-
#
|
4
|
+
class Reducer
|
5
|
+
# Refine a few core classes in Rack::Reducer's scope only
|
6
6
|
module Refinements
|
7
7
|
refine Proc do
|
8
8
|
def required_argument_names
|
@@ -37,6 +37,12 @@ module Rack
|
|
37
37
|
|
38
38
|
alias_method :to_unsafe_h, :to_h
|
39
39
|
end
|
40
|
+
|
41
|
+
refine NilClass do
|
42
|
+
def empty?
|
43
|
+
true
|
44
|
+
end
|
45
|
+
end
|
40
46
|
end
|
41
47
|
|
42
48
|
private_constant :Refinements
|
data/lib/rack/reducer/version.rb
CHANGED
data/spec/fixtures.rb
CHANGED
@@ -17,14 +17,11 @@ module Fixtures
|
|
17
17
|
->(name:) {
|
18
18
|
select { |item| item[:name].match(/#{name}/i) }
|
19
19
|
},
|
20
|
-
->(sort: 'name') {
|
21
|
-
sort_by { |item| item[sort.to_sym] }
|
22
|
-
},
|
23
20
|
->(releases:) {
|
24
21
|
select { |item| item[:release_count].to_i == releases.to_i }
|
25
22
|
},
|
26
23
|
]
|
27
24
|
|
28
|
-
ArtistReducer = Rack::Reducer.
|
25
|
+
ArtistReducer = Rack::Reducer.new(DB[:artists], *FILTERS)
|
29
26
|
end
|
30
27
|
|
data/spec/middleware_spec.rb
CHANGED
@@ -4,10 +4,10 @@ require_relative 'fixtures'
|
|
4
4
|
RSpec.describe Rack::Reducer::Middleware do
|
5
5
|
using SpecRefinements
|
6
6
|
module AppFactory
|
7
|
-
def self.create(key: nil
|
7
|
+
def self.create(key: nil)
|
8
8
|
Rack::Builder.new do
|
9
9
|
use(
|
10
|
-
|
10
|
+
Rack::Reducer::Middleware,
|
11
11
|
dataset: Fixtures::DB[:artists],
|
12
12
|
filters: Fixtures::FILTERS,
|
13
13
|
key: key
|
@@ -44,20 +44,4 @@ RSpec.describe Rack::Reducer::Middleware do
|
|
44
44
|
end
|
45
45
|
end
|
46
46
|
end
|
47
|
-
|
48
|
-
describe 'using Rack::Reducer instead of Rack::Reducer::Middleware' do
|
49
|
-
before do
|
50
|
-
@warnings = []
|
51
|
-
allow(Rack::Reducer).to receive(:warn) do |msg|
|
52
|
-
@warnings << msg
|
53
|
-
end
|
54
|
-
end
|
55
|
-
|
56
|
-
let(:app) { AppFactory.create(middleware_class: Rack::Reducer) }
|
57
|
-
|
58
|
-
it 'emits a deprecation warning' do
|
59
|
-
get('/')
|
60
|
-
expect(@warnings.last).to include('alias of ::create')
|
61
|
-
end
|
62
|
-
end
|
63
47
|
end
|
data/spec/reducer_spec.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
require_relative 'fixtures'
|
3
3
|
|
4
|
-
RSpec.describe Rack::Reducer do
|
4
|
+
RSpec.describe 'Rack::Reducer' do
|
5
5
|
using SpecRefinements
|
6
6
|
|
7
7
|
let(:app) do
|
@@ -64,17 +64,31 @@ RSpec.describe Rack::Reducer do
|
|
64
64
|
expect(Fixtures::ArtistReducer.apply(nil)).to be_truthy
|
65
65
|
end
|
66
66
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
67
|
+
describe 'with default filters' do
|
68
|
+
let(:app) do
|
69
|
+
sort = ->(sort: 'name') { sort_by { |item| item[sort.to_sym] } }
|
70
|
+
filters = Fixtures::FILTERS + [sort]
|
71
|
+
reducer = Rack::Reducer.new(Fixtures::DB[:artists], *filters)
|
72
|
+
|
73
|
+
lambda do |env|
|
74
|
+
req = Rack::Request.new(env)
|
75
|
+
res = reducer.apply(req.params).to_json
|
76
|
+
[200, { 'Content-Type' => 'application/json' }, [res]]
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'applies default filters' do
|
81
|
+
get '/artists' do |response|
|
82
|
+
name = response.json[0]['name']
|
83
|
+
expect(name).to eq('Björk')
|
84
|
+
end
|
71
85
|
end
|
72
|
-
end
|
73
86
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
87
|
+
it 'overrides default filters with values from params' do
|
88
|
+
get '/artists?sort=genre' do |response|
|
89
|
+
genre = response.json[0]['genre']
|
90
|
+
expect(genre).to eq('alt-soul')
|
91
|
+
end
|
78
92
|
end
|
79
93
|
end
|
80
94
|
|
@@ -91,22 +105,8 @@ RSpec.describe Rack::Reducer do
|
|
91
105
|
end
|
92
106
|
end
|
93
107
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
let(:model) do
|
98
|
-
dataset = Fixtures::DB[:artists].dup
|
99
|
-
allow(dataset).to(receive(:warn)) { |msg| @warnings << msg }
|
100
|
-
dataset.extend Rack::Reducer
|
101
|
-
dataset.reduces dataset, filters: Fixtures::FILTERS
|
102
|
-
dataset
|
103
|
-
end
|
104
|
-
|
105
|
-
it 'is still supported, but with a deprecation warning' do
|
106
|
-
params = { 'genre' => 'electronic', 'name' => 'blake' }
|
107
|
-
expect(model.reduce(params).count).to eq(1)
|
108
|
-
expect(@warnings.first).to include('mixin-style is deprecated')
|
109
|
-
end
|
108
|
+
it 'aliases ::create and ::new' do
|
109
|
+
expect(Rack::Reducer.create({}, -> { 'hi' })).to be_a(Rack::Reducer)
|
110
110
|
end
|
111
111
|
|
112
112
|
it 'accepts nested params' do
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rack-reducer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Chris Frank
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
11
|
+
date: 2019-05-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: actionpack
|
@@ -222,10 +222,8 @@ files:
|
|
222
222
|
- README.md
|
223
223
|
- lib/rack/reducer.rb
|
224
224
|
- lib/rack/reducer/middleware.rb
|
225
|
-
- lib/rack/reducer/reduction.rb
|
226
225
|
- lib/rack/reducer/refinements.rb
|
227
226
|
- lib/rack/reducer/version.rb
|
228
|
-
- lib/rack/reducer/warnings.rb
|
229
227
|
- spec/benchmarks.rb
|
230
228
|
- spec/fixtures.rb
|
231
229
|
- spec/middleware_spec.rb
|
@@ -1,46 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require_relative 'refinements'
|
4
|
-
|
5
|
-
module Rack
|
6
|
-
module Reducer
|
7
|
-
# call `reduce` on a params hash, filtering data via lambdas with
|
8
|
-
# matching keyword arguments
|
9
|
-
class Reduction
|
10
|
-
using Refinements # define Proc#required_argument_names, #satisfies?, etc
|
11
|
-
|
12
|
-
def initialize(dataset, *filters)
|
13
|
-
@dataset = dataset
|
14
|
-
@filters = filters
|
15
|
-
@default_filters = filters.select do |filter|
|
16
|
-
filter.required_argument_names.empty?
|
17
|
-
end
|
18
|
-
end
|
19
|
-
|
20
|
-
# Run +@filters+ against the params argument
|
21
|
-
# @param [Hash, ActionController::Parameters, nil] params
|
22
|
-
# a Rack-compatible params hash
|
23
|
-
# @return +@dataset+ with the matching filters applied
|
24
|
-
def apply(params)
|
25
|
-
if !params || params.empty?
|
26
|
-
return @dataset if @default_filters.empty?
|
27
|
-
|
28
|
-
filters = @default_filters
|
29
|
-
symbolized_params = {}
|
30
|
-
else
|
31
|
-
filters = @filters
|
32
|
-
symbolized_params = params.to_unsafe_h.symbolize_keys
|
33
|
-
end
|
34
|
-
|
35
|
-
filters.reduce(@dataset) do |data, filter|
|
36
|
-
next data unless filter.satisfies?(symbolized_params)
|
37
|
-
|
38
|
-
data.instance_exec(
|
39
|
-
**symbolized_params.slice(*filter.all_argument_names),
|
40
|
-
&filter
|
41
|
-
)
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
45
|
-
end
|
46
|
-
end
|
@@ -1,27 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Rack
|
4
|
-
module Reducer
|
5
|
-
module Warnings
|
6
|
-
MESSAGES = {
|
7
|
-
new: [
|
8
|
-
'Rack::Reducer.new will become an alias of ::create in v2.',
|
9
|
-
'To mount middleware that will still work in 2.0, write',
|
10
|
-
'"use Rack::Reducer::Middleware" instead of "use Rack::Reducer"',
|
11
|
-
],
|
12
|
-
reduces: [
|
13
|
-
'Rack::Reducer’s mixin-style is deprecated and may be removed in v2.',
|
14
|
-
'To keep using Rack::Reducer in your models, use a Reducer constant.',
|
15
|
-
'class MyModel',
|
16
|
-
' MyReducer = Rack::Reducer.create(dataset, *filter_functions)',
|
17
|
-
'end',
|
18
|
-
'MyModel::MyReducer.call(params)',
|
19
|
-
]
|
20
|
-
}.freeze
|
21
|
-
|
22
|
-
def self.[](key)
|
23
|
-
MESSAGES.fetch(key, []).join("\n")
|
24
|
-
end
|
25
|
-
end
|
26
|
-
end
|
27
|
-
end
|