Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 84 additions & 108 deletions concepts/backend/mappers.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,136 +2,112 @@

The job of a mapper is to take an incoming request (e.g. the body of a PUT) and transform the data therein to changes in the application (usually records in the database.)

Generally, you'll have (at least) one mapper per REST resource. Those mappers will be descendants of Xing::Mappers::Base.
Generally, you'll have (at least) one mapper per REST resource. Those mappers will be descendants of Xing::Mappers::Base. It's best practice for there to be one mapper class per model used - it's much easier to compose several mappers into a single Rails controller than it is to manage multiple database tables in a single mapper.

### Subclasses must define:
## In use

Method | Purpose
--- | ----
aliases | for self.record
record_class | if the mapper maps to an AR object
Within the controller for a particular resource, actions that modify the database (i.e. `#update`, `#create`) create the appropriate mapper, give it the JSON of the request body and a parameter value (e.g. `:id`), and then call `mapper.save`.

### Subclasses should usually define:
A very standard example:
```ruby
class CheeseController
def update
mapper = CheeseMapper.new(body, params[:id])
mapper.save
# and so on ...
end
end
```

Method | Purpose
--- | ---
assign_values | move values from JSON into the mapped AR record
Each mapper extends and overrides `Xing::Mappers::Base`. Like other Xing backend classes, mappers are designed to be overriden in both large and small ways. Most useful for this task is understanding the flow that happens in the `.new; .save` process.

### Subclasses may also want to define:
## Mapper Extension Guide

Method | Purpose
--: | ---
locator_attribute_name | if the locator something other than :id, like :url_slug
find_existing_record | for locating the underlying AR record
build_new_record | for for instantiating a new underlying AR record
map_nested_models |
build_errors | if simply copying AR errors is insufficient
save | if they need to save more than 1 AR record

When updating records, pass the locator (e.g. DB id, url_slug, or other unique resource extracted from the resource path) as the second argument.
First of all, a mapper goes through four distinct phases when it does a `save`:

0. Load - the ActiveRecord object needed is loaded from the database or created fresh
0. Update - values from the request JSON are transfered to the the object
0. Validation - ActiveRecord validations are checked, and whole-request validations are performed
0. Save - if the request validates, the ActiveRecord object is saved.

```ruby
class Xing::Mappers::Base
include Services::Locator
class MissingLinkException < Exception; end

def initialize(json, locator = nil)
@source_json = json
if @source_json.is_a? String
@source_hash = JSON.parse(json).with_indifferent_access
else
@source_hash = @source_json
end
@locator = locator
end
attr_accessor :locator, :locator_attribute_name, :error_data
attr_writer :record
attr_reader :links
By cleanly separating these phases, we make it possible to compose Xing Mappers in order to cleanly separate HTTP resources from the database tables that record them.

def self.locator_attribute_name
:id
end
One key idea to mappers is the idea of "nesting" mappers: a top level mapper can contruct other mappers, build JSON documents for them, and otherwise use other mappers in order to break even the most complicated requests into reasonably sized pieces.

# Default save - subclasses might override
def save
perform_mapping
unless self.errors[:data].present?
save_nested_models
self.record.save
end
end
### A Simple Mapper

# Default for finding an existing record - override this *or* define
# #record_class (e.g. `return Page`
def find_existing_record
@record = record_class.find(@locator)
end
Many mappers simply copy values from the incoming JSON record to fields in the database. If this is sufficient for a particular resource, you can do it like this:

# Default for building a new record - override this *or* define #record_class
# (e.g. `return Page`
def build_new_record
@record = record_class.new
```ruby
class CheeseMapper < Xing::Mappers::Base
def record_class
Cheese
end

def perform_mapping
data = unwrap_data(@source_hash)
@links = unwrap_links(@source_hash)
self.error_data = Hash.new { |hash, key| hash[key] = {} }

assign_values(data)
map_nested_models
build_errors
def assign_values(data)
record.update_attributes(data.slice(:kind, :age, :color, :smell))
end
end
```

def unwrap_links(hash)
hash['links'].with_indifferent_access if hash['links']
end

def unwrap_data(hash)
return hash['data'] if hash['data'].is_a?(Array)
hash['data'].with_indifferent_access
end
### Complete reference for the save cycle

The example above covers many use cases of mappers, but not all. Almost every Xing application will include more complicated resources. Rather than try to categorize every such use case, we've included a guide to how the Mapper superclass works, so that you can determine what methods make sense to override.

First, the mapper is initialized with:
source_hash: the parsed JSON from the request
locator: the value used to look up the record (nil, in the case of a create)

In the below, indents indicate a nested call. Reading top to bottom completes the save process
* `save`
* `perform_mapping`
* data = `unwrap_data(source_hash)`
* `source_hash["data"]`
* links = `unwrap_links(source_hash)`
* `source_hash["links"]`
* errors = {}

* `assign_values(data)`
* `record`
* `find_existing_record` if locator is present
* @record gets `record_class`.find(@locator)
* _record_class_ has to be implemented by subclass
* `build_new_record` otherwise
* @record gets `record_class`.new
* `update_record`
* _update_record_ has to be implemented by a subclass
* `map_nested_models`
* _map_nested_models_ can be left as is, but doesn't do anything by default
* `build_errors`
* `add_ar_errors(record)`
* Converts activerecord validations into Xing style error document fields
* `save_nested_models` and `record.save` if no errors
* _save_nested_models_ can be left as is, but doesn't do anything by default

def wrap_data(hash)
{
data: hash
}
end

def record
@record ||= if !locator.nil?
find_existing_record
else
build_new_record
end
end
### Subclasses must define:

def assign_values(data_hash)
# Override in subclasses to assign needed values here
record # force loading or creation of the underlying DB record
update_record
end
Method | Purpose
--- | ----
record_class | if the mapper maps to an AR object
assign_values | move values from JSON into the mapped AR record

# Do nothing if there are no nested models
# Override this method in subclass if necessary
def map_nested_models
end
### Subclasses should usually define:

def save_nested_models
end
Method | Purpose
--- | ---
aliases | for self.record

def build_errors
self.add_ar_errors(self.record)
end
### Subclasses may also want to define:

def errors
wrap_data(error_data)
end
Method | Purpose
--: | ---
locator_attribute_name | if the locator something other than :id, like :url_slug
find_existing_record | for locating the underlying AR record
build_new_record | for for instantiating a new underlying AR record
map_nested_models |
save_nested_models |
build_errors | if simply copying AR errors is insufficient

def add_ar_errors(object)
object_errors = Xing::Services::ErrorConverter.new(object).convert
error_data.deep_merge!(object_errors)
end
end
```
When updating records, pass the locator (e.g. DB id, url_slug, or other unique resource extracted from the resource path) as the second argument.