Usage Without ActiveRecord
Graphiti was built to be used with any ORM or datastore, from PostgreSQL
to elasticsearch to Net::HTTP
. In fact, Graphiti itself is tested with
Plain Old Ruby Objects (POROs).
This cookbook will show how to customize a resource around a particular datastore, and how to package those customizations into a reusable adapter. We’ll use an in-memory datastore and Plain Old Ruby Objects (POROs) here, but the lessons apply to any datastore.
For working code, see this branch of the sample application.
We’ll start with this PORO model:
class Post
# Define getters/setters
# e.g. post.title = 'foo'
ATTRS = [:id, :title]
ATTRS.each { |a| attr_accessor(a) }
# Instantiate with hash of attributes
# e.g. Post.new(title: 'foo')
def initialize(attrs = {})
attrs.each_pair { |k,v| send(:"#{k}=", v) }
end
# This part only needed for our particular
# persistence implementation; you may not need it
# e.g. post.attributes # => { title: 'foo' }
def attributes
{}.tap do |attrs|
ATTRS.each do |name|
attrs[name] = send(name)
end
end
end
end
And this in-memory datastore:
# If we were working with more than just Posts, we'd need a 'type'
# field here as well, to simulate a table name.
DATA = [
{ id: 1, title: 'Graphiti' },
{ id: 2, title: 'is' },
{ id: 3, title: 'super' },
{ id: 4, title: 'dope' }
]
Resource Overrides
If it’s your first time with a new ORM or datastore, we recommend putting the logic in the Resource first. Once things are working and there are multiple uses of the same overrides, package them into an Adapter.
class PostResource < ApplicationResource
self.adapter = Graphiti::Adapters::Null
attribute :title, :string
def base_scope
{}
end
def resolve(scope)
DATA.map { |d| Post.new(d) }
end
end
Here we’re using the Null
adapter, which acts as a dumb pass-through.
This can be helpful when you just want to get running for a simple use
case and don’t want errors around features you haven’t implemented yet.
But it can also be confusing when you expect certain codepaths to
be hit. Mostly just be aware of Null
’s behavior, or use
Graphiti::Adapters::Abstract
to get helpful errors around what’s not
implemented.
We’re also supplying an explicit base_scope
. This is the beginning
query object we’ll modify as params come in. In the case of
ActiveRecord, we might want an ActiveRecord::Relation
like
Post.all
. For our example, we’ll modify a simple ruby hash (keep in
mind the premise of building a hash of options and passing it off to a
client can apply to any datastore).
Finally, we’re resolving that scope,
returning the full dataset for now. The contract of #resolve
is to
return an array of model instances, hence DATA.map { |d| Post.new(d)
}
.
Sorting
sort_all do |scope, attribute, direction|
scope[:sort].merge!(attribute: att, direction: dir)
end
def base_scope
{ sort: {} }
end
def resolve(scope)
if sort = scope[:sort].presence
data = DATA.sort_by { |d| d[sort[:attribute].to_sym] }
data = data.reverse if sort[:direction] == :desc
end
DATA.map { |d| Post.new(d) }
end
We modified the base scope with a default hash key, :sort
. When the
user requests sorting, we record this by merging into the hash. We can
then reference that information on the scope when resolving.
Note the sort_all
scope block, in fact all scope blocks, must return the scope.
Paginating
paginate do |scope, current_page, per_page|
scope.merge!(current_page: current, per_page: per)
end
def resolve(scope)
# ... sorting ...
start = (scope[:current_page] - 1) * scope[:per_page]
stop = start + scope[:per_page]
data = data[start...stop]
# ... return models ...
end
Again: merge into the scope, then reference the scope data when resolving.
Filtering
filter :title, only: [:eq] do
eq do |scope, value|
scope[:filters][attribute] = value
scope
end
end
def base_scope(*)
{ sort: {}, filters: {} }
end
def resolve(scope)
# ... sorting ...
scope[:filters].each_pair do |k, v|
data = data.select { |d| d[k.to_sym].in?(v) }
end
# ... pagination ...
# ... return models ...
end
Same as above examples. Again, note that we must return the scope object from the filter function.
Persisting
All at once:
# Instantiate a model for #create
def build(model_class)
model_class.new
end
# Used for create/update
def assign_attributes(model, attributes)
attributes.each_pair do |k, v|
model.send(:"#{k}=", v)
end
end
# Used for create/update
def save(model)
attrs = model.attributes.dup
attrs[:id] ||= DATA.length + 1
if existing = DATA.find { |d| d[:id].to_s == attrs[:id].to_s }
existing.merge!(attrs)
else
DATA << attrs
end
model
end
# Used for destroy
def delete(model)
DATA.reject! { |d| d[:id].to_s == model.id.to_s }
model
end
These are the overrides for persistence operations. You are encouraged
not to override create/update/destroy
directly and instead use
Persistence Lifecycle Hooks.
Adapters
OK so we have all our read and write operations working correctly. But if we had multiple Resources all using an in-memory datastore, you’d see this logic repeated all over the place. Let’s create an adapter to DRY up this logic.
There isn’t much more to do than copy/paste what we’ve already done.
Let’s start with our base_scope
, sorting, and pagination:
class POROAdapter < Graphiti::Adapters::Abstract
def base_scope(*)
{ sort: {}, filters: {} }
end
def paginate(scope, current, per)
scope.merge!(current_page: current, per_page: per)
end
def order(scope, att, dir)
scope[:sort].merge!(attribute: att, direction: dir)
scope
end
def resolve(scope)
data = DATA
if sort = scope[:sort].presence
data = data.sort_by { |d| d[sort[:attribute].to_sym] }
data = data.reverse if sort[:direction] == :desc
end
start = (scope[:current_page] - 1) * scope[:per_page]
stop = start + scope[:per_page]
data = data[start...stop]
data.map { |d| resource.model.new(d) }
end
end
There’s really nothing here we haven’t seen before. We’re taking the
code we originally wrote, and sticking it into the interface defined by
Graphiti::Adapters::Abstract
.
There’s a little more to do with filtering:
def filter(scope, attribute, value)
scope[:filters][attribute] = value
scope
end
alias :filter_string_eq :filter
alias :filter_integer_eq :filter
alias :filter_date_eq :filter
# ... etc ...
The logic is the same, but we have a separate method for each filter
operator. This allows us to query differently based on the type - for
instance, ActiveRecord will default to case-insensitive for strings, but
straight equality for integers. If you don’t need operator-specific
logic, just alias
as you see here.
You may want to limit the default operators we expect to work with a
given type. Let’s say your backend allows straight equality for strings,
but doesn’t support prefix
, suffix
, etc. You can specify this in
your adapter:
def self.default_operators
super.tap do |built_in|
built_in[:string] = [:eq]
end
end
# or avoid super altogether
def self.default_operators
{
string: [:eq],
integer: [:eq]
# ... etc ...
}
end
That’s it for reads. For writes, I’ll post the entire adapter code below - again, it’s just copy/pasting what we already wrote into a slightly different format.
def destroy(model)
Post::DATA.reject! { |d| d[:id].to_s == model.id.to_s }
model
end
def save(model)
attrs = model.attributes.dup
attrs[:id] ||= Post::DATA.length + 1
if existing = Post::DATA.find { |d| d[:id].to_s == attrs[:id].to_s }
existing.merge!(attrs)
else
Post::DATA << attrs
end
model
end
# For wrapping persistence operations in a DB transactions
# Our in-memory DB doesn't have transactions, so just yield
def transaction(*)
yield
end
That’s really it. See the working code in Employee Directory here.