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.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.travis.yml +38 -0
- data/Gemfile +17 -0
- data/LICENSE.txt +22 -0
- data/README.md +124 -0
- data/Rakefile +6 -0
- data/flex_columns.gemspec +72 -0
- data/lib/flex_columns.rb +15 -0
- data/lib/flex_columns/active_record/base.rb +57 -0
- data/lib/flex_columns/contents/column_data.rb +376 -0
- data/lib/flex_columns/contents/flex_column_contents_base.rb +188 -0
- data/lib/flex_columns/definition/field_definition.rb +316 -0
- data/lib/flex_columns/definition/field_set.rb +89 -0
- data/lib/flex_columns/definition/flex_column_contents_class.rb +327 -0
- data/lib/flex_columns/errors.rb +236 -0
- data/lib/flex_columns/has_flex_columns.rb +187 -0
- data/lib/flex_columns/including/include_flex_columns.rb +179 -0
- data/lib/flex_columns/util/dynamic_methods_module.rb +86 -0
- data/lib/flex_columns/util/string_utils.rb +31 -0
- data/lib/flex_columns/version.rb +4 -0
- data/spec/flex_columns/helpers/database_helper.rb +174 -0
- data/spec/flex_columns/helpers/exception_helpers.rb +20 -0
- data/spec/flex_columns/helpers/system_helpers.rb +47 -0
- data/spec/flex_columns/system/basic_system_spec.rb +245 -0
- data/spec/flex_columns/system/bulk_system_spec.rb +153 -0
- data/spec/flex_columns/system/compression_system_spec.rb +218 -0
- data/spec/flex_columns/system/custom_methods_system_spec.rb +120 -0
- data/spec/flex_columns/system/delegation_system_spec.rb +175 -0
- data/spec/flex_columns/system/dynamism_system_spec.rb +158 -0
- data/spec/flex_columns/system/error_handling_system_spec.rb +117 -0
- data/spec/flex_columns/system/including_system_spec.rb +285 -0
- data/spec/flex_columns/system/json_alias_system_spec.rb +171 -0
- data/spec/flex_columns/system/performance_system_spec.rb +218 -0
- data/spec/flex_columns/system/postgres_json_column_type_system_spec.rb +85 -0
- data/spec/flex_columns/system/types_system_spec.rb +93 -0
- data/spec/flex_columns/system/unknown_fields_system_spec.rb +126 -0
- data/spec/flex_columns/system/validations_system_spec.rb +111 -0
- data/spec/flex_columns/unit/active_record/base_spec.rb +32 -0
- data/spec/flex_columns/unit/contents/column_data_spec.rb +520 -0
- data/spec/flex_columns/unit/contents/flex_column_contents_base_spec.rb +253 -0
- data/spec/flex_columns/unit/definition/field_definition_spec.rb +617 -0
- data/spec/flex_columns/unit/definition/field_set_spec.rb +142 -0
- data/spec/flex_columns/unit/definition/flex_column_contents_class_spec.rb +733 -0
- data/spec/flex_columns/unit/errors_spec.rb +297 -0
- data/spec/flex_columns/unit/has_flex_columns_spec.rb +365 -0
- data/spec/flex_columns/unit/including/include_flex_columns_spec.rb +144 -0
- data/spec/flex_columns/unit/util/dynamic_methods_module_spec.rb +105 -0
- data/spec/flex_columns/unit/util/string_utils_spec.rb +23 -0
- 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
|