aerospike 2.24.0 → 2.26.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edbb7242a54e005f9be1be27da358cca13e6a0ce74013976610eb6c16d99fd45
4
- data.tar.gz: fa8f76a0176381cd41ec11a6554589bfed9b90c2630bb2e4a1551d2704dac26a
3
+ metadata.gz: 569388197af73988d3965e15f8b7caf42605896f21183a0e6f7d507690dec816
4
+ data.tar.gz: dfe35172403817d176aecfd83747e6155a961b92960a97f8efc713a6b7540d23
5
5
  SHA512:
6
- metadata.gz: 4369da31ee5502c9aca145e11777ca39c2a1a4c0d08ff539375a0f4c05aea4d6113a70ee2f645c4c5c3b01b483bf481fa1e2ca5598ff099126827101f1bdadf7
7
- data.tar.gz: fdc7964d17f671795a3fd09d786b809eeeba88cc435ae336072ca7aff1767b8cd0b778bf28fbb27dd4e3651d976ca74b179d8379c8f57a833c4e3dbaa861dd6e
6
+ metadata.gz: e4de68155586c168c75a51c71749531becdf066cb22888745f8473237cb775dd9054c6401e44a04b00bee517848859d128141692da3fc7c970df2f202e5e49b9
7
+ data.tar.gz: 7c041a196f2bf45a3ff575426b9269ba45c10e16f641c82f5ce11e362ace013d2480c407b8f1e9a663a27bcf78df1988d6af46de2bd22d41c0fce5366fb2a52c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.26.0] 2022-12-02
6
+
7
+ - **New Features**
8
+ - [CLIENT-1808] Support creating a secondary index on elements within a CDT using `Context`.
9
+ - [CLIENT-1991] Add base64 encoding methods to `Context`.
10
+ - [CLIENT-2007] Support using `Context` in query filters.
11
+
12
+ ## [2.25.0] 2022-11-28
13
+
14
+ - **New Features**
15
+
16
+ - [CLIENT-1984] Support scan-show and query-show info commands.
17
+
18
+ - [CLIENT-1362] Adds support Aerospike Expression filters. Expression filters are now supported on all commands, including `Client#get`, `Client#put`, `Client#delete`, `Client#operate`, `Client#scan`, `Client#query`, `Client#execute_udf`, etc.
19
+
20
+ - Adds `Policy#filter_exp` and `Policy#fail_on_filtered_out`
21
+
22
+ - Bit expressions: `Exp::Bit::` `#resize`, `#insert`, `#remove`, `#set`, `#or`, `#xor`, `#and`, `#not`, `#lshift`, `#rshift`, `#add`, `#subtract`, `#set_int`, `#get`, `#count`, `#lscan`, `#rscan`, `#get_int`, `#pack_math`, `#pack_get_int`, `#add_write`, `#add_read`
23
+
24
+ - HLL Expressions: `Exp::HLL::` `#init`, `#add`, `#get_count`, `#get_union`, `#get_union_count`, `#get_intersect_count`, `#get_similarity`, `#describe`, `#may_contain`, `#add_write`, `#add_read`
25
+
26
+ - Map Expressions: `Exp::Map::` `#put`, `#put_items`, `#increment`, `#clear`, `#remove_by_key`, `#remove_by_key_list`, `#remove_by_key_range`, `#remove_by_key_relative_index_range`, `#remove_by_value`, `#remove_by_value_list`, `#remove_by_value_range`, `#remove_by_value_relative_rank_range`, `#remove_by_value_relative_rank_range`, `#remove_by_index`, `#remove_by_index_range`, `#remove_by_rank`, `#remove_by_rank_range`, `#size`, `#get_by_key`, `#get_by_key_range`, `#get_by_key_list`, `#get_by_key_relative_index_range`, `#get_by_key_relative_index_range`, `#get_by_value`, `#get_by_value_range`, `#get_by_value_list`, `#get_by_value_relative_rank_range`, `#get_by_index`, `#get_by_index_range`, `#get_by_rank`, `#get_by_rank_range`, `#add_write`, `#add_read`, `#get_value_type`
27
+
28
+ - List Expressions: `Exp::List::` `#append`, `#append_items`, `#insert`, `#insert_items`, `#increment`, `#set`, `#clear`, `#sort`, `#remove_by_value`, `#remove_by_value_list`, `#remove_by_value_range`, `#remove_by_value_relative_rank_range`, `#remove_by_index`, `#remove_by_index_range`, `#remove_by_rank`, `#remove_by_rank_range`, `#size`, `#get_by_value`, `#get_by_value_range`, `#get_by_value_list`, `#get_by_value_relative_rank_range`, `#get_by_index`, `#get_by_index_range`, `#get_by_index_range`, `#get_by_rank`, `#get_by_rank_range`, `#get_by_rank_range`, `#add_write`, `#add_read`, `#get_value_type`, `#pack_range_operation`
29
+
30
+ - Read and Write operations: `Exp::Operation::` `#write`, `#read`
31
+
5
32
  ## [2.24.0] 2022-11-15
