activerecord-cipherstash-pg-adapter 0.8.1 → 0.8.2

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/activerecord-cipherstash-pg-adapter.gemspec +1 -1
  4. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/column.rb +70 -0
  5. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/database_statements.rb +199 -0
  6. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/explain_pretty_printer.rb +44 -0
  7. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/array.rb +91 -0
  8. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/bit.rb +53 -0
  9. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/bit_varying.rb +15 -0
  10. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/bytea.rb +17 -0
  11. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/cidr.rb +48 -0
  12. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/date.rb +31 -0
  13. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/date_time.rb +36 -0
  14. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/decimal.rb +15 -0
  15. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/enum.rb +20 -0
  16. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/hstore.rb +109 -0
  17. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/inet.rb +15 -0
  18. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/interval.rb +49 -0
  19. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/jsonb.rb +15 -0
  20. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/legacy_point.rb +44 -0
  21. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/macaddr.rb +25 -0
  22. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/money.rb +41 -0
  23. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/oid.rb +15 -0
  24. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/point.rb +64 -0
  25. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/range.rb +124 -0
  26. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/specialized_string.rb +18 -0
  27. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/timestamp.rb +15 -0
  28. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/timestamp_with_time_zone.rb +30 -0
  29. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/type_map_initializer.rb +125 -0
  30. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/uuid.rb +35 -0
  31. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/vector.rb +28 -0
  32. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/xml.rb +30 -0
  33. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid.rb +38 -0
  34. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/quoting.rb +237 -0
  35. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/referential_integrity.rb +71 -0
  36. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/schema_creation.rb +170 -0
  37. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/schema_definitions.rb +372 -0
  38. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/schema_dumper.rb +116 -0
  39. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/schema_statements.rb +1110 -0
  40. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/type_metadata.rb +44 -0
  41. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/utils.rb +79 -0
  42. data/lib/active_record/connection_adapters/7.1/postgres_cipherstash_adapter.rb +1266 -0
  43. data/lib/active_record/connection_adapters/postgres_cipherstash_adapter.rb +5 -1
  44. data/lib/version.rb +1 -1
  45. metadata +44 -5
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/array/extract"
4
+
5
+ module ActiveRecord
6
+ module ConnectionAdapters
7
+ module CipherStashPG
8
+ module OID # :nodoc:
9
+ # This class uses the data from PostgreSQL pg_type table to build
10
+ # the OID -> Type mapping.
11
+ # - OID is an integer representing the type.
12
+ # - Type is an OID::Type object.
13
+ # This class has side effects on the +store+ passed during initialization.
14
+ class TypeMapInitializer # :nodoc:
15
+ def initialize(store)
16
+ @store = store
17
+ end
18
+
19
+ def run(records)
20
+ nodes = records.reject { |row| @store.key? row["oid"].to_i }
21
+ mapped = nodes.extract! { |row| @store.key? row["typname"] }
22
+ ranges = nodes.extract! { |row| row["typtype"] == "r" }
23
+ enums = nodes.extract! { |row| row["typtype"] == "e" }
24
+ domains = nodes.extract! { |row| row["typtype"] == "d" }
25
+ arrays = nodes.extract! { |row| row["typinput"] == "array_in" }
26
+ composites = nodes.extract! { |row| row["typelem"].to_i != 0 }
27
+
28
+ mapped.each { |row| register_mapped_type(row) }
29
+ enums.each { |row| register_enum_type(row) }
30
+ domains.each { |row| register_domain_type(row) }
31
+ arrays.each { |row| register_array_type(row) }
32
+ ranges.each { |row| register_range_type(row) }
33
+ composites.each { |row| register_composite_type(row) }
34
+ end
35
+
36
+ def query_conditions_for_known_type_names
37
+ known_type_names = @store.keys.map { |n| "'#{n}'" }
38
+ <<~SQL % known_type_names.join(", ")
39
+ WHERE
40
+ t.typname IN (%s)
41
+ SQL
42
+ end
43
+
44
+ def query_conditions_for_known_type_types
45
+ known_type_types = %w('r' 'e' 'd')
46
+ <<~SQL % known_type_types.join(", ")
47
+ WHERE
48
+ t.typtype IN (%s)
49
+ SQL
50
+ end
51
+
52
+ def query_conditions_for_array_types
53
+ known_type_oids = @store.keys.reject { |k| k.is_a?(String) }
54
+ <<~SQL % [known_type_oids.join(", ")]
55
+ WHERE
56
+ t.typelem IN (%s)
57
+ SQL
58
+ end
59
+
60
+ private
61
+ def register_mapped_type(row)
62
+ alias_type row["oid"], row["typname"]
63
+ end
64
+
65
+ def register_enum_type(row)
66
+ register row["oid"], OID::Enum.new
67
+ end
68
+
69
+ def register_array_type(row)
70
+ register_with_subtype(row["oid"], row["typelem"].to_i) do |subtype|
71
+ OID::Array.new(subtype, row["typdelim"])
72
+ end
73
+ end
74
+
75
+ def register_range_type(row)
76
+ register_with_subtype(row["oid"], row["rngsubtype"].to_i) do |subtype|
77
+ OID::Range.new(subtype, row["typname"].to_sym)
78
+ end
79
+ end
80
+
81
+ def register_domain_type(row)
82
+ if base_type = @store.lookup(row["typbasetype"].to_i)
83
+ register row["oid"], base_type
84
+ else
85
+ warn "unknown base type (OID: #{row["typbasetype"]}) for domain #{row["typname"]}."
86
+ end
87
+ end
88
+
89
+ def register_composite_type(row)
90
+ if subtype = @store.lookup(row["typelem"].to_i)
91
+ register row["oid"], OID::Vector.new(row["typdelim"], subtype)
92
+ end
93
+ end
94
+
95
+ def register(oid, oid_type = nil, &block)
96
+ oid = assert_valid_registration(oid, oid_type || block)
97
+ if block_given?
98
+ @store.register_type(oid, &block)
99
+ else
100
+ @store.register_type(oid, oid_type)
101
+ end
102
+ end
103
+
104
+ def alias_type(oid, target)
105
+ oid = assert_valid_registration(oid, target)
106
+ @store.alias_type(oid, target)
107
+ end
108
+
109
+ def register_with_subtype(oid, target_oid)
110
+ if @store.key?(target_oid)
111
+ register(oid) do |_, *args|
112
+ yield @store.lookup(target_oid, *args)
113
+ end
114
+ end
115
+ end
116
+
117
+ def assert_valid_registration(oid, oid_type)
118
+ raise ArgumentError, "can't register nil type for OID #{oid}" if oid_type.nil?
119
+ oid.to_i
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module CipherStashPG
6
+ module OID # :nodoc:
7
+ class Uuid < Type::Value # :nodoc:
8
+ ACCEPTABLE_UUID = %r{\A(\{)?([a-fA-F0-9]{4}-?){8}(?(1)\}|)\z}
9
+
10
+ alias :serialize :deserialize
11
+
12
+ def type
13
+ :uuid
14
+ end
15
+
16
+ def changed?(old_value, new_value, _new_value_before_type_cast)
17
+ old_value.class != new_value.class ||
18
+ new_value && old_value.casecmp(new_value) != 0
19
+ end
20
+
21
+ def changed_in_place?(raw_old_value, new_value)
22
+ raw_old_value.class != new_value.class ||
23
+ new_value && raw_old_value.casecmp(new_value) != 0
24
+ end
25
+
26
+ private
27
+ def cast_value(value)
28
+ casted = value.to_s
29
+ casted if casted.match?(ACCEPTABLE_UUID)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module CipherStashPG
6
+ module OID # :nodoc:
7
+ class Vector < Type::Value # :nodoc:
8
+ attr_reader :delim, :subtype
9
+
10
+ # +delim+ corresponds to the `typdelim` column in the pg_types
11
+ # table. +subtype+ is derived from the `typelem` column in the
12
+ # pg_types table.
13
+ def initialize(delim, subtype)
14
+ @delim = delim
15
+ @subtype = subtype
16
+ end
17
+
18
+ # FIXME: this should probably split on +delim+ and use +subtype+
19
+ # to cast the values. Unfortunately, the current Rails behavior
20
+ # is to just return the string.
21
+ def cast(value)
22
+ value
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module CipherStashPG
6
+ module OID # :nodoc:
7
+ class Xml < Type::String # :nodoc:
8
+ def type
9
+ :xml
10
+ end
11
+
12
+ def serialize(value)
13
+ return unless value
14
+ Data.new(super)
15
+ end
16
+
17
+ class Data # :nodoc:
18
+ def initialize(value)
19
+ @value = value
20
+ end
21
+
22
+ def to_s
23
+ @value
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./oid/array"
4
+ require_relative "./oid/bit"
5
+ require_relative "./oid/bit_varying"
6
+ require_relative "./oid/bytea"
7
+ require_relative "./oid/cidr"
8
+ require_relative "./oid/date"
9
+ require_relative "./oid/date_time"
10
+ require_relative "./oid/decimal"
11
+ require_relative "./oid/enum"
12
+ require_relative "./oid/hstore"
13
+ require_relative "./oid/inet"
14
+ require_relative "./oid/interval"
15
+ require_relative "./oid/jsonb"
16
+ require_relative "./oid/macaddr"
17
+ require_relative "./oid/money"
18
+ require_relative "./oid/oid"
19
+ require_relative "./oid/point"
20
+ require_relative "./oid/legacy_point"
21
+ require_relative "./oid/range"
22
+ require_relative "./oid/specialized_string"
23
+ require_relative "./oid/timestamp"
24
+ require_relative "./oid/timestamp_with_time_zone"
25
+ require_relative "./oid/uuid"
26
+ require_relative "./oid/vector"
27
+ require_relative "./oid/xml"
28
+
29
+ require_relative "./oid/type_map_initializer"
30
+
31
+ module ActiveRecord
32
+ module ConnectionAdapters
33
+ module CipherStashPG
34
+ module OID # :nodoc:
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module CipherStashPG
6
+ module Quoting
7
+ QUOTED_COLUMN_NAMES = Concurrent::Map.new # :nodoc:
8
+ QUOTED_TABLE_NAMES = Concurrent::Map.new # :nodoc:
9
+
10
+ class IntegerOutOf64BitRange < StandardError
11
+ def initialize(msg)
12
+ super(msg)
13
+ end
14
+ end
15
+
16
+ # Escapes binary strings for bytea input to the database.
17
+ def escape_bytea(value)
18
+ valid_raw_connection.escape_bytea(value) if value
19
+ end
20
+
21
+ # Unescapes bytea output from a database to the binary string it represents.
22
+ # NOTE: This is NOT an inverse of escape_bytea! This is only to be used
23
+ # on escaped binary output from database drive.
24
+ def unescape_bytea(value)
25
+ valid_raw_connection.unescape_bytea(value) if value
26
+ end
27
+
28
+ def check_int_in_range(value)
29
+ if value.to_int > 9223372036854775807 || value.to_int < -9223372036854775808
30
+ exception = <<~ERROR
31
+ Provided value outside of the range of a signed 64bit integer.
32
+
33
+ PostgreSQL will treat the column type in question as a numeric.
34
+ This may result in a slow sequential scan due to a comparison
35
+ being performed between an integer or bigint value and a numeric value.
36
+
37
+ To allow for this potentially unwanted behavior, set
38
+ ActiveRecord.raise_int_wider_than_64bit to false.
39
+ ERROR
40
+ raise IntegerOutOf64BitRange.new exception
41
+ end
42
+ end
43
+
44
+ def quote(value) # :nodoc:
45
+ if ActiveRecord.raise_int_wider_than_64bit && value.is_a?(Integer)
46
+ check_int_in_range(value)
47
+ end
48
+
49
+ case value
50
+ when OID::Xml::Data
51
+ "xml '#{quote_string(value.to_s)}'"
52
+ when OID::Bit::Data
53
+ if value.binary?
54
+ "B'#{value}'"
55
+ elsif value.hex?
56
+ "X'#{value}'"
57
+ end
58
+ when Numeric
59
+ if value.finite?
60
+ super
61
+ else
62
+ "'#{value}'"
63
+ end
64
+ when OID::Array::Data
65
+ quote(encode_array(value))
66
+ when Range
67
+ quote(encode_range(value))
68
+ else
69
+ super
70
+ end
71
+ end
72
+
73
+ # Quotes strings for use in SQL input.
74
+ def quote_string(s) # :nodoc:
75
+ with_raw_connection(allow_retry: true, materialize_transactions: false) do |connection|
76
+ connection.escape(s)
77
+ end
78
+ end
79
+
80
+ # Checks the following cases:
81
+ #
82
+ # - table_name
83
+ # - "table.name"
84
+ # - schema_name.table_name
85
+ # - schema_name."table.name"
86
+ # - "schema.name".table_name
87
+ # - "schema.name"."table.name"
88
+ def quote_table_name(name) # :nodoc:
89
+ QUOTED_TABLE_NAMES[name] ||= Utils.extract_schema_qualified_name(name.to_s).quoted.freeze
90
+ end
91
+
92
+ # Quotes schema names for use in SQL queries.
93
+ def quote_schema_name(name)
94
+ ::CipherStashPG::Connection.quote_ident(name)
95
+ end
96
+
97
+ def quote_table_name_for_assignment(table, attr)
98
+ quote_column_name(attr)
99
+ end
100
+
101
+ # Quotes column names for use in SQL queries.
102
+ def quote_column_name(name) # :nodoc:
103
+ QUOTED_COLUMN_NAMES[name] ||= ::CipherStashPG::Connection.quote_ident(super).freeze
104
+ end
105
+
106
+ # Quote date/time values for use in SQL input.
107
+ def quoted_date(value) # :nodoc:
108
+ if value.year <= 0
109
+ bce_year = format("%04d", -value.year + 1)
110
+ super.sub(/^-?\d+/, bce_year) + " BC"
111
+ else
112
+ super
113
+ end
114
+ end
115
+
116
+ def quoted_binary(value) # :nodoc:
117
+ "'#{escape_bytea(value.to_s)}'"
118
+ end
119
+
120
+ def quote_default_expression(value, column) # :nodoc:
121
+ if value.is_a?(Proc)
122
+ value.call
123
+ elsif column.type == :uuid && value.is_a?(String) && value.include?("()")
124
+ value # Does not quote function default values for UUID columns
125
+ elsif column.respond_to?(:array?)
126
+ type = lookup_cast_type_from_column(column)
127
+ quote(type.serialize(value))
128
+ else
129
+ super
130
+ end
131
+ end
132
+
133
+ def type_cast(value) # :nodoc:
134
+ case value
135
+ when Type::Binary::Data
136
+ # Return a bind param hash with format as binary.
137
+ # See https://deveiate.org/code/pg/PG/Connection.html#method-i-exec_prepared-doc
138
+ # for more information
139
+ { value: value.to_s, format: 1 }
140
+ when OID::Xml::Data, OID::Bit::Data
141
+ value.to_s
142
+ when OID::Array::Data
143
+ encode_array(value)
144
+ when Range
145
+ encode_range(value)
146
+ else
147
+ super
148
+ end
149
+ end
150
+
151
+ def lookup_cast_type_from_column(column) # :nodoc:
152
+ type_map.lookup(column.oid, column.fmod, column.sql_type)
153
+ end
154
+
155
+ def column_name_matcher
156
+ COLUMN_NAME
157
+ end
158
+
159
+ def column_name_with_order_matcher
160
+ COLUMN_NAME_WITH_ORDER
161
+ end
162
+
163
+ COLUMN_NAME = /
164
+ \A
165
+ (
166
+ (?:
167
+ # "schema_name"."table_name"."column_name"::type_name | function(one or no argument)::type_name
168
+ ((?:\w+\.|"\w+"\.){,2}(?:\w+|"\w+")(?:::\w+)? | \w+\((?:|\g<2>)\)(?:::\w+)?)
169
+ )
170
+ (?:(?:\s+AS)?\s+(?:\w+|"\w+"))?
171
+ )
172
+ (?:\s*,\s*\g<1>)*
173
+ \z
174
+ /ix
175
+
176
+ COLUMN_NAME_WITH_ORDER = /
177
+ \A
178
+ (
179
+ (?:
180
+ # "schema_name"."table_name"."column_name"::type_name | function(one or no argument)::type_name
181
+ ((?:\w+\.|"\w+"\.){,2}(?:\w+|"\w+")(?:::\w+)? | \w+\((?:|\g<2>)\)(?:::\w+)?)
182
+ )
183
+ (?:\s+COLLATE\s+"\w+")?
184
+ (?:\s+ASC|\s+DESC)?
185
+ (?:\s+NULLS\s+(?:FIRST|LAST))?
186
+ )
187
+ (?:\s*,\s*\g<1>)*
188
+ \z
189
+ /ix
190
+
191
+ private_constant :COLUMN_NAME, :COLUMN_NAME_WITH_ORDER
192
+
193
+ private
194
+ def lookup_cast_type(sql_type)
195
+ super(query_value("SELECT #{quote(sql_type)}::regtype::oid", "SCHEMA").to_i)
196
+ end
197
+
198
+ def encode_array(array_data)
199
+ encoder = array_data.encoder
200
+ values = type_cast_array(array_data.values)
201
+
202
+ result = encoder.encode(values)
203
+ if encoding = determine_encoding_of_strings_in_array(values)
204
+ result.force_encoding(encoding)
205
+ end
206
+ result
207
+ end
208
+
209
+ def encode_range(range)
210
+ "[#{type_cast_range_value(range.begin)},#{type_cast_range_value(range.end)}#{range.exclude_end? ? ')' : ']'}"
211
+ end
212
+
213
+ def determine_encoding_of_strings_in_array(value)
214
+ case value
215
+ when ::Array then determine_encoding_of_strings_in_array(value.first)
216
+ when ::String then value.encoding
217
+ end
218
+ end
219
+
220
+ def type_cast_array(values)
221
+ case values
222
+ when ::Array then values.map { |item| type_cast_array(item) }
223
+ else type_cast(values)
224
+ end
225
+ end
226
+
227
+ def type_cast_range_value(value)
228
+ infinity?(value) ? "" : type_cast(value)
229
+ end
230
+
231
+ def infinity?(value)
232
+ value.respond_to?(:infinite?) && value.infinite?
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module CipherStashPG
6
+ module ReferentialIntegrity # :nodoc:
7
+ def disable_referential_integrity # :nodoc:
8
+ original_exception = nil
9
+
10
+ begin
11
+ transaction(requires_new: true) do
12
+ execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} DISABLE TRIGGER ALL" }.join(";"))
13
+ end
14
+ rescue ActiveRecord::ActiveRecordError => e
15
+ original_exception = e
16
+ end
17
+
18
+ begin
19
+ yield
20
+ rescue ActiveRecord::InvalidForeignKey => e
21
+ warn <<-WARNING
22
+ WARNING: Rails was not able to disable referential integrity.
23
+
24
+ This is most likely caused due to missing permissions.
25
+ Rails needs superuser privileges to disable referential integrity.
26
+
27
+ cause: #{original_exception&.message}
28
+
29
+ WARNING
30
+ raise e
31
+ end
32
+
33
+ begin
34
+ transaction(requires_new: true) do
35
+ execute(tables.collect { |name| "ALTER TABLE #{quote_table_name(name)} ENABLE TRIGGER ALL" }.join(";"))
36
+ end
37
+ rescue ActiveRecord::ActiveRecordError
38
+ end
39
+ end
40
+
41
+ def check_all_foreign_keys_valid! # :nodoc:
42
+ sql = <<~SQL
43
+ do $$
44
+ declare r record;
45
+ BEGIN
46
+ FOR r IN (
47
+ SELECT FORMAT(
48
+ 'UPDATE pg_constraint SET convalidated=false WHERE conname = ''%I'' AND connamespace::regnamespace = ''%I''::regnamespace; ALTER TABLE %I.%I VALIDATE CONSTRAINT %I;',
49
+ constraint_name,
50
+ table_schema,
51
+ table_schema,
52
+ table_name,
53
+ constraint_name
54
+ ) AS constraint_check
55
+ FROM information_schema.table_constraints WHERE constraint_type = 'FOREIGN KEY'
56
+ )
57
+ LOOP
58
+ EXECUTE (r.constraint_check);
59
+ END LOOP;
60
+ END;
61
+ $$;
62
+ SQL
63
+
64
+ transaction(requires_new: true) do
65
+ execute(sql)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end