flex_columns 1.0.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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.travis.yml +38 -0
  4. data/Gemfile +17 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +124 -0
  7. data/Rakefile +6 -0
  8. data/flex_columns.gemspec +72 -0
  9. data/lib/flex_columns.rb +15 -0
  10. data/lib/flex_columns/active_record/base.rb +57 -0
  11. data/lib/flex_columns/contents/column_data.rb +376 -0
  12. data/lib/flex_columns/contents/flex_column_contents_base.rb +188 -0
  13. data/lib/flex_columns/definition/field_definition.rb +316 -0
  14. data/lib/flex_columns/definition/field_set.rb +89 -0
  15. data/lib/flex_columns/definition/flex_column_contents_class.rb +327 -0
  16. data/lib/flex_columns/errors.rb +236 -0
  17. data/lib/flex_columns/has_flex_columns.rb +187 -0
  18. data/lib/flex_columns/including/include_flex_columns.rb +179 -0
  19. data/lib/flex_columns/util/dynamic_methods_module.rb +86 -0
  20. data/lib/flex_columns/util/string_utils.rb +31 -0
  21. data/lib/flex_columns/version.rb +4 -0
  22. data/spec/flex_columns/helpers/database_helper.rb +174 -0
  23. data/spec/flex_columns/helpers/exception_helpers.rb +20 -0
  24. data/spec/flex_columns/helpers/system_helpers.rb +47 -0
  25. data/spec/flex_columns/system/basic_system_spec.rb +245 -0
  26. data/spec/flex_columns/system/bulk_system_spec.rb +153 -0
  27. data/spec/flex_columns/system/compression_system_spec.rb +218 -0
  28. data/spec/flex_columns/system/custom_methods_system_spec.rb +120 -0
  29. data/spec/flex_columns/system/delegation_system_spec.rb +175 -0
  30. data/spec/flex_columns/system/dynamism_system_spec.rb +158 -0
  31. data/spec/flex_columns/system/error_handling_system_spec.rb +117 -0
  32. data/spec/flex_columns/system/including_system_spec.rb +285 -0
  33. data/spec/flex_columns/system/json_alias_system_spec.rb +171 -0
  34. data/spec/flex_columns/system/performance_system_spec.rb +218 -0
  35. data/spec/flex_columns/system/postgres_json_column_type_system_spec.rb +85 -0
  36. data/spec/flex_columns/system/types_system_spec.rb +93 -0
  37. data/spec/flex_columns/system/unknown_fields_system_spec.rb +126 -0
  38. data/spec/flex_columns/system/validations_system_spec.rb +111 -0
  39. data/spec/flex_columns/unit/active_record/base_spec.rb +32 -0
  40. data/spec/flex_columns/unit/contents/column_data_spec.rb +520 -0
  41. data/spec/flex_columns/unit/contents/flex_column_contents_base_spec.rb +253 -0
  42. data/spec/flex_columns/unit/definition/field_definition_spec.rb +617 -0
  43. data/spec/flex_columns/unit/definition/field_set_spec.rb +142 -0
  44. data/spec/flex_columns/unit/definition/flex_column_contents_class_spec.rb +733 -0
  45. data/spec/flex_columns/unit/errors_spec.rb +297 -0
  46. data/spec/flex_columns/unit/has_flex_columns_spec.rb +365 -0
  47. data/spec/flex_columns/unit/including/include_flex_columns_spec.rb +144 -0
  48. data/spec/flex_columns/unit/util/dynamic_methods_module_spec.rb +105 -0
  49. data/spec/flex_columns/unit/util/string_utils_spec.rb +23 -0
  50. metadata +286 -0
