ella-sparse-array-controller
Version:
Sparse array controller for fetching large collections of records a few at a time.
731 lines (580 loc) • 19.1 kB
text/coffeescript
`import Ember from 'ember'`
get = Ember.get
set = Ember.set
###
@module emberella
@submodule emberella-controllers
###
###
`EmberellaSparseArray` is a variation on an
`Ember.ArrayController` that allows content to be lazily loaded from the
persistence layer.
@class EmberellaSparseArray
@extends Ember.ArrayProxy
###
EmberellaSparseArray = Ember.ArrayProxy.extend Ember.ControllerMixin,
###
@private
Stash a reference to the original content object.
@property _content
@type {Mixed}
@default null
###
_content: null
###
@private
Stash the potential total number of items as reported by the
persistence layer.
@property _length
@type {Integer}
@default null
###
_length: null
###
@property isSelectable
@type Boolean
@default true
@final
###
isSparseArrayController: true #quack like a duck
###
The number of items to fetch together in a single request. Essentially,
the "page size" of each query.
@property rangeSize
@type {Integer}
@default 1
###
rangeSize: 1
###
Flag to indicate if this controller should attempt to fetch data.
@property shouldRequestObjects
@type {Boolean}
@default true
###
shouldRequestObjects: true
###
Alias to `content` property. Override to customize the behavior of
content referencing.
@property sparseContent
###
sparseContent: Ember.computed.alias('content')
###
The total number of potential items in the sparse array. If the length is
unknown, requesting this property will cause the controller to try to fetch
the total length from the persistence layer.
@property length
@type {Integer}
@default 0
@readOnly
###
length: Ember.computed ->
ret = get(@, '_length')
@requestLength() if Ember.isEmpty(ret)
get(@, '_length') || 0
.property('_length').readOnly()
###
True if this controller instance is attempting to fetch its length.
@property isRequestingLength
@type {Boolean}
@default false
###
isRequestingLength: null
###
True if this controller instance is attempting to fetch its length.
@property isUpdating
@type {Boolean}
@default false
###
# TODO: expand to become true when any data being fetched.
isUpdating: Ember.computed ->
!!(get(@, 'isRequestingLength'))
.property('isRequestingLength')
init: ->
@_TMP_OBJECT = isSparseArrayItem: true, isStale: true
@_TMP_PROVIDE_ARRAY = []
@_TMP_PROVIDE_RANGE = length: 1
@_TMP_RANGE = {}
@_super()
###
Return the content in array format.
@method toArray
@return {Array}
###
toArray: ->
sparseContent = get(@, 'sparseContent')
return Ember.A() unless sparseContent
sparseContent.toArray()
###
Check the content to see if a valid, non-stale object is available at the
provided index.
@method isObjectAt
@param {Integer} idx The index to check for object existence
@return {Boolean}
###
isObjectAt: (idx) ->
result = @objectAt(idx, true)
!!(result and result.isStale isnt true)
###
Get the data from the specified index.
If an object is found at a given index, it will be returned immediately.
Otherwise, a "stale" placeholder object will be returned and a new remote
query to fetch the data for the given index will be created.
@method objectAt
@param {Integer} idx The index to obtain content for
@param {Boolean} dontFetch Won't obtain remote data if `true`
@return {Object}
###
objectAt: (idx, dontFetch) ->
idx = parseInt idx, 10
return undefined if (isNaN(idx) or (idx < 0) or (idx >= get(@, 'length')))
result = @_super(idx) ? @insertSparseArrayItem(idx)
return result if (result and result.isStale isnt true)
@requestObjectAt(idx, dontFetch)
###
Fetches data at the specified index. If `rangeSize` is greater than 1, this
method will also retrieve adjacent items to form a "page" of results.
@method requestObjectAt
@param {Integer} idx The index to fetch content for
@param {Boolean} dontFetch Won't obtain remote data if `true`
@return {Object|Null} A placeholder object or null if content is empty
###
requestObjectAt: (idx, dontFetch = !get(@, 'shouldRequestObjects')) ->
return (get(@, 'sparseContent')[idx] ? @insertSparseArrayItem(idx)) if dontFetch
content = get(@, 'content')
rangeSize = parseInt(get(@, 'rangeSize'), 10) || 1
return null unless content?
start = Math.floor(idx / rangeSize) * rangeSize
start = Math.max start, 0
placeholders = Math.min((start + rangeSize), get(@, 'length'))
@insertSparseArrayItems([start...placeholders])
if @didRequestRange isnt Ember.K
range = @_TMP_RANGE
range.start = start
range.length = rangeSize
@_didRequestRange(range)
else
@_didRequestIndex(i) for i in [start...rangeSize]
get(@, 'sparseContent')[idx]
###
Fetches data regarding the total number of objects in the
persistence layer.
@method requestLength
@return {Integer} The current known length
###
requestLength: ->
len = get(@, '_length')
unless (@didRequestLength is Ember.K) or get(@, 'isRequestingLength')
set @, 'isRequestingLength', true
@_didRequestLength()
return len
get(@, '_content.length')
###
Empty the sparse array.
@method reset
@chainable
###
reset: ->
@beginPropertyChanges()
len = get(@, '_length')
@_clearSparseContent()
set(@, '_length', len)
@endPropertyChanges()
@
###
Uncache the item at the specified index.
@method unset
@param {Integer} idx The index to unset
@chainable
###
unset: (idx) ->
return @ unless idx?
sparseContent = get(@, 'sparseContent')
sparseContent[idx] = undefined
@
###
Remove the item at the specified index.
@method removeObject
@param {Mixed} obj The object to remove from the content
@chainable
###
removeObject: (obj) ->
# Ember's standard `removeObject` method will try to fetch all available
# data when attempting to remove an object. Disable data fetching to
# prevent excessive (and slow) remote queries
shouldRequestObjects = get @, 'shouldRequestObjects'
@disableRequests()
@_super obj
@enableRequests() if shouldRequestObjects
@
###
Enable data fetching.
@method enableRequests
@chainable
###
enableRequests: ->
set(@, 'shouldRequestObjects', true)
@
###
Disable data fetching.
@method disableRequests
@chainable
###
disableRequests: ->
set(@, 'shouldRequestObjects', false)
@
#INJECT PLACEHOLDER OBJECTS
###
Insert a placeholder object at the specified index.
@method insertSparseArrayItem
@param {Integer} idx Where to inject a placeholder
@param {Boolean} force If true, placeholder replaces existing content
@return {Object}
###
insertSparseArrayItem: (idx, force = false) ->
sparseContent = get(@, 'sparseContent')
proxy = Ember.copy(@_TMP_OBJECT)
proxy.contentIndex = idx
sparseContent[idx] = proxy if force or !sparseContent[idx]?
sparseContent[idx]
###
Insert placeholder objects at the specified indexes.
@method insertSparseArrayItems
@param {Integer|Array} idx Multiple indexes
@chainable
###
insertSparseArrayItems: (idx...) ->
@insertSparseArrayItem(i) for i in [].concat.apply([], idx)
@
# CALLBACK METHODS FOR LOADING FETCHED DATA
###
Async callback to provide total number of objects available to this
controller stored in the persistence layer.
@method provideLength
@param {Integer} length The total number of available objects
@chainable
###
provideLength: (length) ->
set @, '_length', length
set @, 'isRequestingLength', false
@
###
Async callback to provide objects in a specific range.
@method provideObjectsInRange
@param {Object} [range] A range object
@param {Integer} [range.start]
The index at which objects should be inserted into the content array
@param {Integer} [range.length]
The number of items to replace with the updated data
@param {Array} array The data to inject into the sparse array
@chainable
###
provideObjectsInRange: (range, array) ->
sparseContent = get(@, 'sparseContent')
sparseContent.replace(range.start, range.length, array)
@
###
Async callback to provide an object at a specific index.
Ultimately, this method calls `provideObjectsInRange`. Override
`provideObjectsInRange` to inject custom behavior.
@method provideObjectAtIndex
@param {Integer} idx The index to insert data at
@param {Object} obj The object to insert
@chainable
###
provideObjectAtIndex: (idx, obj) ->
array = @_TMP_PROVIDE_ARRAY
range = @_TMP_PROVIDE_RANGE
array[0] = obj
range.start = idx
@provideObjectsInRange(range, array)
# OVERRIDE ARRAY PROXY METHODS FOR CONTENT
###
Hook for responding to impending updates to the content array. Override to
add custom handling for array updates.
@method contentArrayWillChange
@param {Array} array The array instance being updated
@param {Integer} idx The index where changes applied
@param {Integer} removedCount
@param {Integer} addedCount
###
contentArrayWillChange: (array, idx, removedCount, addedCount) ->
@
###
Hook for responding to updates to the content array. Override to
add custom handling for array updates.
@method contentArrayWillChange
@param {Array} array The array instance being updated
@param {Integer} idx The index where changes applied
@param {Integer} removedCount
@param {Integer} addedCount
###
contentArrayDidChange: (array, idx, removedCount, addedCount) ->
@
###
@private
Override Ember's `_contentWillChange` to observe `_content`.
@method _contentWillChange
###
_contentWillChange: Ember.beforeObserver ->
@_super()
, '_content'
###
@private
Override Ember's `_contentDidChange` to observe `_content` and `content`.
@method _contentDidChange
###
_contentDidChange: Ember.observer ->
@_super()
, 'content', '_content'
###
@private
Move any array set to the `content` property to the `_content` property.
This allows `content` to be used for referencing the sparse array while
retaining a reference to the originally provided content object.
@method _setupContent
@return {Array} The sparse array
###
_setupContent: ->
controller = @
_content = get(controller, 'content')
return if _content and _content.isSparseArray
if _content
_content.addArrayObserver(controller,
willChange: "contentArrayWillChange"
didChange: "contentArrayDidChange"
)
sparseContent = Ember.A((_content and _content.slice()) ? [])
sparseContent.isSparseArray = true
set controller, '_content', _content
set controller, 'sparseContent', sparseContent
sparseContent
###
@private
Remove observers from `_content`.
@method _teardownContent
@return null
###
_teardownContent: ->
controller = @
_content = get(controller, '_content')
if _content
_content.removeArrayObserver(controller,
willChange: "contentArrayWillChange"
didChange: "contentArrayDidChange"
)
null
###
@private
Set reported length to `content.total` if it changes.
@method _contentTotalChanged
@chainable
###
_contentTotalChanged: Ember.observer ->
set @, '_length', get(@, 'content.total')
@
, 'content.total'
# SPARSE CONTENT SETUP/EVENTS
###
Hook for responding to the sparse array being replaced with a new
array instance. Override to add custom handling.
@method sparseContentWillChange
@param {Object} self
###
sparseContentWillChange: Ember.K
###
Hook for responding to the sparse array being replaced with a new
array instance. Override to add custom handling.
@method sparseContentDidChange
@param {Object} self
###
sparseContentDidChange: Ember.K
###
Hook for injecting custom behavior when an item in the sparse array gets
replaced with new data.
@method sparseContentDidChange
@param {Object} item The previous value
@param {Object} addedObject The new value
###
didReplaceSparseArrayItem: Ember.K
###
Hook for responding to impending updates to the sparse array. Extend to
add custom handling for array updates.
@method sparseContentArrayWillChange
@param {Array} array The array instance being updated
@param {Integer} idx The index where changes applied
@param {Integer} removedCount
@param {Integer} addedCount
###
sparseContentArrayWillChange: (array, idx, removedCount, addedCount) ->
@_PREVIOUS_SPARSE_CONTENT = array.slice(idx, idx + removedCount)
@
###
Hook for responding to updates to the sparse array. Extend to
add custom handling for array updates.
@method sparseContentArrayDidChange
@param {Array} array The array instance being updated
@param {Integer} idx The index where changes applied
@param {Integer} removedCount
@param {Integer} addedCount
###
sparseContentArrayDidChange: (array, idx, removedCount, addedCount) ->
removedObjects = @_PREVIOUS_SPARSE_CONTENT ? Ember.A()
addedObjects = array.slice(idx, idx + addedCount)
# Calculate delta with length properties of actual arrays
# More accurate than using addedCount and removedCount
delta = (addedObjects?.length || 0) - (removedObjects?.length || 0)
set(@, '_length', get(@, '_length') + delta)
for item, i in removedObjects
@didReplaceSparseArrayItem(item, addedObjects[i]) if item and item.isSparseArrayItem
@_PREVIOUS_SPARSE_CONTENT = null
@
###
@private
Sparse array change handler.
@method _sparseContentWillChange
###
_sparseContentWillChange: Ember.beforeObserver ->
sparseContent = get(@, 'sparseContent')
len = if sparseContent then get(sparseContent, 'length') else 0
@sparseContentArrayWillChange @, 0, len, undefined
@sparseContentWillChange @
@_teardownSparseContent sparseContent
, 'sparseContent'
###
@private
Sparse array change handler.
@method _sparseContentDidChange
###
_sparseContentDidChange: Ember.observer ->
sparseContent = get(@, 'sparseContent')
len = if sparseContent then get(sparseContent, 'length') else 0
@_setupSparseContent sparseContent
@sparseContentDidChange @
@sparseContentArrayDidChange @, 0, undefined, len
, 'sparseContent'
###
@private
Remove change observing on sparse array.
@method _teardownSparseContent
###
_teardownSparseContent: ->
@_clearSparseContent()
sparseContent = get(@, 'sparseContent')
if sparseContent
sparseContent.removeArrayObserver @,
willChange: 'sparseContentArrayWillChange',
didChange: 'sparseContentArrayDidChange'
###
@private
Add change observing on sparse array.
@method _setupSparseContent
###
_setupSparseContent: ->
sparseContent = get(@, 'sparseContent')
if sparseContent
sparseContent.addArrayObserver @,
willChange: 'sparseContentArrayWillChange',
didChange: 'sparseContentArrayDidChange'
@_lengthDidChange()
###
@private
Set the sparse array's length to the controller's length.
@method _lengthDidChange
###
_lengthDidChange: Ember.observer ->
length = get(@, 'length') ? 0
sparseContent = get(@, 'sparseContent')
sparseContent.length = length if Ember.isArray(sparseContent) and sparseContent.isSparseArray and sparseContent.length isnt length
, 'length'
###
@private
Empty the sparse array.
@method _clearSparseContent
###
_clearSparseContent: ->
sparseContent = get(@, 'sparseContent')
sparseContent.clear() if sparseContent and sparseContent.isSparseArray
@
###
Called before controller destruction.
@method willDestroy
###
willDestroy: ->
@_super()
@_teardownSparseContent()
# DATA FETCHING
###
Hook for single object requests. Override this method to enable this
controller to obtain a single persisted object.
If the request is successful, insert the fetched object into the sparse
array using the `provideObjectAtIndex` method.
@method didRequestIndex
@param {Integer} idx
###
didRequestIndex: Ember.K
###
Hook for range requests. Override this method to enable this controller
to obtain a page of persisted data.
If the request is successful, insert the fetched objects into the sparse
array using the `provideObjectsInRange` method.
@method didRequestRange
@param {Object} [range] A range object
@param {Integer} [range.start]
The index to fetch
@param {Integer} [range.length]
The number of items to fetch
###
didRequestRange: Ember.K
###
Hook for initiating requests for the total number of objects available to
this controller in the persistence layer. Override this method to enable
this controller to obtain its length.
If the request is successful, set the length of this sparse array
controller using the `provideLength` method.
@method didRequestLength
###
didRequestLength: Ember.K
###
@private
Prevents the controller from continuously attempting to fetch data for
objects that are already in the process of being fetched.
@method _markSparseArrayItemInProgress
@param {Integer} idx The index of the object to place into a loading state
###
_markSparseArrayItemInProgress: (idx) ->
sparseContent = get(@, 'sparseContent')
return unless sparseContent and Ember.typeOf sparseContent is 'array'
item = sparseContent[idx]
set(item, 'isStale', false) if item and get(item, 'isStale')
item
###
@private
Prepare to fetch a page of data from the persistence layer.
@method _didRequestRange
@param {Object} [range] A range object
@param {Integer} [range.start]
The index to fetch
@param {Integer} [range.length]
The number of items to fetch
###
_didRequestRange: (range) ->
@_markSparseArrayItemInProgress(idx) for idx in [range.start...(range.start + range.length)]
@didRequestRange(range)
###
@private
Prepare to fetch a single object from the persistence layer.
@method _didRequestIndex
@param {Integer} idx
###
_didRequestIndex: (idx) ->
@_markSparseArrayItemInProgress(idx)
@didRequestIndex(idx)
###
@private
Prepare to fetch the total number of available objects from the
persistence layer.
@method _didRequestLength
###
_didRequestLength: ->
@didRequestLength()
`export default EmberellaSparseArray`