Client Side Scoping

By default scopes will be recalculated on the server. For simple scopes that do not use joins or includes no additional action needs to be taken to make scopes work with Hyperloop. For scopes that do use joins, or if you want to offload the scoping computation from the server to the client read this section.

ActiveRecord Scope Enhancement

When the client receives notification that a record has changed Hyperloop finds the set of currently rendered scopes that might be effected, and requests them to be updated from the server.

On the server scopes are a useful way to structure code. On the client scopes are vital as they limit the amount of data loaded, viewed, and updated in the browser. Consider a factory floor management system that shows job state as work flows through the factory. There may be millions of jobs that a production floor browser is authorized to view, but at any time there are probably only 50 being shown. Using ActiveRecord scopes is the way Hyperloop keeps the data requested by the browser limited to a reasonable amount.

To make scopes work efficiently on the client Hyperloop adds some features to the ActiveRecord scope and default_scope macros. Note you must use the scope macro (and not class methods) for things to work with Hyperloop.

The additional features are accessed via the :joins, :client, and :select options.

The :joins option tells the Hyperloop client which models are joined with the scope. You must add a :joins option if the scope has any data base join operations in it, otherwise if a joined model changes, Hyperloop will not know to update the scope.

The :client and :select options provide the client a way to update scopes without having to contact the server. Unlike the :joins option this is an optimization and is not required for scopes to work.

class Todo < ActiveRecord::Base

  # Standard ActiveRecord form:
  # the proc will be evaluated as normal on the server, and as needed updates
  # will be requested from the clients

  scope :active, -> () { where(completed: true) }

  # In the simple form the scope will be reevaluated if the model that is
  # being scoped changes, and if the scope is currently being used to render data.

  # If the scope joins with other data you will need to specify this by
  # passing a relationship or array of relationships to the `joins` option.

  scope :with_recent_comments,
        -> { joins(:comments).where('comment.created_at >= ?', Time.now-1.week) },
        joins: ['comments'] # or joins: 'comments'

  # Now with_recent_comments will be re-evaluated whenever a Todo record, or a Comment
  # joined with a Todo change.

  # Normally whenever Hyperloop detects that a scope may be effected by a changed
  # model, it will request the scope be re-evaluated on the server.  To offload this
  # computation to the client provide a client side scope method:

  scope :with_recent_comments,
        -> { joins(:comments).where('comment.created_at >= ?', Time.now-1.week) },
        joins: ['comments']
        client: -> { comments.detect { |comment| comment.created_at >= Time.now-1.week }

  # The client proc is executed on each candidate record, and if it returns true the record
  # will be added to the scope.

  # Instead of a client proc you can provide a select proc, which will receive the entire
  # collection which can then be filtered and sorted.

  scope :sort_by_created_at,
        -> { order('created_at DESC') }
        select: -> { sort { |a, b| b.created_at <=> a.created_at }}

  # To keep things tidy you can specify the server scope proc with the :server option

  scope :completed,
        server: -> { where(complete: true) }
        client: -> { complete }

  # The expressions in the joins array can be arbitrary sequences of relationships and
  # scopes such as 'comments.author'.  

  scope :with_managers_comments,
        server: -> { ... }
        joins: ['comments.author', 'owner']
        client: -> { comments.detect { |comment| comment.author == owner.manager }}}

  # You can also use the client, select, server, and joins option with the default_scope macro

  default_scope server: -> { where(deleted: false).order('updated_at DESC') }
                select: -> { select { |r| !r.deleted }.sort { |a, b| b <=> a } }

  # NOTE: it is highly recommend to provide a client proc with default_scopes.  Otherwise
  # every change is going to require a server interaction regardless of what other client procs
  # you provide.

end

How it works

Consider this scope on the Todo model

scope :with_managers_comments,
      server: -> { joins(owner: :manager, comments: :author).where('managers_users.id = authors_comments.id').distinct },
      client: -> { comments.detect { |comment| comment.author == owner.manager }}
      joins: ['comments.author', 'owner']

The joins 'comments.author' relationship is inverted so that we have User 'hasmany' Comments which 'belongsto' Todos.

Thus we now know that whenever a User or a Comment changes this may effect our withmanagerscomments scope

Likewise 'owner' becomes User 'has_many' Todos.

Lets say that a user changes teams and now has a new manager. This means according to the relationships that the User model will change (i.e. there will be a new manager_id in the User model) and thus all Todos belonging to that User are subject to evaluation.

While the server side proc efficiently delivers all the objects in the scope, the client side proc just needs to incrementally update the scope.