6
33
 
7
34
  - **New Features**
@@ -17,16 +17,17 @@
17
17
  # License for the specific language governing permissions and limitations under
18
18
  # the License.
19
19
 
20
+ require "base64"
21
+
20
22
  module Aerospike
21
23
  module CDT
22
24
 
23
- ##
24
- # Nested CDT context. Identifies the location of nested list/map to apply the operation.
25
- # for the current level.
26
- # An array of CTX identifies location of the list/map on multiple
27
- # levels on nesting.
25
+ ##
26
+ # Nested CDT context. Identifies the location of nested list/map to apply the operation.
27
+ # for the current level.
28
+ # An array of CTX identifies location of the list/map on multiple
29
+ # levels on nesting.
28
30
  class Context
29
-
30
31
  attr_accessor :id, :value
31
32
 
32
33
  def initialize(id, value)
@@ -37,64 +38,64 @@ module Aerospike
37
38
  ##
38
39
  # Create list with given type at index offset, given an order and pad.
39
40
  def self.list_index_create(index, order, pad)
40
- Context.new(0x10 | ListOrder.flag(order, pad), index)
41
- end
42
-
43
- ##
44
- # Lookup list by index offset.
45
- # If the index is negative, the resolved index starts backwards from end of list.
46
- # If an index is out of bounds, a parameter error will be returned.
47
- # Examples:
48
- # 0: First item.
49
- # 4: Fifth item.
50
- # -1: Last item.
51
- # -3: Third to last item.
52
- def self.list_index(index)
53
- Context.new(0x10, index)
54
- end
55
-
56
- ##
57
- # Lookup list by rank.
58
- # 0 = smallest value
59
- # N = Nth smallest value
60
- # -1 = largest value
61
- def self.list_rank(rank)
62
- Context.new(0x11, rank)
63
- end
64
-
65
- ##
66
- # Lookup list by value.
67
- def self.list_value(key)
68
- Context.new(0x13, key)
69
- end
70
-
71
- ##
72
- # Lookup map by index offset.
73
- # If the index is negative, the resolved index starts backwards from end of list.
74
- # If an index is out of bounds, a parameter error will be returned.
75
- # Examples:
76
- # 0: First item.
77
- # 4: Fifth item.
78
- # -1: Last item.
79
- # -3: Third to last item.
80
- def self.map_index(index)
81
- Context.new(0x20, index)
82
- end
83
-
84
- ##
85
- # Lookup map by rank.
86
- # 0 = smallest value
87
- # N = Nth smallest value
88
- # -1 = largest value
89
- def self.map_rank(rank)
90
- Context.new(0x21, rank)
91
- end
92
-
93
- ##
94
- # Lookup map by key.
95
- def self.map_key(key)
96
- Context.new(0x22, key)
97
- end
41
+ Context.new(0x10 | ListOrder.flag(order, pad), index)
42
+ end
43
+
44
+ ##
45
+ # Lookup list by index offset.
46
+ # If the index is negative, the resolved index starts backwards from end of list.
47
+ # If an index is out of bounds, a parameter error will be returned.
48
+ # Examples:
49
+ # 0: First item.
50
+ # 4: Fifth item.
51
+ # -1: Last item.
52
+ # -3: Third to last item.
53
+ def self.list_index(index)
54
+ Context.new(0x10, index)
55
+ end
56
+
57
+ ##
58
+ # Lookup list by rank.
59
+ # 0 = smallest value
60
+ # N = Nth smallest value
61
+ # -1 = largest value
62
+ def self.list_rank(rank)
63
+ Context.new(0x11, rank)
64
+ end
65
+
66
+ ##
67
+ # Lookup list by value.
68
+ def self.list_value(key)
69
+ Context.new(0x13, key)
70
+ end
71
+
72
+ ##
73
+ # Lookup map by index offset.
74
+ # If the index is negative, the resolved index starts backwards from end of list.
75
+ # If an index is out of bounds, a parameter error will be returned.
76
+ # Examples:
77
+ # 0: First item.
78
+ # 4: Fifth item.
79
+ # -1: Last item.
80
+ # -3: Third to last item.
81
+ def self.map_index(index)
82
+ Context.new(0x20, index)
83
+ end
84
+
85
+ ##
86
+ # Lookup map by rank.
87
+ # 0 = smallest value
88
+ # N = Nth smallest value
89
+ # -1 = largest value
90
+ def self.map_rank(rank)
91
+ Context.new(0x21, rank)
92
+ end
93
+
94
+ ##
95
+ # Lookup map by key.
96
+ def self.map_key(key)
97
+ Context.new(0x22, key)
98
+ end
98
99
 
