csv_model 0.1.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 +34 -0
- data/.travis.yml +9 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +31 -0
- data/LICENSE +21 -0
- data/README.md +6 -0
- data/Rakefile +20 -0
- data/csv_model.gemspec +24 -0
- data/lib/csv_model/column.rb +27 -0
- data/lib/csv_model/errors.rb +5 -0
- data/lib/csv_model/extensions.rb +45 -0
- data/lib/csv_model/header_row.rb +133 -0
- data/lib/csv_model/model.rb +96 -0
- data/lib/csv_model/object_with_status_snapshot.rb +112 -0
- data/lib/csv_model/record_status.rb +16 -0
- data/lib/csv_model/row.rb +128 -0
- data/lib/csv_model/utilities.rb +15 -0
- data/lib/csv_model/version.rb +3 -0
- data/lib/csv_model.rb +17 -0
- data/spec/csv_model/column_spec.rb +31 -0
- data/spec/csv_model/header_row_spec.rb +278 -0
- data/spec/csv_model/model_spec.rb +192 -0
- data/spec/csv_model/object_with_status_snapshot_spec.rb +297 -0
- data/spec/csv_model/row_spec.rb +351 -0
- data/spec/csv_model/utilities_spec.rb +82 -0
- data/spec/spec_helper.rb +20 -0
- metadata +119 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 670a14423c708636825d5a2cebed117a0ac186e2
|
4
|
+
data.tar.gz: 7c39766c75b1b629c743036a5791da36c93032c9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 8fb5608b94c655f60c36c89b469f651ddd4a725a64fa5359b6534ec4cc37197cb80387e3bc213d40cb09b49569a0417ebfa63c60ccf4e81e54d2db41d71f46e4
|
7
|
+
data.tar.gz: 03c07a434abc2fec5416f62cff6453a4bffd2758f017e5c91cadb35daeca408adf26c03b1592e721ee6051c9be864f74f04e6d05c2c062a8a28bfffada80bc54
|
data/.gitignore
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
/.config
|
4
|
+
/coverage/
|
5
|
+
/InstalledFiles
|
6
|
+
/pkg/
|
7
|
+
/spec/reports/
|
8
|
+
/test/tmp/
|
9
|
+
/test/version_tmp/
|
10
|
+
/tmp/
|
11
|
+
|
12
|
+
## Specific to RubyMotion:
|
13
|
+
.dat*
|
14
|
+
.repl_history
|
15
|
+
build/
|
16
|
+
|
17
|
+
## Documentation cache and generated files:
|
18
|
+
/.yardoc/
|
19
|
+
/_yardoc/
|
20
|
+
/doc/
|
21
|
+
/rdoc/
|
22
|
+
|
23
|
+
## Environment normalisation:
|
24
|
+
/.bundle/
|
25
|
+
/lib/bundler/man/
|
26
|
+
|
27
|
+
# for a library or gem, you might want to ignore these files since the code is
|
28
|
+
# intended to run in multiple environments; otherwise, check them in:
|
29
|
+
# Gemfile.lock
|
30
|
+
# .ruby-version
|
31
|
+
# .ruby-gemset
|
32
|
+
|
33
|
+
# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
|
34
|
+
.rvmrc
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
csv_model (0.1.0)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
diff-lcs (1.2.5)
|
10
|
+
rake (10.3.1)
|
11
|
+
rspec (3.0.0)
|
12
|
+
rspec-core (~> 3.0.0)
|
13
|
+
rspec-expectations (~> 3.0.0)
|
14
|
+
rspec-mocks (~> 3.0.0)
|
15
|
+
rspec-core (3.0.2)
|
16
|
+
rspec-support (~> 3.0.0)
|
17
|
+
rspec-expectations (3.0.2)
|
18
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
19
|
+
rspec-support (~> 3.0.0)
|
20
|
+
rspec-mocks (3.0.2)
|
21
|
+
rspec-support (~> 3.0.0)
|
22
|
+
rspec-support (3.0.2)
|
23
|
+
|
24
|
+
PLATFORMS
|
25
|
+
ruby
|
26
|
+
|
27
|
+
DEPENDENCIES
|
28
|
+
bundler (~> 1.6)
|
29
|
+
csv_model!
|
30
|
+
rake
|
31
|
+
rspec (= 3.0.0)
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2014 Scrimmage
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
data/Rakefile
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler'
|
3
|
+
require 'rspec/core/rake_task'
|
4
|
+
|
5
|
+
Bundler::GemHelper.install_tasks
|
6
|
+
|
7
|
+
desc 'Default: run the specs.'
|
8
|
+
task :default do
|
9
|
+
system("bundle exec rake -s spec:unit;")
|
10
|
+
end
|
11
|
+
|
12
|
+
namespace :spec do
|
13
|
+
desc "Run unit specs"
|
14
|
+
RSpec::Core::RakeTask.new('unit') do |t|
|
15
|
+
t.pattern = 'spec/**/*_spec.rb}'
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
desc "Run the unit and acceptance specs"
|
20
|
+
task :spec => ['spec:unit']
|
data/csv_model.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'csv_model/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "csv_model"
|
8
|
+
spec.version = CSVModel::VERSION
|
9
|
+
spec.authors = ["Matthew Chadwick"]
|
10
|
+
spec.email = ["matthew.chadwick@gmail.com"]
|
11
|
+
spec.summary = %q{Utility library for importing CSV data.}
|
12
|
+
spec.description = %q{}
|
13
|
+
spec.homepage = ""
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler", "~> 1.6"
|
22
|
+
spec.add_development_dependency "rake"
|
23
|
+
spec.add_development_dependency "rspec", "3.0.0"
|
24
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
using CSVModel::Extensions
|
2
|
+
|
3
|
+
module CSVModel
|
4
|
+
class Column
|
5
|
+
include Utilities::Options
|
6
|
+
|
7
|
+
attr_reader :name
|
8
|
+
|
9
|
+
def initialize(name, options = {})
|
10
|
+
@name = name
|
11
|
+
@options = options
|
12
|
+
end
|
13
|
+
|
14
|
+
def is_primary_key?
|
15
|
+
option(:primary_key, false)
|
16
|
+
end
|
17
|
+
|
18
|
+
def key
|
19
|
+
name.to_column_key
|
20
|
+
end
|
21
|
+
|
22
|
+
def model_attribute
|
23
|
+
key.underscore.to_sym
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module CSVModel
|
2
|
+
module Extensions
|
3
|
+
|
4
|
+
refine Array do
|
5
|
+
def all_values_blank?
|
6
|
+
return true if empty?
|
7
|
+
each { |value| return false if !value.nil? && value.try(:strip) != "" }
|
8
|
+
true
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
refine NilClass do
|
13
|
+
def try(*args)
|
14
|
+
nil
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
refine Object do
|
19
|
+
def try(*a, &b)
|
20
|
+
if a.empty? && block_given?
|
21
|
+
yield self
|
22
|
+
else
|
23
|
+
public_send(*a, &b) if respond_to?(a.first)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
refine String do
|
29
|
+
def to_column_key
|
30
|
+
downcase.strip
|
31
|
+
end
|
32
|
+
|
33
|
+
def underscore
|
34
|
+
tr(' ', '_').tr("-", "_")
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
refine Symbol do
|
39
|
+
def to_column_key
|
40
|
+
to_s.downcase.strip
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
using CSVModel::Extensions
|
2
|
+
|
3
|
+
module CSVModel
|
4
|
+
class HeaderRow
|
5
|
+
include Utilities::Options
|
6
|
+
|
7
|
+
attr_reader :data
|
8
|
+
|
9
|
+
def initialize(data, options = {})
|
10
|
+
@data = data
|
11
|
+
@options = options
|
12
|
+
end
|
13
|
+
|
14
|
+
def columns
|
15
|
+
@columns ||= data.collect { |x| Column.new(x) }
|
16
|
+
end
|
17
|
+
|
18
|
+
def column_count
|
19
|
+
columns.count
|
20
|
+
end
|
21
|
+
|
22
|
+
def column_index(key)
|
23
|
+
column_keys.index(key.to_column_key)
|
24
|
+
end
|
25
|
+
|
26
|
+
def errors
|
27
|
+
duplicate_column_errors + illegal_column_errors + missing_column_errors
|
28
|
+
end
|
29
|
+
|
30
|
+
# TODO: Remove? Not currently used.
|
31
|
+
def has_column?(key)
|
32
|
+
!column_index(key).nil?
|
33
|
+
end
|
34
|
+
|
35
|
+
def primary_key_columns
|
36
|
+
@primary_key_columns ||= columns.select { |x| primary_key_column_keys.include?(x.key) }
|
37
|
+
end
|
38
|
+
|
39
|
+
def valid?
|
40
|
+
has_required_columns? && !has_duplicate_columns? && !has_illegal_columns?
|
41
|
+
end
|
42
|
+
|
43
|
+
protected
|
44
|
+
|
45
|
+
def column_keys
|
46
|
+
@column_keys ||= columns.collect { |x| x.key }
|
47
|
+
end
|
48
|
+
|
49
|
+
def column_map
|
50
|
+
@column_map ||= Hash[columns.collect { |x| [x.key, x] }]
|
51
|
+
end
|
52
|
+
|
53
|
+
def column_name(column_key)
|
54
|
+
data.find { |entry| entry.to_column_key == column_key }
|
55
|
+
end
|
56
|
+
|
57
|
+
def duplicate_column_errors
|
58
|
+
duplicate_column_names.collect { |name| "Multiple columns found for #{name}, column headings must be unique" }
|
59
|
+
end
|
60
|
+
|
61
|
+
def duplicate_column_names
|
62
|
+
data.collect { |x| x.to_column_key }
|
63
|
+
.inject(Hash.new(0)) { |counts, key| counts[key] += 1; counts }
|
64
|
+
.select { |key, count| count > 1 }
|
65
|
+
.collect { |key, count| column_name(key) }
|
66
|
+
end
|
67
|
+
|
68
|
+
def has_duplicate_columns?
|
69
|
+
data.count != column_map.keys.count
|
70
|
+
end
|
71
|
+
|
72
|
+
def has_illegal_columns?
|
73
|
+
illegal_column_keys.any?
|
74
|
+
end
|
75
|
+
|
76
|
+
def has_required_columns?
|
77
|
+
missing_column_keys.empty?
|
78
|
+
end
|
79
|
+
|
80
|
+
def illegal_column_errors
|
81
|
+
illegal_column_names.collect { |name| "Unknown column #{name}" }
|
82
|
+
end
|
83
|
+
|
84
|
+
def illegal_column_keys
|
85
|
+
legal_column_names.any? ? column_keys - legal_column_keys : []
|
86
|
+
end
|
87
|
+
|
88
|
+
def illegal_column_names
|
89
|
+
illegal_column_keys.collect { |key| column_name(key) }
|
90
|
+
end
|
91
|
+
|
92
|
+
def legal_column_names
|
93
|
+
option(:legal_columns, [])
|
94
|
+
end
|
95
|
+
|
96
|
+
def legal_column_keys
|
97
|
+
legal_column_names.collect { |x| x.to_column_key }
|
98
|
+
end
|
99
|
+
|
100
|
+
def missing_column_errors
|
101
|
+
missing_column_names.collect { |name| "Missing column #{name}" }
|
102
|
+
end
|
103
|
+
|
104
|
+
def missing_column_keys
|
105
|
+
required_column_keys - column_keys
|
106
|
+
end
|
107
|
+
|
108
|
+
def missing_column_names
|
109
|
+
missing_column_keys.collect { |key| required_column_name(key) }
|
110
|
+
end
|
111
|
+
|
112
|
+
def primary_key_column_keys
|
113
|
+
primary_key_column_names.collect { |x| x.to_column_key }
|
114
|
+
end
|
115
|
+
|
116
|
+
def primary_key_column_names
|
117
|
+
option(:primary_key, [])
|
118
|
+
end
|
119
|
+
|
120
|
+
def required_column_keys
|
121
|
+
required_column_names.collect { |x| x.to_column_key }
|
122
|
+
end
|
123
|
+
|
124
|
+
def required_column_name(column_key)
|
125
|
+
required_column_names.find { |entry| entry.to_column_key == column_key }
|
126
|
+
end
|
127
|
+
|
128
|
+
def required_column_names
|
129
|
+
option(:required_columns, [])
|
130
|
+
end
|
131
|
+
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
using CSVModel::Extensions
|
2
|
+
|
3
|
+
module CSVModel
|
4
|
+
class Model
|
5
|
+
include Utilities::Options
|
6
|
+
|
7
|
+
attr_reader :data, :header, :keys, :parse_error, :rows
|
8
|
+
|
9
|
+
def initialize(data, options = {})
|
10
|
+
@data = data
|
11
|
+
@rows = []
|
12
|
+
@options = options
|
13
|
+
@keys = Set.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def row_count
|
17
|
+
rows.count
|
18
|
+
end
|
19
|
+
|
20
|
+
def structure_errors
|
21
|
+
return [parse_error] if parse_error
|
22
|
+
return header.errors if !header.valid?
|
23
|
+
[]
|
24
|
+
end
|
25
|
+
|
26
|
+
def structure_valid?
|
27
|
+
parse_error.nil? && header.valid?
|
28
|
+
end
|
29
|
+
|
30
|
+
instance_methods(false).each do |method_name|
|
31
|
+
method = instance_method(method_name)
|
32
|
+
define_method(method_name) do |*args, &block|
|
33
|
+
parse_data
|
34
|
+
method.bind(self).(*args, &block)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
protected
|
39
|
+
|
40
|
+
def create_header_row(row)
|
41
|
+
header_class.new(row, options)
|
42
|
+
end
|
43
|
+
|
44
|
+
def create_row(row)
|
45
|
+
row = row_class.new(header, row, options)
|
46
|
+
row.mark_as_duplicate if is_duplicate_key?(row.key)
|
47
|
+
row
|
48
|
+
end
|
49
|
+
|
50
|
+
def header_class
|
51
|
+
option(:header_class, HeaderRow)
|
52
|
+
end
|
53
|
+
|
54
|
+
def is_duplicate_key?(value)
|
55
|
+
return false if value.nil? || value == "" || (value.is_a?(Array) && value.all_values_blank?)
|
56
|
+
!keys.add?(value)
|
57
|
+
end
|
58
|
+
|
59
|
+
def parse_data
|
60
|
+
return if @parsed
|
61
|
+
@parsed = true
|
62
|
+
|
63
|
+
begin
|
64
|
+
CSV.parse(data, { col_sep: "\t" }).each_with_index do |row, index|
|
65
|
+
if index == 0
|
66
|
+
@header = create_header_row(row)
|
67
|
+
end
|
68
|
+
|
69
|
+
if row.size != header.column_count
|
70
|
+
raise ParseError.new("Each row should have exactly #{header.column_count} columns. Error on row #{index + 1}.")
|
71
|
+
end
|
72
|
+
|
73
|
+
# TODO: Should we keep this?
|
74
|
+
# if index > (limit - 1)
|
75
|
+
# raise ParseError.new("You can only import #{limit} records at a time. Please split your import into multiple parts.")
|
76
|
+
# end
|
77
|
+
|
78
|
+
if index > 0
|
79
|
+
@rows << create_row(row)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
rescue CSV::MalformedCSVError => e
|
83
|
+
@parse_error = "The data could not be parsed. Please check for formatting errors: #{e.message}"
|
84
|
+
rescue ParseError => e
|
85
|
+
@parse_error = e.message
|
86
|
+
rescue Exception => e
|
87
|
+
@parse_error = "An unexpected error occurred. Please try again or contact support if the issue persists: #{e.message}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def row_class
|
92
|
+
option(:row_class, Row)
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
using CSVModel::Extensions
|
2
|
+
|
3
|
+
module CSVModel
|
4
|
+
class ObjectWithStatusSnapshot < SimpleDelegator
|
5
|
+
include RecordStatus
|
6
|
+
|
7
|
+
def assign_attributes(attributes)
|
8
|
+
__getobj__.try(:assign_attributes, attributes)
|
9
|
+
end
|
10
|
+
|
11
|
+
def errors
|
12
|
+
if __getobj__.nil?
|
13
|
+
["Record could not be created or updated"]
|
14
|
+
else
|
15
|
+
value = __getobj__.errors
|
16
|
+
value.try(:full_messages) || value
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def mark_as_duplicate
|
21
|
+
@is_duplicate = true
|
22
|
+
end
|
23
|
+
|
24
|
+
def save(options = {})
|
25
|
+
capture_state(options[:dry_run])
|
26
|
+
@was_saved = was_editable? && was_valid? && (is_dry_run? || save_or_destroy)
|
27
|
+
end
|
28
|
+
|
29
|
+
def status
|
30
|
+
return ERROR_ON_READ if __getobj__.nil?
|
31
|
+
return DUPLICATE if is_dry_run? && is_duplicate?
|
32
|
+
return status_for_new_record if was_new?
|
33
|
+
return status_for_existing_record if was_existing?
|
34
|
+
UNKNOWN
|
35
|
+
end
|
36
|
+
|
37
|
+
def valid?
|
38
|
+
__getobj__.try(:valid?)
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def capture_state(dry_run)
|
44
|
+
@is_dry_run = dry_run == true
|
45
|
+
if !__getobj__.nil?
|
46
|
+
@was_changed = changed?
|
47
|
+
@was_deleted = marked_for_destruction?
|
48
|
+
@was_editable = __getobj__.respond_to?(:editable?) ? __getobj__.editable? : true
|
49
|
+
@was_new = new_record?
|
50
|
+
@was_valid = valid?
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def is_duplicate?
|
55
|
+
@is_duplicate
|
56
|
+
end
|
57
|
+
|
58
|
+
def is_dry_run?
|
59
|
+
@is_dry_run
|
60
|
+
end
|
61
|
+
|
62
|
+
def save_or_destroy
|
63
|
+
marked_for_destruction? ? __getobj__.destroy : __getobj__.save
|
64
|
+
end
|
65
|
+
|
66
|
+
def status_for_existing_record
|
67
|
+
return DELETE if was_deleted?
|
68
|
+
return NOT_CHANGED if !was_changed?
|
69
|
+
return UPDATE if was_valid? && was_saved?
|
70
|
+
ERROR_ON_UPDATE # if (!was_editable? || !was_valid? || !was_saved?)
|
71
|
+
end
|
72
|
+
|
73
|
+
def status_for_new_record
|
74
|
+
return ERROR_ON_DELETE if was_deleted?
|
75
|
+
return ERROR_ON_CREATE if was_not_valid?
|
76
|
+
CREATE # if valid?
|
77
|
+
end
|
78
|
+
|
79
|
+
def was_changed?
|
80
|
+
@was_changed
|
81
|
+
end
|
82
|
+
|
83
|
+
def was_deleted?
|
84
|
+
@was_deleted
|
85
|
+
end
|
86
|
+
|
87
|
+
def was_editable?
|
88
|
+
@was_editable
|
89
|
+
end
|
90
|
+
|
91
|
+
def was_existing?
|
92
|
+
!was_new?
|
93
|
+
end
|
94
|
+
|
95
|
+
def was_new?
|
96
|
+
@was_new
|
97
|
+
end
|
98
|
+
|
99
|
+
def was_saved?
|
100
|
+
@was_saved
|
101
|
+
end
|
102
|
+
|
103
|
+
def was_not_valid?
|
104
|
+
!was_valid?
|
105
|
+
end
|
106
|
+
|
107
|
+
def was_valid?
|
108
|
+
@was_valid
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
112
|
+
end
|