google_data_source 0.7.6
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.
- data/.document +5 -0
- data/.gitignore +7 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +25 -0
- data/Rakefile +31 -0
- data/google_data_source.gemspec +32 -0
- data/lib/assets/images/google_data_source/chart_bar_add.png +0 -0
- data/lib/assets/images/google_data_source/chart_bar_delete.png +0 -0
- data/lib/assets/images/google_data_source/loader.gif +0 -0
- data/lib/assets/javascripts/google_data_source/data_source_init.js +3 -0
- data/lib/assets/javascripts/google_data_source/extended_data_table.js +76 -0
- data/lib/assets/javascripts/google_data_source/filter_form.js +180 -0
- data/lib/assets/javascripts/google_data_source/google_visualization/combo_table.js.erb +113 -0
- data/lib/assets/javascripts/google_data_source/google_visualization/table.js +116 -0
- data/lib/assets/javascripts/google_data_source/google_visualization/timeline.js +13 -0
- data/lib/assets/javascripts/google_data_source/google_visualization/visualization.js.erb +141 -0
- data/lib/assets/javascripts/google_data_source/index.js +7 -0
- data/lib/dummy_engine.rb +5 -0
- data/lib/google_data_source.rb +33 -0
- data/lib/google_data_source/base.rb +281 -0
- data/lib/google_data_source/column.rb +31 -0
- data/lib/google_data_source/csv_data.rb +23 -0
- data/lib/google_data_source/data_date.rb +17 -0
- data/lib/google_data_source/data_date_time.rb +17 -0
- data/lib/google_data_source/helper.rb +69 -0
- data/lib/google_data_source/html_data.rb +6 -0
- data/lib/google_data_source/invalid_data.rb +14 -0
- data/lib/google_data_source/json_data.rb +78 -0
- data/lib/google_data_source/railtie.rb +36 -0
- data/lib/google_data_source/sql/models.rb +266 -0
- data/lib/google_data_source/sql/parser.rb +239 -0
- data/lib/google_data_source/sql_parser.rb +82 -0
- data/lib/google_data_source/template_handler.rb +31 -0
- data/lib/google_data_source/test_helper.rb +26 -0
- data/lib/google_data_source/version.rb +3 -0
- data/lib/google_data_source/xml_data.rb +25 -0
- data/lib/locale/de.yml +5 -0
- data/lib/reporting/action_controller_extension.rb +19 -0
- data/lib/reporting/grouped_set.rb +58 -0
- data/lib/reporting/helper.rb +110 -0
- data/lib/reporting/reporting.rb +352 -0
- data/lib/reporting/reporting_adapter.rb +27 -0
- data/lib/reporting/reporting_entry.rb +147 -0
- data/lib/reporting/sql_reporting.rb +220 -0
- data/test/lib/empty_reporting.rb +2 -0
- data/test/lib/test_reporting.rb +33 -0
- data/test/lib/test_reporting_b.rb +9 -0
- data/test/lib/test_reporting_c.rb +3 -0
- data/test/locales/en.models.yml +6 -0
- data/test/locales/en.reportings.yml +5 -0
- data/test/rails/reporting_renderer_test.rb +47 -0
- data/test/test_helper.rb +50 -0
- data/test/units/base_test.rb +340 -0
- data/test/units/csv_data_test.rb +36 -0
- data/test/units/grouped_set_test.rb +60 -0
- data/test/units/json_data_test.rb +68 -0
- data/test/units/reporting_adapter_test.rb +20 -0
- data/test/units/reporting_entry_test.rb +149 -0
- data/test/units/reporting_test.rb +374 -0
- data/test/units/sql_parser_test.rb +111 -0
- data/test/units/sql_reporting_test.rb +307 -0
- data/test/units/xml_data_test.rb +32 -0
- metadata +286 -0
@@ -0,0 +1,110 @@
|
|
1
|
+
module GoogleDataSource
|
2
|
+
module Reporting
|
3
|
+
module Helper
|
4
|
+
# Shows a reporting consisting of a visualization and a form for configuration
|
5
|
+
# +type+ can be either
|
6
|
+
# * 'Table'
|
7
|
+
# * 'TimeLine'
|
8
|
+
# +reporting+ is the +Reporting+ object to be displayed
|
9
|
+
# Have a look at +google_visualization+ helper for +url+ and +options+ parameter
|
10
|
+
def google_reporting(type, reporting, url = nil, options = {})
|
11
|
+
# no form
|
12
|
+
return google_visualization(type, url, options) unless options.delete(:form)
|
13
|
+
|
14
|
+
# form
|
15
|
+
# default options
|
16
|
+
options = {
|
17
|
+
:form => reporting_form_id(reporting),
|
18
|
+
:autosubmit => true,
|
19
|
+
:form_position => :bottom
|
20
|
+
}.update(options)
|
21
|
+
|
22
|
+
visualization_html = google_visualization(type, url, options)
|
23
|
+
|
24
|
+
form_html = content_tag("form", :id => reporting_form_id(reporting), :class => 'formtastic') do
|
25
|
+
render options[:form_partial] || reporting_form_partial(reporting)
|
26
|
+
end
|
27
|
+
|
28
|
+
options[:form_position] == :bottom ? visualization_html << form_html : form_html << visualization_html
|
29
|
+
end
|
30
|
+
|
31
|
+
# Shows a timeline reporting
|
32
|
+
def google_reporting_timeline(reporting, url = nil, options = {})
|
33
|
+
google_reporting('TimeLine', reporting, url, options)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Shows a table reporting
|
37
|
+
def google_reporting_table(reporting, url = nil, options = {})
|
38
|
+
google_reporting('Table', reporting, url, options)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Shows a combo table reporting
|
42
|
+
def google_reporting_combo_table(reporting, url = nil, options = {})
|
43
|
+
google_reporting('ComboTable', reporting, url, options)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns callback JS code that sends the rendered form partial
|
47
|
+
# So validation errors can be displayed
|
48
|
+
def form_render_callback(reporting, options = {})
|
49
|
+
out = "$('##{reporting_form_id(reporting)}').html(#{render(options[:form_partial] || reporting_form_partial(reporting), :reporting => reporting).to_json});"
|
50
|
+
out << '$(document).trigger("formRendered");'
|
51
|
+
out
|
52
|
+
end
|
53
|
+
|
54
|
+
# Shows a select tag for grouping selection on a given reporting
|
55
|
+
# TODO more docu
|
56
|
+
# TODO really take namespace from classname?
|
57
|
+
def reporting_group_by_select(reporting, select_options, i = 1, options = {})
|
58
|
+
select_options = reporting_options_for_select(reporting, select_options, options)
|
59
|
+
|
60
|
+
tag_name = "#{reporting.class.name.underscore}[groupby(#{i}i)]"
|
61
|
+
current_option = (reporting.group_by.size < i) ? nil : reporting.group_by[i-1]
|
62
|
+
option_tags = options_for_select(select_options, current_option)
|
63
|
+
select_tag(tag_name, option_tags, options)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Shows a Multiselect box for the columns to 'select'
|
67
|
+
def reporting_select_select(reporting, select_options, options = {})
|
68
|
+
select_options = reporting_options_for_select(reporting, select_options, options)
|
69
|
+
|
70
|
+
tag_name = "#{reporting.class.name.underscore}[select]"
|
71
|
+
option_tags = options_for_select(select_options, reporting.select)
|
72
|
+
select_tag(tag_name, option_tags, :multiple => true)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Adds labels to the select options when columns are passed in
|
76
|
+
def reporting_options_for_select(reporting, select_options, options = {})
|
77
|
+
if (select_options.is_a?(Array))
|
78
|
+
select_options = select_options.collect { |column| [reporting.column_label(column), column] }
|
79
|
+
select_options.unshift('') if options.delete(:include_blank)
|
80
|
+
end
|
81
|
+
select_options
|
82
|
+
end
|
83
|
+
|
84
|
+
# Registers form subit hooks
|
85
|
+
# This way the standard form serialization can be overwritten
|
86
|
+
def reporting_form_hooks(reporting)
|
87
|
+
hooks = OpenStruct.new
|
88
|
+
yield(hooks)
|
89
|
+
|
90
|
+
json = []
|
91
|
+
%w(select).each do |hook|
|
92
|
+
next if hooks.send(hook).nil?
|
93
|
+
json << "#{hook}: function(){#{hooks.send hook}}"
|
94
|
+
end
|
95
|
+
js = "DataSource.FilterForm.setHooks(#{reporting_form_id(reporting).to_json}, {#{json.join(', ')}});"
|
96
|
+
javascript_tag(js)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Returns the standard DOM id for reporting forms
|
100
|
+
def reporting_form_id(reporting)
|
101
|
+
"#{reporting.id}_form"
|
102
|
+
end
|
103
|
+
|
104
|
+
# Returns the standard partial for reporting forms
|
105
|
+
def reporting_form_partial(reporting)
|
106
|
+
"#{reporting.id}_form.html"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,352 @@
|
|
1
|
+
class CircularDependencyException < Exception; end
|
2
|
+
|
3
|
+
# Super class for reportings to be rendered with Google visualizations
|
4
|
+
# Encapsulates the aggregation of the reporting data based on a configuration.
|
5
|
+
#
|
6
|
+
# The configuration is implemented by inherting from +ActiveRecord+ and switching off
|
7
|
+
# the database stuff. Thus the regular +ActiveRecord+ validators and parameter assignments
|
8
|
+
# including type casting cab be used
|
9
|
+
#
|
10
|
+
# The ActiveRecord extension is copied from the ActiveForm plugin (http://github.com/remvee/active_form)
|
11
|
+
class Reporting < ActiveRecord::Base
|
12
|
+
attr_accessor :query, :group_by, :select, :order_by, :limit, :offset
|
13
|
+
attr_reader :virtual_columns #, :required_columns
|
14
|
+
attr_writer :id
|
15
|
+
|
16
|
+
class_attribute :datasource_filters,
|
17
|
+
:datasource_columns,
|
18
|
+
:datasource_defaults,
|
19
|
+
{:instance_reader => false, :instance_writer => false}
|
20
|
+
|
21
|
+
def initialize(*args)
|
22
|
+
@required_columns = []
|
23
|
+
@virtual_columns = {}
|
24
|
+
super(*args)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Returns an instance of our own connection adapter
|
28
|
+
#
|
29
|
+
def self.connection
|
30
|
+
@adapter ||= ActiveRecord::ConnectionAdapters::ReportingAdapter.new
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns an ID which is used by frontend components to generate unique dom ids
|
34
|
+
# Defaults to the underscoreized classname
|
35
|
+
def id
|
36
|
+
@id || self.class.name.underscore.split('/').last #gsub('/', '_')
|
37
|
+
end
|
38
|
+
|
39
|
+
# helper method used by required_columns
|
40
|
+
# Returns all columns required by a certain column resolving the dependencies recursively
|
41
|
+
def required_columns_for(column, start = nil)
|
42
|
+
return [] unless self.datasource_columns.has_key?(column)
|
43
|
+
raise CircularDependencyException.new("Column #{start} has a circular dependency") if column.to_sym == start
|
44
|
+
columns = [ self.datasource_columns[column][:requires] ].flatten.compact.collect(&:to_s)
|
45
|
+
columns.collect { |c| [c, required_columns_for(c, start || column.to_sym)] }.flatten
|
46
|
+
end
|
47
|
+
|
48
|
+
# Returns the columns that have to be selected
|
49
|
+
def required_columns
|
50
|
+
(select + group_by + @required_columns).inject([]) do |columns, column|
|
51
|
+
columns << required_columns_for(column)
|
52
|
+
columns << column
|
53
|
+
end.flatten.map(&:to_s).uniq
|
54
|
+
end
|
55
|
+
|
56
|
+
# Adds required columns
|
57
|
+
def add_required_columns(*columns)
|
58
|
+
@required_columns = (@required_columns + columns.flatten.collect(&:to_s)).uniq
|
59
|
+
end
|
60
|
+
|
61
|
+
# 'Abstract' method that has to be overridden by subclasses
|
62
|
+
# Returns an array of rows written to #rows and accessible in DataSource compatible format in #rows_as_datasource
|
63
|
+
#
|
64
|
+
def aggregate
|
65
|
+
[]
|
66
|
+
end
|
67
|
+
|
68
|
+
# Returns the data rows
|
69
|
+
# Calls the aggregate method first if rows do not exist
|
70
|
+
#
|
71
|
+
def data(options = {})
|
72
|
+
add_required_columns(options[:required_columns])
|
73
|
+
@rows ||= aggregate
|
74
|
+
end
|
75
|
+
|
76
|
+
# Lazy getter for the columns object
|
77
|
+
def columns
|
78
|
+
select.inject([]) do |columns, column|
|
79
|
+
columns << {
|
80
|
+
:type => all_columns[column][:type]
|
81
|
+
}.merge({
|
82
|
+
:id => column.to_s,
|
83
|
+
:label => column_label(column)
|
84
|
+
}) if all_columns.key?(column)
|
85
|
+
|
86
|
+
columns
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Retrieves the I18n translation of the column label
|
91
|
+
def column_label(column, default = nil)
|
92
|
+
return '' if column.blank?
|
93
|
+
defaults = ['reportings.{{model}}.{{column}}', 'models.attributes.{{model}}.{{column}}'].collect do |scope|
|
94
|
+
scope.gsub!('{{model}}', self.class.name.underscore.gsub('/', '.'))
|
95
|
+
scope.gsub('{{column}}', column.to_s)
|
96
|
+
end.collect(&:to_sym)
|
97
|
+
defaults << column.to_s.humanize
|
98
|
+
I18n.t(defaults.shift, :default => defaults)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Returns the select columns as array
|
102
|
+
def select
|
103
|
+
(@select ||= (defaults[:select] || [])).collect { |c| c == '*' ? all_columns.keys : c }.flatten
|
104
|
+
end
|
105
|
+
|
106
|
+
# Returns the grouping columns as array
|
107
|
+
def group_by
|
108
|
+
@group_by ||= (defaults[:group_by] || [])
|
109
|
+
end
|
110
|
+
|
111
|
+
# add a virtual column
|
112
|
+
def add_virtual_column(name, type = :string)
|
113
|
+
virtual_columns[name.to_s] = {
|
114
|
+
:type => type
|
115
|
+
}
|
116
|
+
end
|
117
|
+
|
118
|
+
# Returns a list of all columns (real and virtual)
|
119
|
+
def all_columns
|
120
|
+
datasource_columns.merge(virtual_columns)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Returns an array of columns which are allowed for grouping
|
124
|
+
#
|
125
|
+
def groupable_columns
|
126
|
+
datasource_columns.collect do |key, options|
|
127
|
+
key.to_sym if options[:grouping]
|
128
|
+
end.compact
|
129
|
+
end
|
130
|
+
|
131
|
+
# Returns the +defaults+ Hash
|
132
|
+
# Convenience wrapper for instance access
|
133
|
+
def defaults
|
134
|
+
self.class.defaults #.merge(@defaults || {})
|
135
|
+
end
|
136
|
+
|
137
|
+
# Attribute reader for datasource_columns
|
138
|
+
def datasource_columns
|
139
|
+
self.class.datasource_columns || { }.freeze
|
140
|
+
end
|
141
|
+
|
142
|
+
# Returns a serialized representation of the reporting
|
143
|
+
def serialize
|
144
|
+
to_param.to_json
|
145
|
+
end
|
146
|
+
|
147
|
+
def to_param # :nodoc:
|
148
|
+
attributes.merge({
|
149
|
+
:select => select,
|
150
|
+
:group_by => group_by,
|
151
|
+
:order_by => order_by,
|
152
|
+
:limit => limit,
|
153
|
+
:offset => offset
|
154
|
+
})
|
155
|
+
end
|
156
|
+
|
157
|
+
def limit
|
158
|
+
(@limit || 1000).to_i
|
159
|
+
end
|
160
|
+
|
161
|
+
# Returns the serialized Reporting in a Hash that can be used for links
|
162
|
+
# and which is deserialized by from_params
|
163
|
+
def to_params(key = self.class.name.underscore.gsub('/', '_'))
|
164
|
+
HashWithIndifferentAccess.new( key => to_param )
|
165
|
+
end
|
166
|
+
|
167
|
+
# Returns true if the given column is available for filtering
|
168
|
+
#
|
169
|
+
def filterable_by?(column_name)
|
170
|
+
self.class.datasource_filters.key?(column_name.to_s)
|
171
|
+
end
|
172
|
+
|
173
|
+
class << self
|
174
|
+
# Defines a displayable column of the datasource
|
175
|
+
# Type defaults to string
|
176
|
+
def column(name, options = {})
|
177
|
+
self.datasource_columns ||= Hash.new.freeze
|
178
|
+
self.datasource_columns = column_or_filter(name, options, self.datasource_columns)
|
179
|
+
end
|
180
|
+
|
181
|
+
# Marks a column as filterable / adds it in the background
|
182
|
+
#
|
183
|
+
#
|
184
|
+
def filter(name, options = {})
|
185
|
+
self.datasource_filters ||= Hash.new.freeze
|
186
|
+
|
187
|
+
# update the column options
|
188
|
+
self.datasource_filters = column_or_filter(name, options, self.datasource_filters)
|
189
|
+
end
|
190
|
+
|
191
|
+
# Returns the defaults class variable
|
192
|
+
def defaults
|
193
|
+
self.datasource_defaults ||= Hash.new.freeze
|
194
|
+
end
|
195
|
+
|
196
|
+
# Sets the default value for select
|
197
|
+
def select_default(select)
|
198
|
+
self.datasource_defaults = defaults.merge({:select => select})
|
199
|
+
end
|
200
|
+
|
201
|
+
# Sets the default value for group_by
|
202
|
+
def group_by_default(group_by)
|
203
|
+
self.datasource_defaults = defaults.merge({:group_by => group_by})
|
204
|
+
end
|
205
|
+
|
206
|
+
# Returns a reporting from a serialized representation
|
207
|
+
def deserialize(value)
|
208
|
+
self.new(JSON.parse(value))
|
209
|
+
end
|
210
|
+
|
211
|
+
# Uses the +simple_parse+ method of the SqlParser to setup a reporting
|
212
|
+
# from a query. The where clause is intepreted as reporting configuration (activerecord attributes)
|
213
|
+
def from_params(params, key = self.name.underscore.gsub('/', '_'))
|
214
|
+
return self.deserialize(params[key]) if params.has_key?(key) && params[key].is_a?(String)
|
215
|
+
|
216
|
+
reporting = self.new(params[key])
|
217
|
+
reporting = from_query_params(params["query"]) if params.has_key?("query")
|
218
|
+
return reporting unless params.has_key?(:tq)
|
219
|
+
|
220
|
+
query = GoogleDataSource::DataSource::SqlParser.simple_parse(params[:tq])
|
221
|
+
attributes = Hash.new
|
222
|
+
query.conditions.each do |k, v|
|
223
|
+
if v.is_a?(Array)
|
224
|
+
v.each do |condition|
|
225
|
+
case condition.op
|
226
|
+
when '<='
|
227
|
+
attributes["to_#{k}"] = condition.value
|
228
|
+
when '>='
|
229
|
+
attributes["from_#{k}"] = condition.value
|
230
|
+
when 'in'
|
231
|
+
attributes["in_#{k}"] = condition.value
|
232
|
+
else
|
233
|
+
# raise exception for unsupported operator?
|
234
|
+
end
|
235
|
+
end
|
236
|
+
else
|
237
|
+
attributes[k] = v
|
238
|
+
end
|
239
|
+
end
|
240
|
+
attributes[:group_by] = query.groupby
|
241
|
+
attributes[:select] = query.select
|
242
|
+
attributes[:order_by] = query.orderby
|
243
|
+
attributes[:limit] = query.limit
|
244
|
+
attributes[:offset] = query.offset
|
245
|
+
attributes.merge!(params[key]) if params.has_key?(key)
|
246
|
+
#reporting.update_attributes(attributes)
|
247
|
+
reporting.attributes = attributes
|
248
|
+
reporting.query = params[:tq]
|
249
|
+
reporting
|
250
|
+
end
|
251
|
+
|
252
|
+
############################
|
253
|
+
# ActiveRecord overrides
|
254
|
+
############################
|
255
|
+
# Needed to build column accessors
|
256
|
+
#
|
257
|
+
def columns_hash
|
258
|
+
@columns_hash ||= begin
|
259
|
+
ret = { }
|
260
|
+
data = { }
|
261
|
+
data.update(self.datasource_columns) if self.datasource_columns
|
262
|
+
data.update(self.datasource_filters) if self.datasource_filters
|
263
|
+
|
264
|
+
data.each do |k, v|
|
265
|
+
ret[k.to_s] = ActiveRecord::ConnectionAdapters::Column.new(k.to_s,
|
266
|
+
v[:default],
|
267
|
+
v[:type].to_s,
|
268
|
+
v.key?(:null) ? v[:null] : true)
|
269
|
+
|
270
|
+
# define a humanize method which returns the human name
|
271
|
+
if v.key?(:human_name)
|
272
|
+
ret[k.to_s].define_singleton_method(:humanize) do
|
273
|
+
v[:human_name]
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
ret
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
# Returns a hash with column name as key and default value as value
|
283
|
+
#
|
284
|
+
def column_defaults(*args)
|
285
|
+
columns_hash.keys.inject({}) do |mem, var|
|
286
|
+
mem[var] = columns_hash[var].default
|
287
|
+
mem
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
# TODO: merge with columns hash
|
292
|
+
def columns
|
293
|
+
columns_hash.values
|
294
|
+
end
|
295
|
+
|
296
|
+
def primary_key
|
297
|
+
'id'
|
298
|
+
end
|
299
|
+
|
300
|
+
def abstract_class # :nodoc:
|
301
|
+
true
|
302
|
+
end
|
303
|
+
|
304
|
+
protected
|
305
|
+
# Used to decorate a filter or column
|
306
|
+
# used by #column and #filter internally
|
307
|
+
#
|
308
|
+
def column_or_filter(name, options, registry)
|
309
|
+
name = name.to_s
|
310
|
+
|
311
|
+
# whatever this is.
|
312
|
+
# options.each { |k,v| options[k] = v.to_s if Symbol === v }
|
313
|
+
|
314
|
+
default_options = { :type => :string }
|
315
|
+
|
316
|
+
new_entry = { name => (registry[name] || {}) }
|
317
|
+
new_entry[name].reverse_merge!(default_options.merge(options))
|
318
|
+
new_entry.freeze
|
319
|
+
|
320
|
+
# frozen, to prevent modifications on class_attribute
|
321
|
+
registry.merge(new_entry).freeze
|
322
|
+
end
|
323
|
+
|
324
|
+
def from_query_params(query)
|
325
|
+
page = query.delete('page')
|
326
|
+
reporting = self.new(query)
|
327
|
+
reporting.limit = query['limit']
|
328
|
+
if page
|
329
|
+
reporting.offset = reporting.limit * (page.to_i - 1)
|
330
|
+
else
|
331
|
+
reporting.offset = query['offset'].to_i
|
332
|
+
end
|
333
|
+
|
334
|
+
reporting
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
############################
|
339
|
+
# ActiveRecord overrides
|
340
|
+
############################
|
341
|
+
|
342
|
+
def save # :nodoc:
|
343
|
+
if result = valid?
|
344
|
+
end
|
345
|
+
|
346
|
+
result
|
347
|
+
end
|
348
|
+
|
349
|
+
def save! # :nodoc:
|
350
|
+
save or raise ActiveRecord::RecordInvalid.new(self)
|
351
|
+
end
|
352
|
+
end
|