Module: Hoodoo::ActiveRecord::Writer
- Defined in:
- lib/hoodoo/active/active_record/writer.rb
Overview
Support mixin for models subclassed from ActiveRecord::Base providing context-aware data writing, allowing service authors to auto-inherit persistence-related features from Hoodoo without changing their own code.
See individual module methods for examples, along with:
Dependency Hoodoo::ActiveRecord::ErrorMapping is also included automatically.
Defined Under Namespace
Modules: ClassMethods
Class Method Summary collapse
-
.included(model) ⇒ Object
Instantiates this module when it is included.
-
.instantiate(model) ⇒ Object
When instantiated in an ActiveRecord::Base subclass, all of the Hoodoo::ActiveRecord::Writer::ClassMethods methods are defined as class methods on the including class.
Instance Method Summary collapse
-
#persist_in(context) ⇒ Object
(also: #update_in)
Overview.
Class Method Details
.included(model) ⇒ Object
Instantiates this module when it is included.
Example:
class SomeModel < ActiveRecord::Base
include Hoodoo::ActiveRecord::Writer
# ...
end
Depends upon and auto-includes Hoodoo::ActiveRecord::Creator and Hoodoo::ActiveRecord::ErrorMapping.
model
-
The ActiveRecord::Base descendant that is including this module.
50 51 52 53 54 55 56 57 58 59 |
# File 'lib/hoodoo/active/active_record/writer.rb', line 50 def self.included( model ) unless model == Hoodoo::ActiveRecord::Base model.send( :include, Hoodoo::ActiveRecord::Creator ) model.send( :include, Hoodoo::ActiveRecord::ErrorMapping ) instantiate( model ) end super( model ) end |
.instantiate(model) ⇒ Object
When instantiated in an ActiveRecord::Base subclass, all of the Hoodoo::ActiveRecord::Writer::ClassMethods methods are defined as class methods on the including class.
This module depends upon Hoodoo::ActiveRecord::ErrorMapping, so that will be auto-included first if it isn’t already.
model
-
The ActiveRecord::Base descendant that is including this module.
71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/hoodoo/active/active_record/writer.rb', line 71 def self.instantiate( model ) model.extend( ClassMethods ) # See instance method "persist_in" for how this gets used. # model.validate do if @nz_co_loyalty_hoodoo_writer_db_uniqueness_violation == true errors.add( :base, 'has already been taken' ) end end end |
Instance Method Details
#persist_in(context) ⇒ Object Also known as: update_in
Overview
Service authors SHOULD use this method when persisting data with ActiveRecord if there is a risk of duplication constraint violation of any kind. This will include a violation on the UUID of a resource if you support external setting of this value via the body of a create
call containing the id
field, injected by Hoodoo as the result of an authorised use of the X-Resource-UUID
HTTP header.
You can use this method for both persisting new records or persisting updates, in the same way as ActiveRecord’s save
is used for either.
Concurrency
Services often run in highly concurrent environments and uniqueness constraint validations with ActiveRecord cannot protect against race conditions in such cases. Those work at the application level; the check to see if a record exists with a duplicate value in some given column is a separate operation from that which stores the record subsequently. As per the Rails Guides entry on the uniqueness validation at the time of writing:
guides.rubyonrails.org/active_record_validations.html#uniqueness
“It does not create a uniqueness constraint in the database, so it may happen that two different database connections create two records with the same value for a column that you intend to be unique. To avoid that, you must create a unique index on both columns in your database.”
You MUST always use a uniqueness constraint at the database level and MAY additionally use ActiveRecord validations for a higher level warning in all but race condition edge cases. If you then use this persist_in
method to store records, all duplication cases will be handled elegantly and reported as a generic.invalid_duplication
error. In the event that a caller has used the X-Deja-Vu
HTTP header, Hoodoo will take such an error and transform it into a non-error 204 HTTP response; so by using persist_in
, you also ensure that your service participates successfully in this process without any additional coding effort. You get safe concurrency and protection against the inherent lack of idempotency in HTTP POST
operations via any must-be-unique fields (within your defined scope) automatically.
Using this method for data storage instead of plain ActiveRecord save
or save!
will also help your code auto-inherit any additional future write-related enhancements in Hoodoo should they arise, without necessarily needing service code changes.
Parameters
context
-
Hoodoo::Services::Context instance describing a call context. This is typically a value passed to one of the Hoodoo::Services::Implementation instance methods that a resource subclass implements.
Returns a Symbol of :success
or :failure
indicating the outcome of the same attempt. In the event of failure, the model will be invalid and not persisted; you can read errors immediately and should avoid unnecessarily re-running validations by calling valid?
or validate
on the instance.
Example
class Unique < ActiveRecord::Base
include Hoodoo::ActiveRecord::Writer
validates :unique_code, :presence => true, :uniqueness => true
end
The migration to create the table for the Unique model MUST have a uniqueness constraint on the unique_code
field, e.g.:
def change
add_column :uniques, :unique_code, :null => false
add_index :uniques, [ :unique_code ], :unique => true
end
Then, inside the implementation class which uses the above model, where you have (say) written private methods mapping_of
which maps context.request.body
to an attributes Hash for persistence and rendering_of
which uses Hoodoo::Presenters::Base.render_in to properly render a representation of your resource, you would write:
def create( context )
attributes = mapping_of( context.request.body )
model_instance = Unique.new( attributes )
# ...maybe make other changes to model_instance, then...
unless model_instance.persist_in( context ).equal?( :success )
# Error condition. If you're using the error handler mixin
# in Hoodoo::ActiveRecord::ErrorMapping, do this:
#
context.response.add_errors( model_instance.platform_errors )
return # Early exit
end
# ...any other processing...
context.response.set_resource( rendering_of( context, model_instance ) )
end
See also
There is a class method equivalent which combines creating a new record and persisting it in a single call. If you prefer that code style, see Hoodoo::ActiveRecord::Writer::ClassMethods.persist_in. In such cases, it could look quite odd to mix the class method and instance method variants for new records or existing record updates; as syntax sugar, an alias of the #persist_in instance method is available under the name #update_in, so that you can use the class method for creation and the aliased instance method for updates.
Nested transaction note
Ordinarily an exception in a nested transaction does not roll back. ActiveRecord wraps all saves in a transaction “out of the box”, so the following construct could have unexpected results…
Model.transaction do
instance.persist_in( context )
end
…if instance.valid?
runs any SQL queries - which is very likely. PostgreSQL, for example, would then raise an exception; the inner transaction failed, leaving the outer one in an aborted state:
PG::InFailedSqlTransaction: ERROR: current transaction is
aborted, commands ignored until end of transaction block
ActiveRecord provides us with a way to define a transaction that does roll back via the requires_new: true
option. Hoodoo thus protects callers from the above artefacts by ensuring that all saves are wrapped in an outer transaction that causes rollback in any parents. This sidesteps the unexpected behaviour, but service authors might sometimes need to be aware of this if using complex transaction behaviour along with persist_in
.
In pseudocode, the internal implementation is:
self.transaction( :requires_new => true ) do
self.save
end
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 |
# File 'lib/hoodoo/active/active_record/writer.rb', line 236 def persist_in( context ) # If this model has an ActiveRecord uniqueness validation, it is # still subject to race conditions and MUST be backed by a database # constraint. If this constraint fails, try to re-run model # validations just in case it was a race condition case; though of # course, it could be that there is *only* a database constraint and # no model validation. If there is *only* a model validation, the # model is ill-defined and at risk. # TODO: This flag is nasty but seems unavoidable. Whenever you query # the validity of a record, AR will always clear all errors and # then (re-)run validations. We cannot just add an error to # "base" and expect it to survive. Instead, it's necessary to # use this flag to signal to the custom validator added in the # 'self.instantiate' implementation earlier that it should add # an error. Trouble is, when do we clear the flag...? # # This solution works but is inelegant and fragile. # @nz_co_loyalty_hoodoo_writer_db_uniqueness_violation = false # First just see if we have any problems saving anyway. # errors_occurred = begin self.transaction( :requires_new => true ) do :any unless self.save end rescue ::ActiveRecord::RecordNotUnique => error :duplication end # If an exception caught a duplication violation then either there is # a race condition on an AR-level uniqueness validation, or no such # validation at all. Thus, re-run validations with "valid?" and if it # still seems OK we must be dealing with a database-only constraint. # Set the magic flag (ugh, see earlier) to signal that when # validations run, they should add a relevant error to "base". # if errors_occurred == :duplication if self.valid? @nz_co_loyalty_hoodoo_writer_db_uniqueness_violation = true self.validate end end return errors_occurred.nil? ? :success : :failure end |