Project

synced

0.01
Low commit activity in last 3 years
There's a lot of open issues
No release in over a year
Keep your BookingSync Application synced with BookingSync.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

Runtime

 Project Readme

©Code Climate Build Status

Synced

Synced is a Rails Engine that helps you keep local models synchronized with their BookingSync representation.

It decreases time needed to fetch data from BookingSync API. If given endpoint supports updated_since parameter, Synced will first perform a full synchronization and then every next synchronization will only synchronize added/changed/deleted objects since last synchronization.

Requirements

This engine requires Rails >= 6.0.0 and Ruby >= 2.7.0.

Installation

To get started, add it to your Gemfile with:

gem 'synced'

and run bundle install

Basic usage

Assume we want to create an application displaying rentals from multiple BookingSync accounts and we want to synchronize rentals to make it snappy and traffic efficient.

We will surely have Rental and Account models. Where Account will have BookingSync::Engine::Account mixin and thus respond to api method.

First generate a migration to add synced fields to the model. These fields will be used for storing data from the API.

Example:

rails g migration AddSyncedFieldsToRentals synced_id:integer:index \
  synced_data:text synced_all_at:datetime

and migrate:

rake db:migrate

Add synced statement to the model you want to keep in sync and add api method which return instance of BookingSync::API::Client used for fetching data.

class Rental < ActiveRecord::Base
  synced
  belongs_to :account
end

Example:

Synchronize rentals for given account.

Rental.synchronize(scope: account)

Now rentals details fetched from the API are accessible through synced_data method.

rental = account.rentals.first
rental.synced_data.bedrooms # => 4
rental.synced_data.rental_type # => "villa"

Fetching data

You can choose between two ways of fetching remote data:

  1. auto_paginate - default strategy, which fetches and persists all data at once. This strategy ensures that resources are fetched as quickly as possible, therefore minimizing risk of data changes during request. However, this way may become cumbersome when working with large data-sets (high memory usage).
  2. pagination with block - fetches and persists records in batches. Especially helpful, when dealing with large data-sets. Increases overall syncing time, but significantly reduces memory usage. Number of entries per page can be customized by :per_page attribute inside :query_params

In order to switch to pagination with block, you just need to use auto_paginate: false:

class Rental
  synced auto_paginate: false, query_params: { per_page: 50 }
end

Persisted objects

There is another major difference between auto_paginate and pagination with block - when using auto_paginate, .synchronize returns collection of all persisted records. On the other hand, pagination with block returns last batch of fetched resources. If you need to process persisted data we encourage you to use handle_processed_objects_proc. This proc takes one argument (persisted records) and is called after persisting each batch of remote objects. So when using auto_paginate, this:

class Rental
  synced handle_processed_objects_proc: Proc.new { |persisted_rentals|
      persisted_rentals.each { |rental| rental.do_stuff }
    }
end

would be an equivalent of overriding .synchronize:

class Rental
  synced

  def self.synchronize(options)
    super.tap do |persisted_rentals|
      persisted_rentals.each { |rental| rental.do_stuff }
    end
  end
end

When using pagination with block only the former will work.

Synced strategies

There are currently 3 synced strategies: :full, :updated_since and :check.

  • :full strategy fetches all available data each time, being simple but very inefficient in most cases.
  • :updated_since is default strategy and syncs only changes since last sync. It's more efficient, but also more complex.
  • :check strategy fetches everything like full one, but only compares the datas without updating anything.

Synced database fields

Option name Default value Description Required
:id_key :synced_id ID of the object fetched from the API YES
:data_key :synced_data Stores data fetched from the API NO
:synced_all_at_key :synced_all_at Stores time of the last synchronization NO

Custom fields name can be configured in the synced statement of your model:

class Rental < ActiveRecord::Base
  synced id_key: :remote_id, data_key: :remote_data
end

Local attributes

Whole remote data is stored in synced_data column, however sometimes it's useful (for example for sorting) to have some attributes directly in your model. You can use local_attributes to achieve it:

class Rental < ActiveRecord::Base
  synced local_attributes: [:name, :size]
end

This assumes that model has name and size attributes. On every synchronization these two attributes will be assigned with value of remote_object.name and remote_object.size appropriately.

Local attributes with custom names

If you want to store attributes from remote object under different name, you need to pass your own mapping hash to synced statement. Keys are local attributes and values are remote ones. See below example:

class Rental < ActiveRecord::Base
  synced local_attributes: { headline: :name, remote_size: :size }
end

During synchronization to local attribute headline will be assigned value of name attribute of the remote object and to the local remote_size attribute will be assigned value of size attribute of the remote object.

