Opium
Provides an intuitive, Mongoid-inspired mapping layer between your application's object space and Parse.
Installation
Add this line to your application's Gemfile:
gem 'opium'
And then execute:
$ bundle
Or install it yourself as:
$ gem install opium
Usage
Within Rails
Opium will automatically establish itself as the default ORM for Rails.
ORM Configuration
Create a config file to communicate with your Parse database by running the config generator:
$ rails g opium:config
See the generated file at config/opium.yml
for more details
Parse.com server closure note
Opium was originally written to communicate with apps hosted on Parse.com; as hosted parse is hitting end-of-life in January 2017, any parse apps which wish to continue using the infrastructure need to be migrated to third-party hosted parse-server instance.
As of version 1.4.0, Opium should be able to communicate with these third-party installations. Within the generated configuration file, two settings need to be updated to point Opium at the proper server instance:
server_url
: the URL of the server on which a parser-server API instance is hosted.mount_point
: the sub-URI endpoint onserver_url
where Opium can reach the API.
As the config file suggests, it is suggested that these values be provided in a server environment via environment variables.
Model Generator
A generator exists for creating new models; this should be invoked whenever rails g model
gets invoked.
$ rails g model game title:string price:float
A separate generate is available for creating a model to wrap Parse's User model:
$ rails g opium:user
Specifying a model
Models are defined by mixing in Opium::Model
into a new class. Class names should match the names of the
classes defined within Parse. You can define fields on your model which mirror the columns within a Parse
class.
class Game
include Opium::Model
field :title, type: String
field :price, type: Float
end
All models automatically come with three fields: :id, :created_at, and :updated_at. Field names are converted from a native ruby snake_case naming convention to a Parse lowerCamel convention.
Field data types
Opium comes with support for a variety of different data types for fields. These automatically will convert native ruby representations of the stored values to values supported by the parse backend, and conversely. At this time, Opium supports the following field types, where the first column is the type specified in ruby, and the second column is the type as stored in parse.
Ruby Type | Parse Type |
---|---|
Integer | Number |
Float | Number |
String | String |
Symbol | String |
Date | Date |
DateTime | Date |
Time | Date |
Array | Array |
Opium::Boolean | Boolean |
Opium::GeoPoint | GeoPoint |
Opium::File | File |
Opium::Pointer | Pointer |
Field setters will generally attempt to convert any incoming value to a native ruby representation, as noted above. Opium will automatically convert these values to a parse-friendly representation as necessary: e.g., when performing a query or persisting data.
Setting the type for a field is done by specifying the :type
option on the field method. If this option is not present, the field will default to a ruby type of Object
, which acts as a pass-through of the values coming from and going to parse. In the example from the last section, the Game
model has two fields, one which is specified as having a String
type, while the other has a Float
type.
Field options
Fields can be modified by a small set of options passed to their definition:
readonly
: Expects a boolean value. If present, Opium will prevent the field from being altered locally. (The associated parse column may still be modified from other locations outside of Opium.)as
: Expects a string or symbol value. If present, this will specify the name of the associated column within parse where the value should be stored. This allows the field to be named something more semantically useful locally.type
: See the previous section. Expects a class constant.default
: Expects either a logically convertible literal or a lambda providing the same. If a lambda is provided, it will be evaluated each time a model is instantiated, unless a value is provided for the field.
class Article
include Opium::Model
field :title, type: String, readonly: true
field :edited_on, type: Date, as: :last_edited
field :published_at, type: Date, default: -> { Time.now }
end
In the preceding example, an Article
is never allowed to alter its title
, while its last_edited
field is locally aliased as edited_on
, and it will provide a default value for published_at
, should none otherwise be provided.
Model associations
Opium currently supports basic associations between models: an owning model can specify that it has_many
of another model, which can specify that it belongs_to
the former.
class Player
# ...
has_many :high_scores
# ...
end
class HighScore
# ...
belongs_to :player
# ...
end
Opium will attempt to infer the class name and inverse method of an association by standard Rails naming conventions: the singular, classified variant of the method name is taken to be the target class. In case naming conventions prohibit this inference from working properly, the following options are available:
class_name
: Expects a string. In case the class name cannot be inferred from the association name, it can be provided by this option.inverse_of
: Expects a string or a symbol. In case the inverse method name cannot be inferred from the association name or its class name, it can be provided by this option.
Associations will be covered in more detail in the sections covering creating models and querying data. For now, note that Opium will attempt to manage the relationships between associated models for you and provides a robust, Rails-centric approach to manipulating and querying them.
Model field metadata
A set of utility class methods are provided to survey the defined fields and associations on any given model:
#fields
: returns a hash of all defined fields, keyed by the name of the field. Each value stored within the hash is anOpium::Model::Field
object, which has methods which reflect the settings discussed in Field options.#has_field?
/#field?
: expects a string or symbol, and returns a boolean value denoting whether the field is currently defined on the model.#relations
: returns a hash of all defined associations, keyed by the method name used to define the relationship on the current model. Each value is aOpium::Model::Relatable::Metadata
, which contains details pertaining to what the association is being made between.
Each of these methods would be called on the model you wish to inspect. In the following example, we ask the Player
model if it has a :name
field, get all of its readonly fields, and grab a list of its associations:
class Player
include Opium::Model
field :name, type: String
field :gamer_score, type: Integer
has_many :high_scores
has_many :played_games, class_name: 'GameSave', inverse_of: :played_by
end
# Should output the message, as Player does define a field called "name"
puts "'Player' has a name field!" if Player.has_field? :name
# Winnows the collection of field definitions by selecting only those which are readonly.
readonly_fields = Player.fields.select {|_, field| field.readonly?}
# #relations is a hash, indexed by name; in this example, the output should include the text "high_scores, played_games".
puts "'Player' defines the following associations: #{ Player.relations.keys.join(', ') }"
Model metadata is readonly; it shouldn't be updated after the model has been defined. It might be useful, however, for writing model concerns or some sort of view decorator to help DRY up model usage in Rails.
Validations
Opium provides access to ActiveModel::Validations
on a per model basis, so it is possible to validate the integrity of any data stored within an instance prior to saving it. Validations follow the normal ActiveModel format:
class Article
include Opium::Model
field :title, type: String
# ...
validates :title, presence: true
end
article = Article.new
article.valid? # false, as .title is nil.
article.errors # Standard ActiveModel::Errors object
article.title = 'Wibbly Wobbly Timey Wimey'
article.valid? # true, as .title has a value.
As is standard with ActiveModel
compliant libraries, attempting to save an invalid model will either return false (and not trigger the save) or raise an exception, depending upon how the save was triggered.
Callback hooks
Each Opium model has a set of callback points which can be hooked into. Adhering to the standard of ActiveModel::Callbacks
, these provide a model a means by which to tie into various parts of an instance's lifecycle.
A full list of the available callbacks can be found by accessing the following constant:
Opium::Model::Callbacks::CALLBACKS
Opium defines callbacks for the following events:
Event / Action | Supported hooks |
---|---|
Save | before, after, around |
Create | before, after, around |
Update | before, after, around |
Destroy | before, after, around |
Initialize | after |
Find | after |
Touch | after |
Validation | before, after |
To define a callback, do something like in the following example. (This example uses Dirty attributes, which are discussed in the next section.)
class Game
# ...
field :price, type: Float
has_many :on_wishlists, class_name: Wishlist
# ...
before_save :notify_price_drop
private
def notify_price_drop
if price_changed? && price_was > price
# Send a message to all players who have the game on their wishlist ...
end
end
end
Please note that callbacks are only invokable within the context of Opium running in Ruby itself; these do not define Cloud Code JavaScript methods within the parse-server backend. If you need to tie into an instance's lifecycle outside the scope of a Ruby project, it is suggested that you look at using Cloud Code directly.
Dirty attribute tracking
Models will automatically gain attribute tracking, provided by ActiveModel::Dirty
. As the fields of a model are altered, specialty events, of the form <field-name>_will_change!
will be raised. At any point prior to saving, you can use the full suite of Dirty methods to query an instance about changes which have occurred to it. Dirty tracking follows a cycle: upon instance initialization, the model has no changes; upon a field changing, the current set of changes is updated; upon successfully saving the model, the current changes are cleared, and the previous changes get updated.
class Article
include Opium::Model
field :title, type: String
field :body, type: String
end
article = Article.new
article.changes # Should be empty
# As we are updating the .title, it'll flag the dirty tracking with its update;
# as it has changed, some output denoting the alteration should be made.
article.title = 'Something happen'
puts article.title_change.inspect if article.title_changed?
Dirty tracking is provided for any attribute defined by the field
method.
JSON serialization
All Opium models should be serializable to JSON, using ActiveModel::Serialization
. Note that a model instance does not include a root node.
JSON serialization is built around an object's attributes
hash, which is publicly accessible. By default, all fields of the model are included within this hash. You can also store non-field data within attributes
and have it show up in the JSON output.
Be aware that all Opium models use ActiveModel::ForbiddenAttributesProtection
for mass assignment sanitization.
Creating and updating models
After defining a model with Opium, you might want to create new instances of it, or update the data of an existing instance. Opium has been designed to be familiar to anyone who has used other Rails-centric ORMs, such as ActiveRecord. In this regard, object creation follows two patterns: delayed persistence, and immediate persistence.
With delayed persistence, a model object is partially built and capable of being manipulated before being persisted. To finally create the model in parse-server, call its save
method:
class Player
include Opium::Model
field :name, type: String
field :gamer_score, type: Integer, default: 0
end
player = Player.new( name: 'The Doctor' )
player.gamer_score = 1000
player.persisted? # false, as it has not yet been saved
player.new_record? # true, as it has not yet been saved
if player.save
# persisted!
player.persisted? # true, as it has been saved
else
# there was a problem!
end
As this example suggests, you build a new model instance by calling its constructor, which accepts an attributes hash. The model may be altered and updated to taste. When ready to save, call the save
method. save
will run validations on the model, fire off any defined callbacks, update dirty tracking, and attempt to persist the model to parse-server. Should any of these steps fail, save
will return false, and halt the operation at the point of failure. Otherwise, save
will return true.
Alternatively, you can use save!
, which, in the event of a failure, will raise an exception.
Note that the model's id
, created_at
, and updated_at
fields will not have a value until it has been persisted.
With immediate persistence, a model object is built and then immediately stored to parse-server. This is achieved using the create
class method, which accepts an attributes hash:
player = Player.create( name: 'The Doctor', gamer_score: 1000 )
player.persisted? # true, as it has been saved
create
behaves like save!
: if there was a failure at any point in the persistence process, an exception will be raised. If there is no failure, the object returned by the method is a persisted model within parse-server.
Updating a model follows a similar set of patterns: at any time you wish to store changes to a persisted model, call save
or save!
:
player.gamer_score += 50
player.save!
Alternatively, if you want to update the attributes of a model and save it simultaneously, you can do so with either update
or update!
, which behave analogously to the save methods:
if player.update( gamer_score: player.gamer_score + 50 )
# persisted!
else
# There was a problem!
end
player.update!( gamer_score: 2000 )
update
is aliased as update_attributes
, and update!
is aliased as update_attributes!
; use whichever makes more semantic sense to you.
If a model has any associations, it will attempt to persist the changes to the association, which might very well cause a cascade of persistence. Opium will attempt to only trigger a save call to a model if it needs to. Due to parse-server's unique way of representing model associations in its API, updating the association between two models does require a separate API call. Be wary when attempting to modify many models simultaneously.
When you define an association between two models, one of those models receives a special collection field of type Opium::Model::Relation
for storing instances of the other model. This collection has utility methods for building new instances of the associated model:
class Player
# ...
has_many :high_scores
# ...
end
class HighScore
# ...
belongs_to :player
# ...
end
player = Player.new
player.high_scores.class # Opium::Model::Relation
player.high_scores.count # 0, as there are none, yet.
score1 = player.high_scores.build
score1.value = 200
# .build is aliased as .new; either accepts an attributes hash.
score2 = player.high_scores.new( value: 1000 )
player.high_scores.count # 2, from the previous actions
fail "That shouldn't have happened" unless score1.player == player
As you might expect, using a relation's build method will automatically add the built associated model to the collection; using this method will also cause the built model to point at the owner of the collection. You can also update the owner of a particular model on that model directly:
score3 = HighScore.new( value: 10000 )
score3.player = player
player.high_scores.include?( score3 ) # true, as the above setter updates player.
Querying data
Find by id
Criteria & Scopes
Kaminari support
Opium comes with support for Kaminari. To ensure that Opium loads itself correctly, please specify it after Kaminari within your Gemfile:
gem 'kaminari'
gem 'opium'
Models and Criteria will gain the methods defined by Kaminari, and should be compatible with Kaminari's pagination partials.
Contributing
- Fork it ( https://github.com/[my-github-username]/opium/fork )
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request