Broadly speaking, the Indexable
module provides classes with
functionality related to the following:
- define what should be indexed (attributes names/values, nested fields, field types etc.)
- define who should be notified when an object (or its owner) needs to be indexed (when the object changes in some way)
- when should the above notifications occur
In our examples below, we are working with ActiveRecord
models, but
AgnosticBackend
can be used with any object. Also, whenever we
mention the word "document" below, we take this to be a Ruby Hash.
Say we need to represent a workflow comprising a sequence of tasks
within a case, using a Workflow
AR model and a Task
AR model
connected by a one-to-many relationship, as follows:
class Task < ActiveRecord::Base
belongs_to :workflow, class_name: 'Workflow'
end
class Workflow < ActiveRecord::Base
has_many :tasks, class_name: 'Task'
end
We can specify what to index in the Task
model by including
AgnosticBackend::Indexable
and using the define_index_fields
method as follows:
class Task < ActiveRecord::Base
include AgnosticBackend::Indexable
belongs_to :workflow, class_name: 'Workflow'
define_index_fields do
integer :id
date :last_assigned_at, value: :assigned_at
string :type, value: proc { task_category.name }
end
end
In this case, we specify that the document to be generated when the
time comes (more about that below) will include 3 fields: id
,
last_assigned_at
and type
. Let's look at each one of them in more
detail. The document will contain a field with the key id
and the
value that will be generated when the object receives the message
:id
at document generation time. This means that in the simplest
possible case, the field's key is a method to which we expect the
object to respond. The document will also contain the
last_assigned_at
key whose value will be determined by sending the
message assigned_at
to the object. Finally, the document will also
contain the field type
which in this case is a computed value that
will be determined at runtime at executing the specified proc
in the
context of self
(i.e. in the context of the class's instance).
Indexable
supports the specification of nested documents by using
the struct
type as follows:
class Task < ActiveRecord::Base
include AgnosticBackend::Indexable
belongs_to :workflow, class_name: 'Workflow'
define_index_fields do
integer :id
date :last_assigned_at, value: :assigned_at
string :type, value: proc { task_category.name }
struct :workflow, from: Workflow
end
end
As a result, the document will also contain a workflow
field whose
value will be derived by requesting a document from the object's
workflow
reference (which is a Workflow
instance). In order to get
this to work, we need a corresponding definition in the Workflow
class as follows:
class Workflow < ActiveRecord::Base
include AgnosticBackend::Indexable
has_many :tasks, class_name: 'Task'
define_index_fields(owner: Task) do
integer :id
date :created_at
text_array :notes, value: proc { notes.map(&:body) }
end
end
Notice the use of the owner: Task
argument in
define_index_fields
. This means that the document specified within
the block is to be used only when requested by the Task
's document
generation process. It also implies that we can specify multiple
document definitions in the same class for different owners. When the
owner is not specified, it is taken to be the class in which the
definition is written.
Use the Indexable#generate_document
method in order to obtain a hash
with the document's contents. For example, given a Task
instance:
> task.generate_document
{:id=>5, :last_assigned_at=>2016-02-09 19:45:00 UTC, ...,
:workflow=>{:id=>6, ...}}
The document includes all fields specified in Task
including the
nested hash retrieved from Workflow
.
Indexable
does not specify when and in what way a document should be
indexed. Instead, this decision is up to the client. The objective is
to achieve the maximum flexibility with regard to different
requirements, some of which are summarized below:
- when the class is an AR model, the client would like to use AR
callbacks (such as
after_save
orafter_commit
) to index the model. - the client may wish to implement document indexing in an asynchronous manner for performance reasons.
- the client may wish to decide whether to index the document only if certain conditions are met.
The entry point to indexing a document is
Indexable::InstanceMethods#trigger_index_notification
, which is
responsible for notifying whoever has been registered in one or many
define_index_notifiers
blocks within the class. The message
:index_object
is sent to all objects that need to be notified. By
default, Indexable::InstanceMethods#index_object
will delegate to
Indexable::InstanceMethods#put_in_index
which will index the
document synchronously.
Indexable::InstanceMethods#index_object
can be overriden in order to
implement custom behaviour (such as asynchronous indexing through
queueing). Any call to put_in_index
will index
Indexable::InstanceMethods#trigger_index_notification
must be used in order to notify all registered objectsIndexable::InstanceMethods#index_object
, by default, will index the document synchronously (by callingIndexable::InstanceMethods#put_in_index
).index_object
needs to be overriden in order to implement custom behaviour (such as asynchronous indexing)Indexable::InstanceMethods#put_in_index
is the method that implements the actual indexing. Any custom implementation ofindex_object
must ultimately callput_in_index
for indexing to occur.
Indexable
supports the following generic types:
:integer
:double
:string
: this is a literal string (i.e. should be matched exactly):string_array
: an array of literal strings:text
: text that can be interpreted as free text by a specific backend:text_array
: an array of text fields:date
: datetime field:boolean
:struct
: used to specify a nested structure
The specification of types in the definition of index fields implies
that we can derive the document schema using the Indexable#schema
method. E.g. given the Task
class:
> Task.schema
{
"id" => :integer,
"last_assigned_at" => :date,
"type" => :string,
"workflow" => {
"id" => :integer,
"created_at" => :date,
"notes" => :text_array
}
}
The definition of index fields within a class allows for the specification of custom attributes, for example:
class Task < ActiveRecord::Base
include AgnosticBackend::Indexable
belongs_to :workflow, class_name: 'Workflow'
define_index_fields do
integer :id
date :last_assigned_at, value: :assigned_at,
is_column: true, label: 'Last Assigned At'
string :type, value: proc { task_category.name }
is_column: true, label: 'Task Type'
struct :workflow, from: Workflow
end
end
In this example, we have specified two custom attributes for fields
label
and is_column
, for use in UI elements.
We can get these options back (say :is_column
) by passing a block to
Indexable#schema
(that yields a FieldType
instance) as follows:
> task.schema {|field_type| field_type.get_option('is_column') }
{:id=>nil, :last_assigned_at=>true, :type=>true, ...}
Custom attributes can be very useful in a variety of situations; for example, they can be used in the context of web views in order to control the visual/behavioural aspects of the document's fields.
Indexable
also supports AR polymorphic relationships as nested
fields. Suppose we have the following model:
class Task < ActiveRecord::Base
include AgnosticBackend::Indexable
has_one :concrete_task, polymorphic: true
define_index_fields do
struct :concrete_task
end
end
that has a polymorphic relationship with a concrete task, which can be
one of various classes, say ConcreteTaskA
and ConcreteTaskB
. When
requesting the Task
's schema using Task.schema
the algorithm can
not figure out which class needs to be queried about its schema when
it encounters the struct
field. As a result, the schema is
incomplete.
This can be overcome by specifying the possible classes that can
constitute a concrete task using the from
attribute as:
class Task < ActiveRecord::Base
include AgnosticBackend::Indexable
has_one :concrete_task, polymorphic: true
define_index_fields do
struct :concrete_task, from: [ConcreteTaskA, ConcreteTaskB]
end
end
As a result, the schema will include a concrete_task
field whose
value will be the result of a merge between the schemas of all the
classes specified in the from
attribute.
AgnosticBackends
also supplies RSpec
matchers for verifying that a
given class is Indexable
and that it indexes the expected fields.
In your spec_helper.rb
use the following:
require 'agnostic_backend/rspec/matchers'
RSpec.configure do |config|
config.include AgnosticBackend::RSpec::Matchers
end
This gives access to the matchers be_indexable
and
define_index_field
. For usage examples, check the
corresponding test file.