Local attributes with mapping blocks

If you want to convert attribute's value during synchronization you can pass a block as value in the mapping hash. Block will receive remote object as the only argument.

class Rental < ActiveRecord::Base
  synced local_attributes: { headline: ->(rental) { rental.headline.downcase } }
end

Local attributes with mapping modules

Converting remote object's values with blocks is really easy, but when you get more attributes and longer code in the blocks they might become quite complex and hard to read. In such cases you can use a mapper module. Remote object will be extended with it.

class Rental < ActiveRecord::Base
  module Mapper
    def downcased_headline
      headline.downcase
    end
  end
  synced mapper: Mapper, local_attributes: { headline: :downcased_headline }
end

If you want to define Mapper module after the synced directive, you need to pass Mapper module inside a block to avoid "uninitialized constant" exception.

class Rental < ActiveRecord::Base
  synced mapper: -> { Mapper },
    local_attributes: { headline: :downcased_headline }
  module Mapper
  end
end

Globalized attributes

Some of the API endpoints return strings in multiple languages. When your app is also multilingual you might want take advantage of it and import translations straight to model translations.

In order to import translations use :globalized_attributes attribute. It assumes that your app is using Globalize 3 or newer and :headline is already a translated attribute.

class Rental < ActiveRecord::Base
  synced globalized_attributes: :headline
  translates :headline
end

Now headline will be saved for all translations provided by the API. If given translation will be removed on the API side, it will set to nil locally.

If you want to map remote field to a different local attribute, specify mapping as a Hash instead of an Array.

class Rental < ActiveRecord::Base
  synced globalized_attributes: {headline: :description}
  translates :headline
end

This will map remote :description to local :headline attribute.

Partial updates (using :updated_since strategy)

Partial updates mean that first synchronization will copy all of the remote objects into local database and next synchronizations will sync only added/changed and removed objects. This significantly improves synchronization time and saves network traffic.

NOTE: In order it to work, given endpoint needs to support updated_since parameter. Check API documentation for given endpoint.

Storing last sync timestamps

When using :updated_since sync strategy you need to store the timestamp of the last sync somewhere. By default Synced::Strategies::SyncedAllAtTimestampStrategy strategy is used, which requires synced_all_at column to be present in the synced model. This is simple solution but on large syncs it causes serious overhead on updating the timestamps on all the records.

There is also a Synced::Strategies::SyncedPerScopeTimestampStrategy, that uses another model, Synced::Timestamp, to store the synchronization timestamps. You can generate the migration the following way:

rails generate migration create_synced_timestamps

and copy the body from here:

class CreateSyncedTimestamps < ActiveRecord::Migration
  def change
    create_table :synced_timestamps do |t|
      t.string :parent_scope_type
      t.integer :parent_scope_id
      t.string :model_class, null: false
      t.datetime :synced_at, null: false
    end

    add_index :synced_timestamps, [:parent_scope_id, :parent_scope_type, :model_class, :synced_at], name: "synced_timestamps_max_index", order: { synced_at: "DESC" }
  end
end

This strategy is added to fix the problems with massive updates on synced_all_at. Proper cleanup of timestamp records is needed once in a while with Synced::Timestamp.cleanup (cleans records older than 1 week).

Syncing with offset

Sometimes (mostly for pricing related endpoints like LOS Records or Rates) sync request is made during transaction. That can make your local database out of sync, usually due to older records not being removed. For such cases there is tolerance option which will reduce updated_since value by specified amount of seconds. That way, if your last request has been made during transaction, everything will heal itself during next sync.

For example:

class Rental < ActiveRecord::Base
  synced tolerance: 60
end

Will always reduce updated_since param by 60 seconds. Setting this value too high can cause re-fetching same changes multiple times, which may exhaust rate limit of your application much faster and increase overall sync time.

Forcing local objects to be re-synced with the API

When you add a new column or change something in the synced attributes and you are using partial updates, old local objects will not be re-synced with API automatically. You need to reset synced_all_at column in order to force re-syncing objects again on the next synchronization. In order to do that use reset_synced method.

Rental.reset_synced

You can use this method on a relation as well.

account.rentals.reset_synced

Disabling saving whole synced_data

If you don't need whole remote object to be stored in local object skip creating synced_data column in the database or set synced_data_key: nil.

If you don't want to synchronize only added/changed or deleted objects but all objects every time, don't create synced_all_at column in the database or set synced_all_at: false in the synced statement.

You cannot disable synchronizing synced_id as it's required to match local objects with the remote ones.

Associations