99
100
  ##
100
101
  # Create map with given type at map key.
@@ -102,12 +103,78 @@ module Aerospike
102
103
  Context.new(0x22 | order[:flag], key)
103
104
  end
104
105
 
105
- ##
106
- # Lookup map by value.
107
- def self.map_value(key)
108
- Context.new(0x23, key)
109
- end
106
+ ##
107
+ # Lookup map by value.
108
+ def self.map_value(key)
109
+ Context.new(0x23, key)
110
+ end
111
+
112
+ ##
113
+ # Encodes the context via message pack.
114
+ def self.pack(packer, ctx)
115
+ unless ctx.to_a.empty?
116
+ packer.write_array_header(2)
117
+ ctx.each do |c|
118
+ packer.write(c.id)
119
+ Value.of(c.value)
120
+ end
121
+ end
122
+ end
123
+
124
+ ##
125
+ # Encodes the context via message pack and return the results.
126
+ def self.bytes(ctx)
127
+ unless ctx.to_a.empty?
128
+ Packer.use do |packer|
129
+ packer.write_array_header(ctx.length * 2)
130
+ ctx.each do |c|
131
+ packer.write(c.id)
132
+ Value.of(c.value).pack(packer)
133
+ end
134
+ return packer.bytes
135
+ end
136
+ end
137
+ nil
138
+ end
139
+
140
+ def ==(other)
141
+ self.id == other.id && self.value == other.value
142
+ end
110
143
 
144
+ ##
145
+ # decodes the base64 encoded messagepack byte array
146
+ # and converts it to an array of Context.
147
+ def self.from_bytes(buf)
148
+ list = nil
149
+ Unpacker.use do |unpacker|
150
+ list = unpacker.unpack(buf)
151
+ end
152
+
153
+ unless list.length % 2 == 0
154
+ raise Exceptions::Aerospike.new(Aerospike::ResultCode::PARAMETER_ERROR, "Invalid buffer")
155
+ end
156
+
157
+ list.each_slice(2).map { |id, value| Context.new(id, value) }
158
+ end
159
+
160
+ ##
161
+ # Encodes the context array to messagepack and then encodes
162
+ # the resulting byte array to base64.
163
+ def self.base64(ctx)
164
+ unless ctx.to_a.empty?
165
+ data = self.bytes(ctx)
166
+ return Base64.strict_encode64(data).force_encoding("binary")
167
+ end
168
+ ""
169
+ end
170
+
171
+ ##
172
+ # Decodes the byte array to messagepack and then decodes
173
+ # the resulting byte array to an array of Context.
174
+ def self.from_base64(buf)
175
+ bytes = Base64.strict_decode64(buf)
176
+ self.from_bytes(bytes)
177
+ end
111
178
  end
112
179
  end
113
180
  end
@@ -17,8 +17,8 @@
17
17
  module Aerospike
18
18
  module CDT
19
19
  class MapPolicy
20
-
21
20
  attr_accessor :order, :write_mode, :flags
21
+ attr_accessor :item_command, :items_command, :attributes
22
22
 
