Skip to content

Commit 3151027

Browse files
committed
[STORE] Added the first version of the ActiveRecord-based model persistence
Using the `Elasticsearch::Persistence::Repository` from previous commits, this patch adds support for the ActiveRecord pattern of persistance for Ruby objects in Elasticsearch. The goal is to have a 1:1 implementation to the ActiveRecord::Base implementation, allowing to use it as a drop-in replacement for similar OxMs in Rails applications, with minimal changes to the model definition and application code. The model implementation uses [Virtus](https://github.com/solnic/virtus) for handling the model attributes, and [ActiveModel](https://github.com/rails/rails/tree/master/activemodel) for validations, callbacks, and similar model-related features. Example: -------- require 'elasticsearch/persistence/model' class Person include Elasticsearch::Persistence::Model settings index: { number_of_shards: 1 } attribute :name, String, mapping: { fields: { name: { type: 'string', analyzer: 'snowball' }, raw: { type: 'string', analyzer: 'keyword' } } } attribute :birthday, Date attribute :department, String attribute :salary, Integer attribute :admin, Boolean, default: false validates :name, presence: true before_save do puts "About to save: #{self}" end end Person.gateway.create_index! force: true person = Person.create name: 'John Smith', salary: 10_000 About to save: #<Person:0x007f961e89f010> => #<Person:0x007f961e89f010 ...> person.id => "zNf3yxZDQsOTZfNfTX4E5A" person = Person.find(person.id) => #<Person:0x007f961cf1f478 ... > person.salary => 10000 person.increment :salary => { ... "_version"=>2} person.salary => 10001 person.update admin: true => { ... "_version"=>3} Person.search('smith').to_a => [#<Person:0x007f961ebc5b90 ...>]
1 parent f2ca55f commit 3151027

File tree

8 files changed

+811
-1
lines changed

8 files changed

+811
-1
lines changed

elasticsearch-persistence/elasticsearch-persistence.gemspec

+4-1
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@ Gem::Specification.new do |s|
2626
s.add_dependency "elasticsearch", '> 0.4'
2727
s.add_dependency "elasticsearch-model", '>= 0.1'
2828
s.add_dependency "activesupport", '> 3'
29+
s.add_dependency "activemodel", '> 3'
2930
s.add_dependency "hashie"
31+
s.add_dependency "virtus"
3032

3133
s.add_development_dependency "bundler", "~> 1.5"
3234
s.add_development_dependency "rake"
3335

3436
s.add_development_dependency "oj"
35-
s.add_development_dependency "virtus"
37+
38+
s.add_development_dependency "rails"
3639

3740
s.add_development_dependency "elasticsearch-extensions"
3841

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
require 'active_support/core_ext/module/delegation'
2+
3+
require 'active_model'
4+
require 'virtus'
5+
6+
require 'elasticsearch/persistence'
7+
require 'elasticsearch/persistence/model/errors'
8+
require 'elasticsearch/persistence/model/store'
9+
10+
module Elasticsearch
11+
module Persistence
12+
13+
module Model
14+
module Utils
15+
def lookup_type(type)
16+
case
17+
when type == String
18+
'string'
19+
when type == Integer
20+
'integer'
21+
when type == Float
22+
'float'
23+
when type == Date || type == Time || type == DateTime
24+
'date'
25+
when type == Virtus::Attribute::Boolean
26+
'boolean'
27+
end
28+
end; module_function :lookup_type
29+
end
30+
31+
def self.included(base)
32+
base.class_eval do
33+
include ActiveModel::Naming
34+
include ActiveModel::Conversion
35+
include ActiveModel::Serialization
36+
include ActiveModel::Serializers::JSON
37+
include ActiveModel::Validations
38+
39+
include Virtus.model
40+
41+
extend ActiveModel::Callbacks
42+
define_model_callbacks :create, :save, :update, :destroy
43+
define_model_callbacks :find, :touch, only: :after
44+
45+
extend Elasticsearch::Persistence::Model::Store::ClassMethods
46+
include Elasticsearch::Persistence::Model::Store::InstanceMethods
47+
48+
class << self
49+
def attribute(name, type=nil, options={}, &block)
50+
mapping = options.delete(:mapping) || {}
51+
super
52+
53+
gateway.mapping do
54+
indexes name, {type: Utils::lookup_type(type)}.merge(mapping)
55+
end
56+
57+
gateway.mapping(&block) if block_given?
58+
end
59+
60+
def gateway(&block)
61+
@gateway ||= Elasticsearch::Persistence::Repository::Class.new host: self
62+
block.arity < 1 ? @gateway.instance_eval(&block) : block.call(@gateway) if block_given?
63+
@gateway
64+
end
65+
66+
delegate :settings,
67+
:mappings,
68+
:mapping,
69+
:document_type,
70+
:document_type=,
71+
:index_name,
72+
:index_name=,
73+
:search,
74+
:find,
75+
:exists?,
76+
to: :gateway
77+
end
78+
79+
gateway do
80+
klass base
81+
index_name base.model_name.collection.gsub(/\//, '-')
82+
document_type base.model_name.element
83+
84+
def serialize(document)
85+
document.to_hash.except(:id, 'id')
86+
end
87+
88+
def deserialize(document)
89+
object = klass.new document['_source']
90+
object.set_id document['_id']
91+
object.instance_variable_set(:@persisted, true)
92+
object
93+
end
94+
end
95+
96+
attribute :id, String, writer: :private
97+
attribute :created_at, DateTime, default: lambda { |o,a| Time.now.utc }
98+
attribute :updated_at, DateTime, default: lambda { |o,a| Time.now.utc }
99+
100+
def set_id(id)
101+
self.id = id
102+
end
103+
end
104+
105+
end
106+
end
107+
108+
end
109+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module Elasticsearch
2+
module Persistence
3+
module Model
4+
class DocumentNotSaved < StandardError; end
5+
class DocumentNotPersisted < StandardError; end
6+
end
7+
end
8+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
module Elasticsearch
2+
module Persistence
3+
module Model
4+
5+
module Store
6+
module ClassMethods
7+
def create(attributes, options={})
8+
object = self.new(attributes)
9+
object.run_callbacks :create do
10+
object.save(options)
11+
object
12+
end
13+
end
14+
end
15+
16+
module InstanceMethods
17+
def save(options={})
18+
return false unless valid?
19+
run_callbacks :save do
20+
response = self.class.gateway.save(self, options.merge(id: self.id))
21+
self[:updated_at] = Time.now.utc
22+
@persisted = true
23+
set_id(response['_id']) if respond_to?(:set_id)
24+
response
25+
end
26+
end
27+
28+
def destroy(options={})
29+
raise DocumentNotPersisted, "Object not persisted: #{self.inspect}" unless persisted?
30+
31+
run_callbacks :destroy do
32+
response = self.class.gateway.delete(self.id, options)
33+
@destroyed = true
34+
@persisted = false
35+
self.freeze
36+
response
37+
end
38+
end; alias :delete :destroy
39+
40+
def update(attributes={}, options={})
41+
raise DocumentNotPersisted, "Object not persisted: #{self.inspect}" unless persisted?
42+
43+
run_callbacks :update do
44+
attributes.update( { updated_at: Time.now.utc } )
45+
response = self.class.gateway.update(self.id, { doc: attributes}.merge(options))
46+
self.attributes = self.attributes.merge(attributes)
47+
response
48+
end
49+
end; alias :update_attributes :update
50+
51+
def increment(attribute, value=1, options={})
52+
raise DocumentNotPersisted, "Object not persisted: #{self.inspect}" unless persisted?
53+
54+
response = self.class.gateway.update(self.id, { script: "ctx._source.#{attribute} += #{value}"}.merge(options))
55+
self[attribute] += value
56+
response
57+
end
58+
59+
def decrement(attribute, value=1, options={})
60+
raise DocumentNotPersisted, "Object not persisted: #{self.inspect}" unless persisted?
61+
62+
response = self.class.gateway.update(self.id, { script: "ctx._source.#{attribute} = ctx._source.#{attribute} - #{value}"}.merge(options))
63+
self[attribute] -= value
64+
response
65+
end
66+
67+
def touch(attribute=:updated_at, options={})
68+
raise DocumentNotPersisted, "Object not persisted: #{self.inspect}" unless persisted?
69+
raise ArgumentError, "Object does not have '#{attribute}' attribute" unless respond_to?(attribute)
70+
71+
run_callbacks :touch do
72+
value = Time.now.utc
73+
response = self.class.gateway.update(self.id, { doc: { attribute => value.iso8601 }}.merge(options))
74+
self[attribute] = value
75+
response
76+
end
77+
end
78+
79+
def destroyed?
80+
!!@destroyed
81+
end
82+
83+
def persisted?
84+
!!@persisted && !destroyed?
85+
end
86+
87+
def new_record?
88+
!persisted? && !destroyed?
89+
end
90+
end
91+
end
92+
93+
end
94+
end
95+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
require 'test_helper'
2+
3+
require 'elasticsearch/persistence/model'
4+
5+
module Elasticsearch
6+
module Persistence
7+
class PersistenceModelBasicIntegrationTest < Elasticsearch::Test::IntegrationTestCase
8+
9+
class ::Person
10+
include Elasticsearch::Persistence::Model
11+
12+
settings index: { number_of_shards: 1 }
13+
14+
attribute :name, String,
15+
mapping: { fields: {
16+
name: { type: 'string', analyzer: 'snowball' },
17+
raw: { type: 'string', analyzer: 'keyword' }
18+
} }
19+
20+
attribute :birthday, Date
21+
attribute :department, String
22+
attribute :salary, Integer
23+
attribute :admin, Boolean, default: false
24+
25+
validates :name, presence: true
26+
end
27+
28+
context "A basic persistence model" do
29+
should "save and find the object" do
30+
person = Person.new name: 'John Smith', birthday: Date.parse('1970-01-01')
31+
person.save
32+
33+
assert_not_nil person.id
34+
document = Person.find(person.id)
35+
36+
assert_instance_of Person, document
37+
assert_equal 'John Smith', document.name
38+
assert_equal 'John Smith', Person.find(person.id).name
39+
40+
assert_not_nil Elasticsearch::Persistence.client.get index: 'people', type: 'person', id: person.id
41+
end
42+
43+
should "delete the object" do
44+
person = Person.create name: 'John Smith', birthday: Date.parse('1970-01-01')
45+
46+
person.destroy
47+
assert person.frozen?
48+
49+
assert_raise Elasticsearch::Transport::Transport::Errors::NotFound do
50+
Elasticsearch::Persistence.client.get index: 'people', type: 'person', id: person.id
51+
end
52+
end
53+
54+
should "update an object attribute" do
55+
person = Person.create name: 'John Smith'
56+
57+
person.update name: 'UPDATED'
58+
59+
assert_equal 'UPDATED', person.name
60+
assert_equal 'UPDATED', Person.find(person.id).name
61+
end
62+
63+
should "increment an object attribute" do
64+
person = Person.create name: 'John Smith', salary: 1_000
65+
66+
person.increment :salary
67+
68+
assert_equal 1_001, person.salary
69+
assert_equal 1_001, Person.find(person.id).salary
70+
end
71+
72+
should "update the object timestamp" do
73+
person = Person.create name: 'John Smith'
74+
updated_at = person.updated_at
75+
76+
sleep 1
77+
person.touch
78+
79+
assert person.updated_at > updated_at, [person.updated_at, updated_at].inspect
80+
81+
found = Person.find(person.id)
82+
assert found.updated_at > updated_at, [found.updated_at, updated_at].inspect
83+
end
84+
85+
should "find instances by search" do
86+
Person.create name: 'John Smith'
87+
Person.create name: 'Mary Smith'
88+
Person.gateway.refresh_index!
89+
90+
people = Person.search query: { match: { name: 'smith' } }
91+
92+
assert_equal 2, people.total
93+
assert_equal 2, people.size
94+
95+
assert people.map_with_hit { |o,h| h._score }.all? { |s| s > 0 }
96+
end
97+
end
98+
99+
end
100+
end
101+
end

0 commit comments

Comments
 (0)