It's possible to synchronize objects together with it's associations. Meaning local associated objects will be created. For that you need to:

  1. Specify associations you want to synchronize within synced declaration of the parent model
  2. Add synced declaration to the associated model
class Location < ActiveRecord::Base
  synced associations: :photos
  has_many :photos
end

class Photo < ActiveRecord::Base
  synced
  belongs_to :location
end

Then run synchronization of the parent objects. Every of the remote_locations objects needs to respond to remote_location[:photos] from where data for photos association will be taken.

Location.synchronize

NOTE: It assumes that local association photos exists in Location model.

Including associations in synced_data

When you need associated data available in the local object, but you don't need it to be a local association, you can use include: option in model or synchronize method.

class Location < ActiveRecord::Base
  synced include: :photos
end

Location.first.synced_data.photos # => [{id: 1}, {id: 2}]

You can also specify include: option in synchronize method. In this case it will overwrite include: from the model.

Location.synchronize(include: :addresses)

Synchronization of given remote objects

By default synced will fetch remote objects using BookingSync::API::Client but in some cases you might want to provide own list of remote objects to synchronize. In order to do that provide them as remote: option to synchronize method.

Location.synchronize(remote: remote_locations)

NOTE: Partial updates are disabled when providing remote objects. WARNING: When using remove: true with remote, remember that synced will remove ALL records that are not passed to remote.

Removing local objects

By default synchronization will not delete any local objects which are removed on the API side. In order to remove local objects removed on the API, specify remove: true in the model or as an option to synchronize method.

class Photo < ActiveRecord::Base
  synced remove: true
end

Option remove: passed to Photo.synchronize method will overwrite configuration in the model.

For objects which need to be removed :destroy_all is called. If model has canceled_at column, local objects will be canceled with :cancel_all class method. You can force your own class method to be called on the local objects which should be removed by passing it as an symbol.

class Photo < ActiveRecord::Base
  synced remove: :mark_as_outdated

  def self.mark_as_outdated
    all.update_attributes(outdated: true)
  end
end

Selecting fields to be synchronized

Very often you don't need whole object to be fetched and stored in local database but only several fields. You can specify which fields should be fetched and stored with fields: option.

class Photo < ActiveRecord::Base
  synced fields: [:name, :url]
end

This can be overwritten in synchronize method.

Photo.synchronize(fields: [:name, :size])

Delegate attributes

You can delegate attributes from your synced model to synced_data Hash for easier access to synchronized data.

class Photo < ActiveRecord::Base
  synced delegate_attributes: [:name]
end

Now you can fetch photo name using:

@photo.name #=> "Sunny morning"

If you want to access synced attribute with different name, you can pass a Hash:

class Photo < ActiveRecord::Base
  synced delegate_attributes: {title: :name}
end

keys are delegated attributes' names and values are keys on synced data Hash. This is a simpler version of delegate :name, to: :synced_data which works with Hash reserved attributes names, like :zip, :map.

Persisting fetched objects

By default all fetched objects are persisted inside one big transaction. You can customize this behaviour by providing transaction_per_page option either in model configuration:

class Photo < ActiveRecord::Base
  synced transaction_per_page: true
end

Or as a param in synchronize method:

Photo.synchronize(transaction_per_page: true)

Model Aliasing

By default synced gem will look for an endpoint in BookingSync with the same name as it was defined in. If you want to name your class differently or place it in a namespace, provide endpoint of resource that you want to access with the endpoint option. Like so: synced endpoint: :bookings.

Synced configuration options

Option name Default value Description synced synchronize
:id_key :synced_id ID of the object fetched from the API YES NO
:data_key :synced_data Object fetched from the API YES NO
:associations [] Sync remote associations to local ones YES NO
:local_attributes [] Sync remote attributes to local ones YES NO
:mapper nil Module used for mapping remote objects YES NO
:remove false If local objects should be removed when deleted on API YES YES
:include [] An array of associations to be fetched YES YES
:fields [] An array of fields to be fetched YES YES
:remote nil Remote objects to be synchronized with local ones NO YES
:delegate_attributes [] Define delegators to synced data Hash attributes YES NO
:auto_paginate true Whether data should be fetched in batches or as one response YES YES
:transaction_per_page false Whether transaction should be per page of fetched objects or for all the pages - note that setting this value to true will mean always fetching data in batches, even when specifying auto_paginate as true YES YES
:handle_processed_objects_proc nil Custom proc taking persisted remote objects, called after persisting batch of data YES NO
:tolerance 0 How many seconds updated_since param should be reduced during request YES NO
:endpoint table name of the class it was defined in endpoint for synchronizeable resource on BookingSync YES NO

Documentation

API documentation is available at rdoc.info.