23
23
  def initialize(order: nil, write_mode: nil, flags: nil)
24
24
  if write_mode && flags
@@ -28,10 +28,24 @@ module Aerospike
28
28
  @order = order || MapOrder::DEFAULT
29
29
  @write_mode = write_mode || MapWriteMode::DEFAULT
30
30
  @flags = flags || MapWriteFlags::DEFAULT
31
+ @attributes = order ? order[:attr] : 0
32
+
33
+ case @write_mode
34
+ when CDT::MapWriteMode::DEFAULT
35
+ @item_command = CDT::MapOperation::PUT
36
+ @items_command = CDT::MapOperation::PUT_ITEMS
37
+ when CDT::MapWriteMode::UPDATE_ONLY
38
+ @item_command = CDT::MapOperation::REPLACE
39
+ @items_command = CDT::MapOperation::REPLACE_ITEMS
40
+ when CDT::MapWriteMode::CREATE_ONLY
41
+ @item_command = CDT::MapOperation::ADD
42
+ @items_command = CDT::MapOperation::ADD_ITEMS
43
+ else
44
+ raise Exceptions.new(ResultCode::PARAMETER_ERROR, "invalid value for MapWriteMode #{write_mode}")
45
+ end
31
46
  end
32
47
 
33
48
  DEFAULT = MapPolicy.new
34
-
35
49
  end
36
50
  end
37
51
  end
@@ -69,10 +69,18 @@ module Aerospike
69
69
  # Return true if count > 0.
70
70
  EXISTS = 13
71
71
 
72
+ ##
73
+ # :private
74
+ #
75
+ # TODO: Should be like ListOperation and Implement InvertibleMapOperation
76
+ # Inverts meaning of map command and return values. For example:
77
+ # map_remove_by_key_range(bin_name, key_begin, key_end, MapReturnType::KEY | MapReturnType::INVERTED)
78
+ # With the INVERTED flag enabled, the keys outside of the specified key range will be removed and returned.
79
+ INVERTED = 0x10000
80
+
72
81
  ##
73
82
  # Default return type: NONE
74
83
  DEFAULT_RETURN_TYPE = NONE
75
-
76
84
  end
77
85
  end
78
86
  end
@@ -15,8 +15,8 @@
15
15
  # License for the specific language governing permissions and limitations under
16
16
  # the License.
17
17
 
18
- require 'digest'
19
- require 'base64'
18
+ require "digest"
19
+ require "base64"
20
20
 
21
21
  module Aerospike
22
22
 
@@ -36,7 +36,6 @@ module Aerospike
36
36
  # +:fail_if_not_connected+ set to true
37
37
 
38
38
  class Client
39
-
40
39
  attr_accessor :default_admin_policy
41
40
  attr_accessor :default_batch_policy
42
41
  attr_accessor :default_info_policy
@@ -48,8 +47,7 @@ module Aerospike
48
47
  attr_accessor :cluster
49
48
 
50
49
  def initialize(hosts = nil, policy: ClientPolicy.new, connect: true)
51
-
52
- hosts = ::Aerospike::Host::Parse.(hosts || ENV['AEROSPIKE_HOSTS'] || 'localhost')
50
+ hosts = ::Aerospike::Host::Parse.(hosts || ENV["AEROSPIKE_HOSTS"] || "localhost")
53
51
  policy = create_policy(policy, ClientPolicy)
54
52
  set_default_policies(policy.policies)
55
53
  @cluster = Cluster.new(policy, hosts)
@@ -249,7 +247,7 @@ module Aerospike
249
247
  end
250
248
 
251
249
  response = send_info_command(policy, str_cmd, node).upcase
252
- return if response == 'OK'
250
+ return if response == "OK"
253
251
  raise Aerospike::Exceptions::Aerospike.new(Aerospike::ResultCode::SERVER_ERROR, "Truncate failed: #{response}")
254
252
  end
255
253
 
@@ -386,7 +384,8 @@ module Aerospike
386
384
  def operate(key, operations, options = nil)
387
385
  policy = create_policy(options, OperatePolicy, default_operate_policy)
388
386
 