@@ -0,0 +1,89 @@
1
+ require 'flex_columns/errors'
2
+ require 'flex_columns/definition/field_definition'
3
+
4
+ module FlexColumns
5
+ module Definition
6
+ # A FieldSet keeps track of a set of FieldDefinition objects for a particular flex-column contents calss. It's largely
7
+ # a wrapper around this set that allows you to add fields (via #field), find fields based on their name or JSON
8
+ # storage name, return all field names, and invoke certain delegation methods across all fields.
9
+ class FieldSet
10
+ # Create a new instance for the given class that inherits from FlexColumnContentsBase.
11
+ def initialize(flex_column_class)
12
+ @flex_column_class = flex_column_class
13
+ @fields = { }
14
+ @fields_by_json_storage_names = { }
15
+ end
16
+
17
+ # Defines a new field. This is passed through directly by the flex-column contents class -- its semantics are therefore
18
+ # exactly what the client sees. +name+ is the name of the new field, and +args+ receives any additional arguments
19
+ # (type, options, etc.).
20
+ def field(name, *args)
21
+ # Peel off the options
22
+ options = args.pop if args[-1] && args[-1].kind_of?(Hash)
23
+ options ||= { }
24
+
25
+ # Clean up the name
26
+ name = FlexColumns::Definition::FieldDefinition.normalize_name(name)
27
+
28
+ # Create a new field
29
+ field = FlexColumns::Definition::FieldDefinition.new(@flex_column_class, name, args, options)
30
+
31
+ # If we have a duplicate name, that's OK; we intentionally replace the existing field. But if we have a
32
+ # collision in the JSON storage name, and the field names are different, we want to raise an exception,
33
+ # because that means you actually have two _different_ fields with the same JSON storage name.
34
+ same_json_storage_name_field = fields_by_json_storage_names[field.json_storage_name]
35
+ if same_json_storage_name_field && same_json_storage_name_field.field_name != field.field_name
36
+ raise FlexColumns::Errors::ConflictingJsonStorageNameError.new(@flex_column_class.model_class,
37
+ @flex_column_class.column_name, name, same_json_storage_name_field.field_name, field.json_storage_name)
38
+ end
39
+
40
+ fields[name] = field
41
+ fields_by_json_storage_names[field.json_storage_name] = field
42
+ end
43
+
44
+ # Adds all delegated methods to both the +column_dynamic_methods_module+, which should be included into the
45
+ # flex-column contents class, and the +model_dynamic_methods_module+, which should be included into the
46
+ # +model_class+. The +model_class+ itself is also passed here; this is used in the FieldDefinition just to make
47
+ # sure we don't define methods that collide with column names or other method names on the model class itself.
48
+ def add_delegated_methods!(column_dynamic_methods_module, model_dynamic_methods_module, model_class)
49
+ each_field do |field_definition|
50
+ field_definition.add_methods_to_flex_column_class!(column_dynamic_methods_module)
51
+ field_definition.add_methods_to_model_class!(model_dynamic_methods_module, model_class)
52
+ end
53
+ end
54
+
55
+ # Returns the names of all defined fields, in no particular order.
56
+ def all_field_names
57
+ fields.keys
58
+ end
59
+
60
+ # Adds delegated methods, as appropriate for IncludeFlexColumns#include_flex_columns_from, to the given
61
+ # DynamicMethodsModule. +association_name+ is the name of the method on the target class that, when called, will
62
+ # return the associated model object of the class on which this flex column is defined (_i.e._, the association
63
+ # name); +target_class+ is the class into which the DynamicMethodsModule is included, so we can check to make sure
64
+ # we're not clobbering methods that we really shouldn't clobber, and +options+ is any options passed along.
65
+ def include_fields_into(dynamic_methods_module, association_name, target_class, options)
66
+ each_field do |field_definition|
67
+ field_definition.add_methods_to_included_class!(dynamic_methods_module, association_name, target_class, options)
68
+ end
69
+ end
70
+
71
+ # Returns the field with the given name, or +nil+ if there is no such field
72
+ def field_named(field_name)
73
+ fields[FlexColumns::Definition::FieldDefinition.normalize_name(field_name)]
74
+ end
75
+
76
+ # Returns the field with the given JSON storage name, or +nil+ if there is no such field.
77
+ def field_with_json_storage_name(json_storage_name)
78
+ fields_by_json_storage_names[FlexColumns::Definition::FieldDefinition.normalize_name(json_storage_name)]
79
+ end
80
+
81
+ private
82
+ attr_reader :fields, :fields_by_json_storage_names
83
+
84
+ def each_field(&block)
85
+ fields.each { |name, field| block.call(field) }
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,327 @@
1
+ module FlexColumns
2
+ module Definition
3
+ # When you declare a flex column, we actually generate a brand-new Class for that column; instances of that flex
4
+ # column are instances of this new Class. This class acquires functionality from two places: FlexColumnContentsBase,
5
+ # which defines its instance methods, and FlexColumnContentsClass, which defines its class methods. (While
6
+ # FlexColumnContentsBase is an actual Class, FlexColumnContentsClass is a Module that FlexColumnContentsBase
7
+ # +extend+s. Both could be combined, but, simply for readability and maintainability, it was better to make them
8
+ # separate.)
9
+ #
10
+ # This Module therefore defines the methods that are available on a flex-column class -- directly from inside
11
+ # the block passed to +flex_column+, for example.
12
+ module FlexColumnContentsClass
13
+ # By default, how long does the generated JSON have to be before we'll try compressing it?
14
+ DEFAULT_MAX_JSON_LENGTH_BEFORE_COMPRESSION = 200
15
+
16
+ # Given a string from storage in +storage_string+, and an object that responds to ColumnData's +data_source+
17
+ # protocol for describing where data came from, create the appropriate ColumnData object to represent that data.
18
+ # (+storage_string+ can absolutely be +nil+, in case there is no data yet.)
19
+ #
20
+ # This is used by instances of the generated Class to create the ColumnData object that does most of the work of
21
+ # actually serializing/deserializing JSON and storing data for that instance.
22
+ def _flex_columns_create_column_data(storage_string, data_source)
23
+ ensure_setup!
24
+
25
+ storage = case column.type
26
+ when :binary, :text, :json then column.type
27
+ when :string then :text
28
+ else raise "Unknown storage type: #{column.type.inspect}"
29
+ end
30
+
31
+ create_options = {
32
+ :storage_string => storage_string,
33
+ :data_source => data_source,
34
+ :unknown_fields => options[:unknown_fields] || :preserve,
35
+ :length_limit => column.limit,
36
+ :storage => storage,
37
+ :binary_header => true,
38
+ :null => column.null
39
+ }
40
+
41
+ create_options[:binary_header] = false if options.has_key?(:header) && (! options[:header])
42
+
43
+ if (! options.has_key?(:compress))
44
+ create_options[:compress_if_over_length] = DEFAULT_MAX_JSON_LENGTH_BEFORE_COMPRESSION
45
+ elsif options[:compress]
46
+ create_options[:compress_if_over_length] = options[:compress]
47
+ end
48
+
49
+ FlexColumns::Contents::ColumnData.new(field_set, create_options)
50
+ end
51
+
52
+ # This is what gets called when you declare a field inside a flex column.
53
+ def field(name, *args)
54
+ ensure_setup!
55
+ field_set.field(name, *args)
56
+ end
57
+
58
+ # Returns the field with the given name, or nil if there is no such field.
59
+ def field_named(name)
60
+ ensure_setup!
61
+ field_set.field_named(name)
62
+ end
63
+
64
+ # Returns the field that stores its JSON under the given key (+json_storage_name+), or nil if there is no such
65
+ # field.
66
+ def field_with_json_storage_name(json_storage_name)
67
+ ensure_setup!
68
+ field_set.field_with_json_storage_name(json_storage_name)
69
+ end
70
+
71
+ # Is this a flex-column class? Of course it is, by definition. We just use this for argument validation in some
72
+ # places.
73
+ def is_flex_column_class?
74
+ true
75
+ end
76
+
77
+ # Tells this flex column that you want to include its methods into the given +dynamic_methods_module+, which is
78
+ # included in the given +target_class+. (We only use +target_class+ to make sure we don't define methods that
79
+ # are already present on the given +target_class+.) +association_name+ is the name of the association that,
80
+ # from the given +target_class+, will return a model instance that contains this flex column.
81
+ #
82
+ # +options+ specifies options for the inclusion; it can specify +:visibility+ to change whether methods are
83
+ # public or private, +:delegate+ to turn off delegation of anything other than the flex column itself, or
84
+ # +:prefix+ to set a prefix for the delegated method names.
85
+ def include_fields_into(dynamic_methods_module, association_name, target_class, options)
86
+ ensure_setup!
87
+
88
+ cn = column_name
89
+ mn = column_name.to_s
90
+ mn = "#{options[:prefix]}_#{mn}" if options[:prefix]
91
+
92
+ # Make sure we don't overwrite some #method_missing magic that defines a column accessor, or something
93
+ # similar.
94
+ if target_class._flex_columns_safe_to_define_method?(mn)
95
+ dynamic_methods_module.define_method(mn) do
96
+ associated_object = send(association_name) || send("build_#{association_name}")
97
+ associated_object.send(cn)
98
+ end
99
+ dynamic_methods_module.private(mn) if options[:visibility] == :private
100
+ end
101
+
102
+ unless options.has_key?(:delegate) && (! options[:delegate])
103
+ add_custom_methods!(dynamic_methods_module, target_class, options)
104
+ field_set.include_fields_into(dynamic_methods_module, association_name, target_class, options)
105
+ end
106
+ end
107
+
108
+ # Given an instance of the model that this flex column is defined on, return the appropriate flex-column
109
+ # object for that instance. This simply delegates to #_flex_column_object_for on that model instance.
110
+ def object_for(model_instance)
111
+ ensure_setup!
112
+ model_instance._flex_column_object_for(column.name)
113
+ end
114
+
115
+ # When we delegate methods, what should we prefix them with (if anything)?
116
+ def delegation_prefix
117
+ ensure_setup!
118
+ options[:prefix].try(:to_s)
119
+ end
120
+
121
+ # When we delegate methods, should we delegate them at all (returns +nil+), publicly (+:public+), or
122
+ # privately (+:private+)?
123
+ def delegation_type
124
+ ensure_setup!
125
+ return :public if (! options.has_key?(:delegate))
126
+
127
+ case options[:delegate]
128
+ when nil, false then nil
129
+ when true, :public then :public
130
+ when :private then :private
131
+ # OK to raise an untyped error here -- we should've caught this in #validate_options.
132
+ else raise "Impossible value for :delegate: #{options[:delegate]}"
133
+ end
134
+ end
135
+
136
+ # What's the name of the actual model column this flex-column uses? Returns a Symbol.
137
+ def column_name
138
+ ensure_setup!
139
+ column.name.to_sym
140
+ end
141
+
142
+ # What are the names of all fields defined on this flex column?
143
+ def all_field_names
144
+ field_set.all_field_names
145
+ end
146
+
147
+ # Given a model instance, do we need to save this column? This is true under one of two cases:
148
+ #
149
+ # * Someone has changed ("touched") at least one of the flex-column fields (or called #touch! on it);
150
+ # * The column is non-NULL, and there's no data in it right now. (Saving it will populate it with an empty string.)
151
+ def requires_serialization_on_save?(model)
152
+ maybe_flex_object = model._flex_column_object_for(column_name, false)
153
+ out = true if maybe_flex_object && maybe_flex_object.touched?
154
+ out ||= true if ((! column.null) && (! model[column_name]))
155
+ out
156
+ end
157
+
158
+ # Are fields in this flex column private by default?
159
+ def fields_are_private_by_default?
160
+ ensure_setup!
161
+ options[:visibility] == :private
162
+ end
163
+
164
+ # This is, for all intents and purposes, the initializer (constructor) for this module. But because it's a module
165
+ # (and has to be), this can't actually be #initialize. (Another way of saying it: objects have initializers;
166
+ # classes do not.)
167
+ #
168
+ # You must call this method exactly once for each class that extends this module, and before you call any other
169
+ # method.
170
+ #
171
+ # +model_class+ must be the ActiveRecord model class for this flex column. +column_name+ must be the name of
172
+ # the column that you're using as a flex column. +options+ can contain any of:
173
+ #
174
+ # [:visibility] If +:private+, then all field accessors (readers and writers) will be private by default, unless
175
+ # overridden in their field declaration.
176
+ # [:delegate] If specified and +false+ or +nil+, then field accessors and custom methods defined in this class
177
+ # will not be automatically delegated to from the +model_class+.
178
+ # [:prefix] If specified (as a Symbol or String), then field accessors and custom methods delegated from the
179
+ # +model_class+ will be prefixed with this string, followed by an underscore.
180
+ # [:unknown_fields] If specified and +:delete+, then, if the JSON string for an instance contains fields that
181
+ # aren't declared in this class, they will be removed from the JSON when saving back out to
182
+ # the database. This is dangerous, but powerful, if you want to keep your data clean.
183
+ # [:compress] If specified and +false+, this column will never be compressed. If specified as a number, then,
184
+ # when serializing data, we'll try to compress it if the uncompressed version is at least that many
185
+ # bytes long; we'll store the compressed version if it's no more than 95% as long as the uncompressed
186
+ # version. The default is 200. Also note that compression requires a binary storage type for the
187
+ # underlying column.
188
+ # [:header] If the underlying column is of binary storage type, then, by default, we use a tiny header to indicate
189
+ # what kind of data is stored there and whether it's compressed or not. If this is set to +false+,
190
+ # disables this header (and therefore also disables compression).
191
+ def setup!(model_class, column_name, options = { }, &block)
192
+ raise ArgumentError, "You can't call setup! twice!" if @model_class || @column
193
+
194
+ # Make really sure we're being declared in the right kind of class.
195
+ unless model_class.kind_of?(Class) && model_class.respond_to?(:has_any_flex_columns?) && model_class.has_any_flex_columns?
196
+ raise ArgumentError, "Invalid model class: #{model_class.inspect}"
197
+ end
198
+
199
+ raise ArgumentError, "Invalid column name: #{column_name.inspect}" unless column_name.kind_of?(Symbol)
200
+
201
+ column = model_class.columns.detect { |c| c.name.to_s == column_name.to_s }
202
+ unless column
203
+ raise FlexColumns::Errors::NoSuchColumnError, %{You're trying to define a flex column #{column_name.inspect}, but
204
+ the model you're defining it on, #{model_class.name}, seems to have no column
205
+ named that.
206
+
207
+ It has columns named: #{model_class.columns.map(&:name).sort_by(&:to_s).join(", ")}.}
208
+ end
209
+
210
+ unless column.type == :binary || column.text? || column.sql_type == "json" # for PostgreSQL >= 9.2, which has a native JSON data type
211
+ raise FlexColumns::Errors::InvalidColumnTypeError, %{You're trying to define a flex column #{column_name.inspect}, but
212
+ that column (on model #{model_class.name}) isn't of a type that accepts text.
213
+ That column is of type: #{column.type.inspect}.}
214
+ end
215
+
216
+ validate_options(options)
217
+
218
+ @model_class = model_class
219
+ @column = column
220
+ @options = options
221
+ @field_set = FlexColumns::Definition::FieldSet.new(self)
222
+
223
+ class_name = "#{column_name.to_s.camelize}FlexContents".to_sym
224
+ @model_class.send(:remove_const, class_name) if @model_class.const_defined?(class_name)
225
+ @model_class.const_set(class_name, self)
226
+
227
+ # Keep track of which methods were present before and after calling the block that was passed in; this is how
228
+ # we know which methods were declared custom, so we know which ones to add delegation for.
229
+ methods_before = instance_methods
230
+ block_result = class_eval(&block) if block
231
+ @custom_methods = (instance_methods - methods_before).map(&:to_sym)
232
+ block_result
233
+ end
234
+
235
+ # Tells this class to re-publish all its methods to the DynamicMethodsModule it uses internally, and to the
236
+ # model class it's a part of.
237
+ #
238
+ # Because Rails in development mode is constantly redefining classes, and we don't want old cruft that you've
239
+ # removed to hang around, we use a "remove absolutely all methods, then add back only what's defined now"
240
+ # strategy.
241
+ def sync_methods!
242
+ @dynamic_methods_module ||= FlexColumns::Util::DynamicMethodsModule.new(self, :FlexFieldsDynamicMethods)
243
+ @dynamic_methods_module.remove_all_methods!
244
+
245
+ field_set.add_delegated_methods!(@dynamic_methods_module, model_class._flex_column_dynamic_methods_module, model_class)
246
+
247
+ if delegation_type
248
+ add_custom_methods!(model_class._flex_column_dynamic_methods_module, model_class,
249
+ :visibility => (delegation_type == :private ? :private : :public))
250
+ end
251
+ end
252
+
253
+ attr_reader :model_class, :column
254
+
255
+ private
256
+ attr_reader :fields, :options, :custom_methods, :field_set
257
+
258
+ # Takes all custom methods defined on this flex-column class, and adds delegates to them to the given
259
+ # +dynamic_methods_module+. +target_class+ is checked before each one to make sure we don't have a conflict.
260
+ def add_custom_methods!(dynamic_methods_module, target_class, options = { })
261
+ cn = column_name
262
+
263
+ custom_methods.each do |custom_method|
264
+ mn = custom_method.to_s
265
+ mn = "#{options[:prefix]}_#{mn}" if options[:prefix]
266
+
267
+ if target_class._flex_columns_safe_to_define_method?(mn)
268
+ dynamic_methods_module.define_method(mn) do |*args, &block|
269
+ flex_object = _flex_column_object_for(cn)
270
+ flex_object.send(custom_method, *args, &block)
271
+ end
272
+
273
+ dynamic_methods_module.private(custom_method) if options[:visibility] == :private
274
+ end
275
+ end
276
+ end
277
+
278
+ # Check all of our options to make sure they're correct. This is pretty defensive programming, but it is SO
279
+ # much nicer to get an error on startup if you've specified anything incorrectly than way on down the line,
280
+ # possibly in production, when it really matters.
281
+ def validate_options(options)
282
+ unless options.kind_of?(Hash)
283
+ raise ArgumentError, "You must pass a Hash, not: #{options.inspect}"
284
+ end
285
+
286
+ options.assert_valid_keys(:visibility, :prefix, :delegate, :unknown_fields, :compress, :header)
287
+
288
+ unless [ nil, :private, :public ].include?(options[:visibility])
289
+ raise ArgumentError, "Invalid value for :visibility: #{options[:visibility.inspect]}"
290
+ end
291
+
292
+ unless [ :delete, :preserve, nil ].include?(options[:unknown_fields])
293
+ raise ArgumentError, "Invalid value for :unknown_fields: #{options[:unknown_fields].inspect}"
294
+ end
295
+
296
+ unless [ true, false, nil ].include?(options[:compress]) || options[:compress].kind_of?(Integer)
297
+ raise ArgumentError, "Invalid value for :compress: #{options[:compress].inspect}"
298
+ end
299
+
300
+ unless [ true, false, nil ].include?(options[:header])
301
+ raise ArgumentError, "Invalid value for :header: #{options[:header].inspect}"
302
+ end
303
+
304
+ case options[:prefix]
305
+ when nil then nil
306
+ when String, Symbol then nil
307
+ else raise ArgumentError, "Invalid value for :prefix: #{options[:prefix].inspect}"
308
+ end
309
+
310
+ unless [ nil, true, false, :private, :public ].include?(options[:delegate])
311
+ raise ArgumentError, "Invalid value for :delegate: #{options[:delegate].inspect}"
312
+ end
313
+
314
+ if options[:visibility] == :private && options[:delegate] == :public
315
+ raise ArgumentError, "You can't have public delegation if methods in the flex column are private; this makes no sense, as methods in the model class would have *greater* visibility than methods on the flex column itself"
316
+ end
317
+ end
318
+
319
+ # Make sure someone has called setup! previously.
320
+ def ensure_setup!
321
+ unless @model_class
322
+ raise "You must call #setup! on this class before calling this method."
323
+ end
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,236 @@
1
+ require 'flex_columns/util/string_utils'
2
+
3
+ module FlexColumns
4
+ # This module contains definitions for all errors thrown by +flex_columns+. One of the goals of +flex_columns+ is to,
5
+ # when an error occurs, raise an exception that has a great amount of detail about what happened -- in general, it
6
+ # should be enough to know exactly where any invalid or problematic data came from, such as the row in the database
7
+ # containing bad data, invalidly-encoded characters, or similar.
8
+ module Errors
9
+ # FlexColumns::Errors::Base: all +flex_columns+ errors inherit from this class.
10
+ class Base < StandardError; end
11
+
12
+
13
+ # FlexColumns::Errors::FieldError: all errors having to do with field definition inherit from this class.
14
+ class FieldError < Base; end
15
+
16
+ # Raised when you try to read or write data for a field that isn't defined.
17
+ class NoSuchFieldError < FieldError
18
+ attr_reader :data_source, :field_name, :all_field_names
19
+
20
+ def initialize(data_source, field_name, all_field_names)
21
+ @data_source = data_source
22
+ @field_name = field_name
23
+ @all_field_names = all_field_names
24
+
25
+ super(%{You tried to set field #{field_name.inspect} of #{data_source.describe_flex_column_data_source}.
26
+ However, there is no such field defined on that flex column; the defined fields are:
27
+
28
+ #{all_field_names.join(", ")}})
29
+ end
30
+ end
31
+
32
+ # Raised when you try to define a field with the same JSON storage name, but a different field name, as a
33
+ # previously-defined field.
34
+ class ConflictingJsonStorageNameError < FieldError
35
+ attr_reader :model_class, :column_name, :new_field_name, :existing_field_name, :json_storage_name
36
+
37
+ def initialize(model_class, column_name, new_field_name, existing_field_name, json_storage_name)
38
+ @model_class = model_class
39
+ @column_name = column_name
40
+ @new_field_name = new_field_name
41
+ @existing_field_name = existing_field_name
42
+ @json_storage_name = json_storage_name
43
+
44
+ super(%{On class #{model_class.name}, flex column #{column_name.inspect}, you're trying to define a field,
45
+ #{new_field_name.inspect}, that has a JSON storage name of #{json_storage_name.inspect},
46
+ but there's already another field, #{existing_field_name.inspect}, that uses that same JSON storage name.
47
+
48
+ These fields would conflict in the JSON store, and thus this is not allowed.})
49
+ end
50
+ end
51
+
52
+
53
+ # FlexColumns::Errors::DefinitionError: all errors having to do with definition of a flex column itself (not fields,
54
+ # but the whole column) inherit from this class.
55
+ class DefinitionError < Base; end
56
+
57
+ # Raised when you try to define a flex_column for a column that doesn't exist in the database (at least according
58
+ # to the model class).
59
+ class NoSuchColumnError < DefinitionError; end
60
+
61
+ # Raised when you try to define a flex_column for a column that isn't of a valid type for that -- for example, an
62
+ # integer or a boolean column.
63
+ class InvalidColumnTypeError < DefinitionError; end
64
+
65
+
66
+ # FlexColumns::Errors::DataError: all errors having to do with the data present in a flex column in the database
67
+ # inherit from this class.
68
+ class DataError < Base; end
69
+
70
+ # Raised when you try to store enough data in a flex column that the generated JSON is too long to fit into the
71
+ # column.
72
+ class JsonTooLongError < DataError
73
+ attr_reader :data_source, :limit, :json_string
74
+
75
+ def initialize(data_source, limit, json_string)
76
+ @data_source = data_source
77
+ @limit = limit
78
+ @json_string = json_string
79
+
80
+ super(%{When trying to serialize JSON for #{data_source.describe_flex_column_data_source},
81
+ the JSON produced was too long to fit in the database.
82
+ We produced #{json_string.length} characters of JSON, but the
83
+ database's limit for that column is #{limit} characters.
84
+
85
+ The JSON we produced was:
86
+
87
+ #{FlexColumns::Util::StringUtils.abbreviated_string(json_string)}})
88
+ end
89
+ end
90
+
91
+ # FlexColumns::Errors::InvalidDataInDatabaseError: all errors raised because something is wrong with the data
92
+ # already stored in the database for a particular row and column.
93
+ class InvalidDataInDatabaseError < DataError
94
+ attr_reader :data_source, :raw_string, :additional_message
95
+
96
+ def initialize(data_source, raw_string, additional_message = nil)
97
+ @data_source = data_source
98
+ @raw_string = raw_string
99
+ @additional_message = additional_message
100
+
101
+ super(create_message)
102
+ end
103
+
104
+ private
105
+ def create_message
106
+ out = %{When parsing the JSON in #{data_source.describe_flex_column_data_source}, which is:
107
+
108
+ #{FlexColumns::Util::StringUtils.abbreviated_string(raw_string)}
109
+
110
+ }
111
+ out += additional_message if additional_message
112
+ out
113
+ end
114
+ end
115
+
116
+ # Raised when the data in the database appears to be GZip'ed, but we can't decompress that data.
117
+ class InvalidCompressedDataInDatabaseError < InvalidDataInDatabaseError
118
+ attr_reader :source_exception
119
+
120
+ def initialize(data_source, raw_string, source_exception)
121
+ @source_exception = source_exception
122
+ super(data_source, raw_string)
123
+ end
124
+
125
+ private
126
+ def create_message
127
+ super + %{, we got an exception when trying to decompress the data:
128
+
129
+ #{source_exception} (#{source_exception.class.name})}
130
+ end
131
+ end
132
+
133
+ # Raised when the data in the database appears to have a +flex_columns+ header on it, but the version number is
134
+ # not something we support.
135
+ class InvalidFlexColumnsVersionNumberInDatabaseError < InvalidDataInDatabaseError
136
+ attr_reader :version_number_in_database, :max_version_number_supported
137
+
138
+ def initialize(data_source, raw_string, version_number_in_database, max_version_number_supported)
139
+ @version_number_in_database = version_number_in_database
140
+ @max_version_number_supported = max_version_number_supported
141
+ super(data_source, raw_string)
142
+ end
143
+
144
+ private
145
+ def create_message
146
+ super + %{, we got a version number in the database, #{version_number_in_database}, which is greater than our maximum supported version number, #{max_version_number_supported}.}
147
+ end
148
+ end
149
+
150
+ # Raised when the data in the database is not parseable as JSON (via JSON.parse). Note that we take special care to
151
+ # exclude characters from the message that aren't in a valid encoding, as this is one of the major causes of JSON
152
+ # parsing failures in some situations...and we really don't want to create an exception that itself has a message
153
+ # with encoding problems.
154
+ class UnparseableJsonInDatabaseError < InvalidDataInDatabaseError
155
+ attr_reader :source_exception
156
+
157
+ def initialize(data_source, raw_string, source_exception)
158
+ @source_exception = source_exception
159
+ super(data_source, raw_string)
160
+ end
161
+
162
+ private
163
+ def create_message
164
+ source_message = source_exception.message
165
+
166
+ if source_message.respond_to?(:force_encoding)
167
+ source_message.force_encoding("UTF-8")
168
+ source_message = source_message.chars.select { |c| c.valid_encoding? }.join
169
+ end
170
+
171
+ super + %{, we got an exception: #{source_message} (#{source_exception.class.name})}
172
+ end
173
+ end
174
+
175
+ # Raised when the string stored in the database is not correctly encoded. We check for this situation before we
176
+ # even try to parse the string as JSON, because the kind of errors you get from this problem are otherwise
177
+ # maddeningly difficult to deal with -- partly because the exceptions themselves often end up with bad encoding.
178
+ #
179
+ # This class does a lot of work to filter out invalid characters, show them just as hex, and show you where into the
180
+ # string they occur. This is, again, so we don't make things worse by raising an exception with invalid characters
181
+ # in its message, and so that you can figure out where the problems are.
182
+ class IncorrectlyEncodedStringInDatabaseError < InvalidDataInDatabaseError
183
+ attr_reader :invalid_chars_as_array, :raw_data_as_array, :first_bad_position
184
+
185
+ def initialize(data_source, raw_string)
186
+ @raw_data_as_array = raw_string.chars.to_a
187
+ @valid_chars_as_array = [ ]
188
+ @invalid_chars_as_array = [ ]
189
+ @raw_data_as_array.each_with_index do |c, i|
190
+ if (! c.valid_encoding?)
191
+ @invalid_chars_as_array << c
192
+ @first_bad_position ||= i
193
+ else
194
+ @valid_chars_as_array << c
195
+ end
196
+ end
197
+ @first_bad_position ||= :unknown
198
+
199
+ super(data_source, @valid_chars_as_array.join)
200
+ end
201
+
202
+ private
203
+ def create_message
204
+ extra = %{\n\nThere are #{invalid_chars_as_array.length} invalid characters out of #{raw_data_as_array.length} total characters.
205
+ (The string above showing the original JSON omits them, so that it's actually a valid String.)
206
+ The first bad character occurs at position #{first_bad_position}.
207
+
208
+ Some of the invalid chars are (in hex):
209
+
210
+ }
211
+
212
+ extra += invalid_chars_as_array[0..19].map { |c| c.unpack("H*") }.join(" ")
213
+
214
+ super + extra
215
+ end
216
+ end
217
+
218
+ # Raised when the JSON in the database is invalid -- not because it's not actually JSON, but because it doesn't
219
+ # represent a Hash.
220
+ class InvalidJsonInDatabaseError < InvalidDataInDatabaseError
221
+ attr_reader :returned_data
222
+
223
+ def initialize(data_source, raw_string, returned_data)
224
+ @returned_data = returned_data
225
+ super(data_source, raw_string)
226
+ end
227
+
228
+ private
229
+ def create_message
230
+ super + %{, the JSON returned wasn't a Hash, but rather #{returned_data.class.name}:
231
+
232
+ #{FlexColumns::Util::StringUtils.abbreviated_string(returned_data.inspect)}}
233
+ end
234
+ end
235
+ end
236
+ end