thinking-sphinx 4.4.1 → 5.2.1
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 +4 -4
- data/.circleci/config.yml +152 -0
- data/.travis.yml +12 -21
- data/Appraisals +10 -19
- data/CHANGELOG.markdown +62 -0
- data/README.textile +15 -17
- data/bin/loadsphinx +17 -4
- data/lib/thinking_sphinx.rb +5 -4
- data/lib/thinking_sphinx/active_record.rb +1 -0
- data/lib/thinking_sphinx/active_record/association_proxy/attribute_finder.rb +1 -1
- data/lib/thinking_sphinx/active_record/attribute/sphinx_presenter.rb +1 -1
- data/lib/thinking_sphinx/active_record/base.rb +2 -6
- data/lib/thinking_sphinx/active_record/callbacks/association_delta_callbacks.rb +21 -0
- data/lib/thinking_sphinx/active_record/callbacks/delete_callbacks.rb +1 -1
- data/lib/thinking_sphinx/active_record/callbacks/delta_callbacks.rb +3 -2
- data/lib/thinking_sphinx/active_record/callbacks/update_callbacks.rb +1 -1
- data/lib/thinking_sphinx/active_record/interpreter.rb +4 -4
- data/lib/thinking_sphinx/active_record/sql_source.rb +12 -0
- data/lib/thinking_sphinx/active_record/sql_source/template.rb +2 -2
- data/lib/thinking_sphinx/callbacks.rb +9 -0
- data/lib/thinking_sphinx/callbacks/appender.rb +59 -0
- data/lib/thinking_sphinx/connection.rb +4 -0
- data/lib/thinking_sphinx/connection/client.rb +6 -1
- data/lib/thinking_sphinx/core/index.rb +1 -2
- data/lib/thinking_sphinx/deletion.rb +18 -17
- data/lib/thinking_sphinx/errors.rb +1 -1
- data/lib/thinking_sphinx/index_set.rb +7 -3
- data/lib/thinking_sphinx/middlewares/sphinxql.rb +1 -1
- data/lib/thinking_sphinx/railtie.rb +9 -4
- data/lib/thinking_sphinx/real_time/index.rb +5 -3
- data/lib/thinking_sphinx/real_time/index/template.rb +12 -0
- data/lib/thinking_sphinx/real_time/interpreter.rb +8 -6
- data/lib/thinking_sphinx/real_time/populator.rb +4 -1
- data/lib/thinking_sphinx/real_time/transcriber.rb +41 -19
- data/lib/thinking_sphinx/real_time/translator.rb +1 -0
- data/lib/thinking_sphinx/settings.rb +13 -9
- data/spec/acceptance/big_integers_spec.rb +1 -1
- data/spec/acceptance/geosearching_spec.rb +13 -3
- data/spec/acceptance/merging_spec.rb +1 -1
- data/spec/acceptance/real_time_updates_spec.rb +2 -2
- data/spec/acceptance/sql_deltas_spec.rb +15 -3
- data/spec/acceptance/support/sphinx_helpers.rb +4 -4
- data/spec/acceptance/suspended_deltas_spec.rb +3 -3
- data/spec/internal/app/indices/article_index.rb +0 -1
- data/spec/internal/app/indices/colour_index.rb +7 -0
- data/spec/internal/app/models/admin/person.rb +3 -1
- data/spec/internal/app/models/album.rb +3 -1
- data/spec/internal/app/models/animal.rb +1 -0
- data/spec/internal/app/models/article.rb +2 -0
- data/spec/internal/app/models/bird.rb +1 -0
- data/spec/internal/app/models/book.rb +2 -0
- data/spec/internal/app/models/car.rb +1 -1
- data/spec/internal/app/models/city.rb +2 -0
- data/spec/internal/app/models/colour.rb +2 -0
- data/spec/internal/app/models/product.rb +1 -1
- data/spec/internal/app/models/tee.rb +5 -0
- data/spec/internal/app/models/user.rb +2 -0
- data/spec/internal/config/database.yml +6 -1
- data/spec/internal/db/schema.rb +1 -0
- data/spec/thinking_sphinx/active_record/callbacks/update_callbacks_spec.rb +2 -1
- data/spec/thinking_sphinx/active_record/index_spec.rb +0 -12
- data/spec/thinking_sphinx/active_record/interpreter_spec.rb +15 -14
- data/spec/thinking_sphinx/active_record/sql_source_spec.rb +41 -3
- data/spec/thinking_sphinx/connection/mri_spec.rb +49 -0
- data/spec/thinking_sphinx/deletion_spec.rb +5 -4
- data/spec/thinking_sphinx/index_set_spec.rb +28 -12
- data/spec/thinking_sphinx/middlewares/sphinxql_spec.rb +2 -1
- data/spec/thinking_sphinx/real_time/index_spec.rb +51 -13
- data/spec/thinking_sphinx/real_time/interpreter_spec.rb +14 -14
- data/spec/thinking_sphinx/real_time/transcriber_spec.rb +9 -1
- data/thinking-sphinx.gemspec +3 -3
- metadata +17 -12
- data/spec/acceptance/connection_spec.rb +0 -25
@@ -16,10 +16,14 @@ class ThinkingSphinx::RealTime::Index < Riddle::Configuration::RealtimeIndex
|
|
16
16
|
end
|
17
17
|
|
18
18
|
def add_attribute(attribute)
|
19
|
+
@attributes.delete_if { |existing| existing.name == attribute.name }
|
20
|
+
|
19
21
|
@attributes << attribute
|
20
22
|
end
|
21
23
|
|
22
24
|
def add_field(field)
|
25
|
+
@fields.delete_if { |existing| existing.name == field.name }
|
26
|
+
|
23
27
|
@fields << field
|
24
28
|
end
|
25
29
|
|
@@ -61,12 +65,10 @@ class ThinkingSphinx::RealTime::Index < Riddle::Configuration::RealtimeIndex
|
|
61
65
|
|
62
66
|
def collection_for(attribute)
|
63
67
|
case attribute.type
|
64
|
-
when :integer, :boolean
|
68
|
+
when :integer, :boolean, :timestamp
|
65
69
|
attribute.multi? ? @rt_attr_multi : @rt_attr_uint
|
66
70
|
when :string
|
67
71
|
@rt_attr_string
|
68
|
-
when :timestamp
|
69
|
-
@rt_attr_timestamp
|
70
72
|
when :float
|
71
73
|
@rt_attr_float
|
72
74
|
when :bigint
|
@@ -13,6 +13,10 @@ class ThinkingSphinx::RealTime::Index::Template
|
|
13
13
|
add_attribute primary_key, :sphinx_internal_id, :bigint
|
14
14
|
add_attribute class_column, :sphinx_internal_class, :string, :facet => true
|
15
15
|
add_attribute 0, :sphinx_deleted, :integer
|
16
|
+
|
17
|
+
if tidying?
|
18
|
+
add_attribute -> (_) { Time.current.to_i }, :sphinx_updated_at, :timestamp
|
19
|
+
end
|
16
20
|
end
|
17
21
|
|
18
22
|
private
|
@@ -34,7 +38,15 @@ class ThinkingSphinx::RealTime::Index::Template
|
|
34
38
|
[:class, :name]
|
35
39
|
end
|
36
40
|
|
41
|
+
def config
|
42
|
+
ThinkingSphinx::Configuration.instance
|
43
|
+
end
|
44
|
+
|
37
45
|
def primary_key
|
38
46
|
index.primary_key.to_sym
|
39
47
|
end
|
48
|
+
|
49
|
+
def tidying?
|
50
|
+
config.settings["real_time_tidy"]
|
51
|
+
end
|
40
52
|
end
|
@@ -5,16 +5,18 @@ class ThinkingSphinx::RealTime::Interpreter <
|
|
5
5
|
|
6
6
|
def has(*columns)
|
7
7
|
options = columns.extract_options!
|
8
|
-
|
8
|
+
|
9
|
+
columns.collect { |column|
|
9
10
|
::ThinkingSphinx::RealTime::Attribute.new column, options
|
10
|
-
}
|
11
|
+
}.each { |attribute| @index.add_attribute attribute }
|
11
12
|
end
|
12
13
|
|
13
14
|
def indexes(*columns)
|
14
15
|
options = columns.extract_options!
|
15
|
-
|
16
|
+
|
17
|
+
columns.collect { |column|
|
16
18
|
::ThinkingSphinx::RealTime::Field.new column, options
|
17
|
-
}
|
19
|
+
}.each { |field| @index.add_field field }
|
18
20
|
|
19
21
|
append_sortable_attributes columns, options if options[:sortable]
|
20
22
|
end
|
@@ -39,7 +41,7 @@ class ThinkingSphinx::RealTime::Interpreter <
|
|
39
41
|
def append_sortable_attributes(columns, options)
|
40
42
|
options = options.except(:sortable).merge(:type => :string)
|
41
43
|
|
42
|
-
|
44
|
+
columns.collect { |column|
|
43
45
|
aliased_name = options[:as]
|
44
46
|
aliased_name ||= column.__name.to_sym if column.respond_to?(:__name)
|
45
47
|
aliased_name ||= column
|
@@ -47,6 +49,6 @@ class ThinkingSphinx::RealTime::Interpreter <
|
|
47
49
|
options[:as] = "#{aliased_name}_sort".to_sym
|
48
50
|
|
49
51
|
::ThinkingSphinx::RealTime::Attribute.new column, options
|
50
|
-
}
|
52
|
+
}.each { |attribute| @index.add_attribute attribute }
|
51
53
|
end
|
52
54
|
end
|
@@ -7,6 +7,7 @@ class ThinkingSphinx::RealTime::Populator
|
|
7
7
|
|
8
8
|
def initialize(index)
|
9
9
|
@index = index
|
10
|
+
@started_at = Time.current
|
10
11
|
end
|
11
12
|
|
12
13
|
def populate
|
@@ -17,12 +18,14 @@ class ThinkingSphinx::RealTime::Populator
|
|
17
18
|
instrument 'populated', :instances => instances
|
18
19
|
end
|
19
20
|
|
21
|
+
transcriber.clear_before(started_at) if configuration.settings["real_time_tidy"]
|
22
|
+
|
20
23
|
instrument 'finish_populating'
|
21
24
|
end
|
22
25
|
|
23
26
|
private
|
24
27
|
|
25
|
-
attr_reader :index
|
28
|
+
attr_reader :index, :started_at
|
26
29
|
|
27
30
|
delegate :controller, :batch_size, :to => :configuration
|
28
31
|
delegate :scope, :to => :index
|
@@ -5,31 +5,20 @@ class ThinkingSphinx::RealTime::Transcriber
|
|
5
5
|
@index = index
|
6
6
|
end
|
7
7
|
|
8
|
+
def clear_before(time)
|
9
|
+
execute <<~SQL.strip
|
10
|
+
DELETE FROM #{@index.name} WHERE sphinx_updated_at < #{time.to_i}
|
11
|
+
SQL
|
12
|
+
end
|
13
|
+
|
8
14
|
def copy(*instances)
|
9
15
|
items = instances.select { |instance|
|
10
16
|
instance.persisted? && copy?(instance)
|
11
17
|
}
|
12
18
|
return unless items.present?
|
13
19
|
|
14
|
-
|
15
|
-
items
|
16
|
-
begin
|
17
|
-
values << ThinkingSphinx::RealTime::TranscribeInstance.call(
|
18
|
-
instance, index, properties
|
19
|
-
)
|
20
|
-
rescue ThinkingSphinx::TranscriptionError => error
|
21
|
-
instrument 'error', :error => error
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
|
-
insert = Riddle::Query::Insert.new index.name, columns, values
|
26
|
-
sphinxql = insert.replace!.to_sql
|
27
|
-
|
28
|
-
ThinkingSphinx::Logger.log :query, sphinxql do
|
29
|
-
ThinkingSphinx::Connection.take do |connection|
|
30
|
-
connection.execute sphinxql
|
31
|
-
end
|
32
|
-
end
|
20
|
+
delete_existing items
|
21
|
+
insert_replacements items
|
33
22
|
end
|
34
23
|
|
35
24
|
private
|
@@ -55,6 +44,27 @@ class ThinkingSphinx::RealTime::Transcriber
|
|
55
44
|
}
|
56
45
|
end
|
57
46
|
|
47
|
+
def delete_existing(instances)
|
48
|
+
ids = instances.collect(&index.primary_key.to_sym)
|
49
|
+
|
50
|
+
execute <<~SQL.strip
|
51
|
+
DELETE FROM #{@index.name} WHERE sphinx_internal_id IN (#{ids.join(', ')})
|
52
|
+
SQL
|
53
|
+
end
|
54
|
+
|
55
|
+
def execute(sphinxql)
|
56
|
+
ThinkingSphinx::Logger.log :query, sphinxql do
|
57
|
+
ThinkingSphinx::Connection.take do |connection|
|
58
|
+
connection.execute sphinxql
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def insert_replacements(instances)
|
64
|
+
insert = Riddle::Query::Insert.new index.name, columns, values(instances)
|
65
|
+
execute insert.replace!.to_sql
|
66
|
+
end
|
67
|
+
|
58
68
|
def instrument(message, options = {})
|
59
69
|
ActiveSupport::Notifications.instrument(
|
60
70
|
"#{message}.thinking_sphinx.real_time", options.merge(:index => index)
|
@@ -64,4 +74,16 @@ class ThinkingSphinx::RealTime::Transcriber
|
|
64
74
|
def properties
|
65
75
|
@properties ||= index.fields + index.attributes
|
66
76
|
end
|
77
|
+
|
78
|
+
def values(instances)
|
79
|
+
instances.each_with_object([]) do |instance, array|
|
80
|
+
begin
|
81
|
+
array << ThinkingSphinx::RealTime::TranscribeInstance.call(
|
82
|
+
instance, index, properties
|
83
|
+
)
|
84
|
+
rescue ThinkingSphinx::TranscriptionError => error
|
85
|
+
instrument 'error', :error => error
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
67
89
|
end
|
@@ -11,14 +11,16 @@ class ThinkingSphinx::Settings
|
|
11
11
|
lemmatizer_base mysql_ssl_cert mysql_ssl_key mysql_ssl_ca
|
12
12
|
].freeze
|
13
13
|
DEFAULTS = {
|
14
|
-
"configuration_file"
|
15
|
-
"indices_location"
|
16
|
-
"pid_file"
|
17
|
-
"log"
|
18
|
-
"query_log"
|
19
|
-
"binlog_path"
|
20
|
-
"workers"
|
21
|
-
"mysql_encoding"
|
14
|
+
"configuration_file" => "config/ENVIRONMENT.sphinx.conf",
|
15
|
+
"indices_location" => "db/sphinx/ENVIRONMENT",
|
16
|
+
"pid_file" => "log/ENVIRONMENT.sphinx.pid",
|
17
|
+
"log" => "log/ENVIRONMENT.searchd.log",
|
18
|
+
"query_log" => "log/ENVIRONMENT.searchd.query.log",
|
19
|
+
"binlog_path" => "tmp/binlog/ENVIRONMENT",
|
20
|
+
"workers" => "threads",
|
21
|
+
"mysql_encoding" => "utf8",
|
22
|
+
"maximum_statement_length" => (2 ** 23) - 5,
|
23
|
+
"real_time_tidy" => false
|
22
24
|
}.freeze
|
23
25
|
|
24
26
|
def self.call(configuration)
|
@@ -62,7 +64,9 @@ class ThinkingSphinx::Settings
|
|
62
64
|
|
63
65
|
def defaults
|
64
66
|
DEFAULTS.inject({}) do |hash, (key, value)|
|
65
|
-
|
67
|
+
if value.is_a?(String)
|
68
|
+
value = value.gsub("ENVIRONMENT", framework.environment)
|
69
|
+
end
|
66
70
|
|
67
71
|
if FILE_KEYS.include?(key)
|
68
72
|
hash[key] = absolute value
|
@@ -52,7 +52,7 @@ describe '64 bit document ids', :live => true do
|
|
52
52
|
context 'with Real-Time' do
|
53
53
|
it 'handles large 32 bit integers with an offset multiplier' do
|
54
54
|
product = Product.create! :name => "Widget"
|
55
|
-
product.
|
55
|
+
product.update :id => 980190962
|
56
56
|
expect(
|
57
57
|
Product.search('widget', :indices => ['product_core']).to_a
|
58
58
|
).to eq([product])
|
@@ -38,10 +38,20 @@ describe 'Searching by latitude and longitude', :live => true do
|
|
38
38
|
expected = {:mysql => 250326.906250, :postgresql => 250331.234375}
|
39
39
|
end
|
40
40
|
|
41
|
-
|
42
|
-
|
41
|
+
adapter = nil
|
42
|
+
|
43
|
+
if ActiveRecord::VERSION::STRING.to_f > 6.0
|
44
|
+
adapter = ActiveRecord::Base.configurations.configs_for.first.adapter
|
45
|
+
elsif ActiveRecord::VERSION::STRING.to_f > 5.2
|
46
|
+
adapter = ActiveRecord::Base.configurations.configs_for.first.config["adapter"]
|
47
|
+
else
|
48
|
+
adapter = ActiveRecord::Base.configurations['test']['adapter']
|
49
|
+
end
|
50
|
+
|
51
|
+
if adapter[/postgres/]
|
52
|
+
expect(cities.first.geodist).to be_within(0.01).of(expected[:postgresql])
|
43
53
|
else # mysql
|
44
|
-
expect(cities.first.geodist).to
|
54
|
+
expect(cities.first.geodist).to be_within(0.01).of(expected[:mysql])
|
45
55
|
end
|
46
56
|
end
|
47
57
|
|
@@ -34,7 +34,7 @@ describe "Merging deltas", :live => true do
|
|
34
34
|
Book.search("Space", :indices => ["book_core"]).to_a
|
35
35
|
).to eq([race])
|
36
36
|
|
37
|
-
race.reload.
|
37
|
+
race.reload.update :title => "The Hate Race"
|
38
38
|
sleep 0.25
|
39
39
|
expect(
|
40
40
|
Book.search("Race", :indices => ["book_delta"]).to_a
|
@@ -11,7 +11,7 @@ describe 'Updates to records in real-time indices', :live => true do
|
|
11
11
|
|
12
12
|
it "handles attributes for sortable fields accordingly" do
|
13
13
|
product = Product.create! :name => 'Red Fish'
|
14
|
-
product.
|
14
|
+
product.update :name => 'Blue Fish'
|
15
15
|
|
16
16
|
expect(Product.search('blue fish', :indices => ['product_core']).to_a).
|
17
17
|
to eq([product])
|
@@ -22,7 +22,7 @@ describe 'Updates to records in real-time indices', :live => true do
|
|
22
22
|
|
23
23
|
expect(Admin::Person.search('Death').to_a).to eq([person])
|
24
24
|
|
25
|
-
person.
|
25
|
+
person.update :name => 'Mort'
|
26
26
|
|
27
27
|
expect(Admin::Person.search('Death').to_a).to be_empty
|
28
28
|
expect(Admin::Person.search('Mort').to_a).to eq([person])
|
@@ -25,7 +25,7 @@ describe 'SQL delta indexing', :live => true do
|
|
25
25
|
|
26
26
|
expect(Book.search('Harry').to_a).to eq([book])
|
27
27
|
|
28
|
-
book.reload.
|
28
|
+
book.reload.update(:author => 'Terry Pratchett')
|
29
29
|
sleep 0.25
|
30
30
|
|
31
31
|
expect(Book.search('Terry').to_a).to eq([book])
|
@@ -37,7 +37,7 @@ describe 'SQL delta indexing', :live => true do
|
|
37
37
|
|
38
38
|
expect(Book.search('Harry').to_a).to eq([book])
|
39
39
|
|
40
|
-
book.reload.
|
40
|
+
book.reload.update(:author => 'Terry Pratchett')
|
41
41
|
sleep 0.25
|
42
42
|
|
43
43
|
expect(Book.search('Harry')).to be_empty
|
@@ -49,7 +49,7 @@ describe 'SQL delta indexing', :live => true do
|
|
49
49
|
|
50
50
|
expect(Album.search('Whitloms').to_a).to eq([album])
|
51
51
|
|
52
|
-
album.reload.
|
52
|
+
album.reload.update(:artist => 'The Whitlams')
|
53
53
|
sleep 0.25
|
54
54
|
|
55
55
|
expect(Book.search('Whitloms')).to be_empty
|
@@ -63,4 +63,16 @@ describe 'SQL delta indexing', :live => true do
|
|
63
63
|
|
64
64
|
expect(Book.search('Gaiman').to_a).to eq([book])
|
65
65
|
end
|
66
|
+
|
67
|
+
it "updates associated models" do
|
68
|
+
colour = Colour.create(:name => 'green')
|
69
|
+
sleep 0.25
|
70
|
+
|
71
|
+
expect(Colour.search('green').to_a).to eq([colour])
|
72
|
+
|
73
|
+
tee = colour.tees.create
|
74
|
+
sleep 0.25
|
75
|
+
|
76
|
+
expect(Colour.search(:with => {:tee_ids => tee.id}).to_a).to eq([colour])
|
77
|
+
end
|
66
78
|
end
|
@@ -6,22 +6,22 @@ module SphinxHelpers
|
|
6
6
|
end
|
7
7
|
|
8
8
|
def index(*indices)
|
9
|
-
sleep 0.5 if ENV['
|
9
|
+
sleep 0.5 if ENV['CI']
|
10
10
|
|
11
11
|
yield if block_given?
|
12
12
|
|
13
13
|
sphinx.index *indices
|
14
14
|
sleep 0.25
|
15
|
-
sleep 0.5 if ENV['
|
15
|
+
sleep 0.5 if ENV['CI']
|
16
16
|
end
|
17
17
|
|
18
18
|
def merge
|
19
|
-
sleep 0.5 if ENV['
|
19
|
+
sleep 0.5 if ENV['CI']
|
20
20
|
sleep 0.5
|
21
21
|
|
22
22
|
sphinx.merge
|
23
23
|
sleep 1.5
|
24
|
-
sleep 0.5 if ENV['
|
24
|
+
sleep 0.5 if ENV['CI']
|
25
25
|
end
|
26
26
|
end
|
27
27
|
|
@@ -10,7 +10,7 @@ describe 'Suspend deltas for a given action', :live => true do
|
|
10
10
|
expect(Book.search('Harry').to_a).to eq([book])
|
11
11
|
|
12
12
|
ThinkingSphinx::Deltas.suspend :book do
|
13
|
-
book.reload.
|
13
|
+
book.reload.update(:author => 'Terry Pratchett')
|
14
14
|
sleep 0.25
|
15
15
|
|
16
16
|
expect(Book.search('Terry').to_a).to eq([])
|
@@ -27,7 +27,7 @@ describe 'Suspend deltas for a given action', :live => true do
|
|
27
27
|
expect(Book.search('Harry').to_a).to eq([book])
|
28
28
|
|
29
29
|
ThinkingSphinx::Deltas.suspend :book do
|
30
|
-
book.reload.
|
30
|
+
book.reload.update(:author => 'Terry Pratchett')
|
31
31
|
sleep 0.25
|
32
32
|
|
33
33
|
expect(Book.search('Terry').to_a).to eq([])
|
@@ -44,7 +44,7 @@ describe 'Suspend deltas for a given action', :live => true do
|
|
44
44
|
expect(Book.search('Harry').to_a).to eq([book])
|
45
45
|
|
46
46
|
ThinkingSphinx::Deltas.suspend_and_update :book do
|
47
|
-
book.reload.
|
47
|
+
book.reload.update(:author => 'Terry Pratchett')
|
48
48
|
sleep 0.25
|
49
49
|
|
50
50
|
expect(Book.search('Terry').to_a).to eq([])
|
@@ -10,7 +10,6 @@ ThinkingSphinx::Index.define :article, :with => :active_record do
|
|
10
10
|
has taggings.created_at, :as => :taggings_at, :type => :timestamp
|
11
11
|
|
12
12
|
set_property :min_infix_len => 4
|
13
|
-
set_property :enable_star => true
|
14
13
|
end
|
15
14
|
|
16
15
|
ThinkingSphinx::Index.define :article, :with => :active_record,
|
@@ -6,7 +6,9 @@ class Album < ActiveRecord::Base
|
|
6
6
|
before_validation :set_id, :on => :create
|
7
7
|
before_validation :set_integer_id, :on => :create
|
8
8
|
|
9
|
-
|
9
|
+
ThinkingSphinx::Callbacks.append(
|
10
|
+
self, :behaviours => [:sql, :real_time, :deltas]
|
11
|
+
)
|
10
12
|
|
11
13
|
validates :id, :presence => true, :uniqueness => true
|
12
14
|
validates :integer_id, :presence => true, :uniqueness => true
|