389
- command = OperateCommand.new(@cluster, policy, key, operations)
387
+ args = OperateArgs.new(cluster, policy, default_write_policy, default_operate_policy, key, operations)
388
+ command = OperateCommand.new(@cluster, key, args)
390
389
  execute_command(command)
391
390
  command.record
392
391
  end
@@ -415,7 +414,7 @@ module Aerospike
415
414
  def register_udf(udf_body, server_path, language, options = nil)
416
415
  policy = create_policy(options, Policy, default_info_policy)
417
416
 
418
- content = Base64.strict_encode64(udf_body).force_encoding('binary')
417
+ content = Base64.strict_encode64(udf_body).force_encoding("binary")
419
418
  str_cmd = "udf-put:filename=#{server_path};content=#{content};"
420
419
  str_cmd << "content-len=#{content.length};udf-type=#{language};"
421
420
 
@@ -424,15 +423,15 @@ module Aerospike
424
423
 
425
424
  res = {}
426
425
  response_map.each do |k, response|
427
- vals = response.to_s.split(';')
426
+ vals = response.to_s.split(";")
428
427
  vals.each do |pair|
429
428
  k, v = pair.split("=", 2)
430
429
  res[k] = v
431
430
  end
432
431
  end
433
432
 
434
- if res['error']
435
- raise Aerospike::Exceptions::CommandRejected.new("Registration failed: #{res['error']}\nFile: #{res['file']}\nLine: #{res['line']}\nMessage: #{res['message']}")
433
+ if res["error"]
434
+ raise Aerospike::Exceptions::CommandRejected.new("Registration failed: #{res["error"]}\nFile: #{res["file"]}\nLine: #{res["line"]}\nMessage: #{res["message"]}")
436
435
  end
437
436
 
438
437
  UdfRegisterTask.new(@cluster, server_path)
@@ -454,7 +453,7 @@ module Aerospike
454
453
  response_map = @cluster.request_info(policy, str_cmd)
455
454
  _, response = response_map.first
456
455
 
457
- if response == 'ok'
456
+ if response == "ok"
458
457
  UdfRemoveTask.new(@cluster, udf_name)
459
458
  else
460
459
  raise Aerospike::Exceptions::Aerospike.new(Aerospike::ResultCode::SERVER_ERROR, response)
@@ -466,27 +465,27 @@ module Aerospike
466
465
  def list_udf(options = nil)
467
466
  policy = create_policy(options, Policy, default_info_policy)
468
467
 
469
- str_cmd = 'udf-list'
468
+ str_cmd = "udf-list"
470
469
 
471
470
  # Send command to one node. That node will distribute it to other nodes.
472
471
  response_map = @cluster.request_info(policy, str_cmd)
473
472
  _, response = response_map.first
474
473
 
475
- vals = response.split(';')
474
+ vals = response.split(";")
476
475
 
477
476
  vals.map do |udf_info|
478
- next if udf_info.strip! == ''
477
+ next if udf_info.strip! == ""
479
478
 
480
- udf_parts = udf_info.split(',')
479
+ udf_parts = udf_info.split(",")
481
480
  udf = UDF.new
482
481
  udf_parts.each do |values|
483
- k, v = values.split('=', 2)
482
+ k, v = values.split("=", 2)
484
483
  case k
485
- when 'filename'
484
+ when "filename"
486
485
  udf.filename = v
487
- when 'hash'
486
+ when "hash"
488
487
  udf.hash = v
489
- when 'type'
488
+ when "type"
490
489
  udf.language = v
491
490
  end
492
491
  end
@@ -501,7 +500,7 @@ module Aerospike
501
500
  # udf file = <server udf dir>/<package name>.lua
502
501
  #
503
502
  # This method is only supported by Aerospike 3 servers.
504
- def execute_udf(key, package_name, function_name, args=[], options = nil)
503
+ def execute_udf(key, package_name, function_name, args = [], options = nil)
505
504
  policy = create_policy(options, WritePolicy, default_write_policy)
506
505
 
507
506
  command = ExecuteCommand.new(@cluster, policy, key, package_name, function_name, args)
@@ -514,10 +513,10 @@ module Aerospike
514
513
  result_map = record.bins
515
514
 
516
515
  # User defined functions don't have to return a value.
517
- key, obj = result_map.detect{ |k, _| k.include?('SUCCESS') }
516
+ key, obj = result_map.detect { |k, _| k.include?("SUCCESS") }
518
517
  return obj if key
519
518
 
520
- key, obj = result_map.detect{ |k, _| k.include?('FAILURE') }
519
+ key, obj = result_map.detect { |k, _| k.include?("FAILURE") }
521
520
  message = key ? obj.to_s : "Invalid UDF return value"
522
521
  raise Aerospike::Exceptions::Aerospike.new(Aerospike::ResultCode::UDF_BAD_RESPONSE, message)
523
522
  end
@@ -530,7 +529,7 @@ module Aerospike
530
529
  #
531
530
  # This method is only supported by Aerospike 3 servers.
532
531
  # If the policy is nil, the default relevant policy will be used.
533
- def execute_udf_on_query(statement, package_name, function_name, function_args=[], options = nil)
532
+ def execute_udf_on_query(statement, package_name, function_name, function_args = [], options = nil)
534
533
  policy = create_policy(options, QueryPolicy, default_query_policy)
535
534
 
536
535
  nodes = @cluster.nodes
@@ -559,7 +558,6 @@ module Aerospike
559
558
  ExecuteTask.new(@cluster, statement)
560
559
  end
561
560
 
562
-
563
561
  # Create secondary index.
564
562
  # This asynchronous server call will return before command is complete.
565
563
  # The user can optionally wait for command completion by using the returned
@@ -568,7 +566,8 @@ module Aerospike
568
566
  # This method is only supported by Aerospike 3 servers.
569
567
  # index_type should be :string, :numeric or :geo2dsphere (requires server version 3.7 or later)
570
568
  # collection_type should be :list, :mapkeys or :mapvalues
571
- def create_index(namespace, set_name, index_name, bin_name, index_type, collection_type = nil, options = nil)
569
+ # ctx is an optional list of context. Supported on server v6.1+.
570
+ def create_index(namespace, set_name, index_name, bin_name, index_type, collection_type = nil, options = nil, ctx: nil)
572
571
  if options.nil? && collection_type.is_a?(Hash)
573
572
  options, collection_type = collection_type, nil
574
573
  end
@@ -577,18 +576,19 @@ module Aerospike
577
576
  str_cmd = "sindex-create:ns=#{namespace}"
578
577
  str_cmd << ";set=#{set_name}" unless set_name.to_s.strip.empty?
579
578
  str_cmd << ";indexname=#{index_name};numbins=1"
579
+ str_cmd << ";context=#{CDT::Context.base64(ctx)}" unless ctx.to_a.empty?
580
580
  str_cmd << ";indextype=#{collection_type.to_s.upcase}" if collection_type
581
581
  str_cmd << ";indexdata=#{bin_name},#{index_type.to_s.upcase}"
582
582
  str_cmd << ";priority=normal"
583
583
 
584
584
  # Send index command to one node. That node will distribute the command to other nodes.
585
585
  response = send_info_command(policy, str_cmd).upcase
586
- if response == 'OK'
586
+ if response == "OK"
587
587
  # Return task that could optionally be polled for completion.
588
588
  return IndexTask.new(@cluster, namespace, index_name)
589
589
  end
590
590
 
591
- if response.start_with?('FAIL:200')
591
+ if response.start_with?("FAIL:200")
592
592
  # Index has already been created. Do not need to poll for completion.
593
593
  return IndexTask.new(@cluster, namespace, index_name, true)
594
594
  end
@@ -607,10 +607,10 @@ module Aerospike
607
607
 
608
608
  # Send index command to one node. That node will distribute the command to other nodes.
609
609
  response = send_info_command(policy, str_cmd).upcase
610
- return if response == 'OK'
610
+ return if response == "OK"
611
611
 
612
612
  # Index did not previously exist. Return without error.
613
- return if response.start_with?('FAIL:201')
613
+ return if response.start_with?("FAIL:201")
614
614
 
615
615
  raise Aerospike::Exceptions::Aerospike.new(Aerospike::ResultCode::INDEX_GENERIC, "Drop index failed: #{response}")
616
616
  end
@@ -966,7 +966,5 @@ module Aerospike
966
966
 
967
967
  threads.each(&:join)
968
968
  end
969
-
970
969
  end # class
971
-
972
970
  end # module