active-sync 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 05b5eaee6042b6879380d62ddb365e2437dab84c
4
- data.tar.gz: 7042c77f823b44739262be1e605efb38050b3fb0
2
+ SHA256:
3
+ metadata.gz: 7df1bee7e9b73969455663f528ffe6c0e6bdcf29c51da5fcfe3198435e9c90aa
4
+ data.tar.gz: d1e8df90405eab22953da90829644acac6c7f3e3003df836d8e32a12f7fb43cf
5
5
  SHA512:
6
- metadata.gz: 34e3e184be13d599ce39678b7ed1f10cadf85b75a19e4a0c0a33d5b2e1bdbe6bd4625cc2eb19800735a24c4915b8582d042a48b41c00cf0ae56b3e111e3b0873
7
- data.tar.gz: e091c678b10a1a8301042b8182a5fa351f57c798f746fc1a18f1810e1b02201f27053855ddbca7ff3377117882ecfcfaf6afc74622a7f0d2d8a0b6163c3f6c07
6
+ metadata.gz: c31d625e52ef4596916acaa468916c7ea767194a77049026ae1ebfc539ae7c2ff5e5ab05a241cb8ae643c9d376b2da49da5a389780c8ab491379d51d1d6f9609
7
+ data.tar.gz: 072dcb445cb5288c65cf073bb9dae6a152f393fba25e69031c0e8b7e144468bba4f612e5f2e038c27d5d1318856f4e263481eae2bafe0b0204f25daffaa721b9
data/README.md CHANGED
@@ -1,10 +1,20 @@
1
1
  # ActiveSync
2
- Short description and motivation.
2
+ Dynamically created and updated front end models.
3
+
4
+ Active record adds an active record interface to your front end. Configured models are available
5
+ within your Javascript application with many active record type functions such as 'where'.
6
+
7
+ Records are lazy loaded and then dynamically updated through actioncable. So any records looked up
8
+ through ActiveSync will be dynamically updated whether updated by the current user or any one else.
9
+
10
+ Currently the only Javascript framework supported is Vue
3
11
 
4
12
  ## Usage
5
13
  How to use my plugin.
6
14
 
7
15
  ## Installation
16
+
17
+ ### Install gem
8
18
  Add this line to your application's Gemfile:
9
19
 
10
20
  ```ruby
@@ -16,11 +26,26 @@ And then execute:
16
26
  $ bundle
17
27
  ```
18
28
 
19
- Or install it yourself as:
20
- ```bash
21
- $ gem install active_sync
29
+ ### Import package
30
+ In packs/application.js import active-sync and create an instance with a list of Models that you
31
+ want available.
32
+
33
+ ```javascript
34
+ import ActiveSync from 'active-sync'
35
+
36
+ let activeSync = new ActiveSync({ modelNames: ['Customer', 'Site'] })
37
+ ```
38
+
39
+ ### Vue setup
40
+
41
+ Before creating your Vue instance :
42
+
43
+ ```javascript
44
+ Vue.use( activeSync )
22
45
  ```
23
46
 
47
+ Then any new Vue instances will have Models globally available
48
+
24
49
  ## Contributing
25
50
  Contribution directions go here.
26
51
 
@@ -0,0 +1,27 @@
1
+ # Rails currently doesn't allow namespacing channels in an engine
2
+ # module ActiveSync
3
+ class ModelsChannel < ActionCable::Channel::Base
4
+ # To change the data sent implement sync_record in the respective model
5
+
6
+ def subscribed
7
+ transmit(subscription_model.where(params[:filter]).map(&:sync_record))
8
+ stream_from params[:model], coder: ActiveSupport::JSON do |message|
9
+ if (params[:filter].to_a - message.to_a).blank?
10
+ transmit([message])
11
+ end
12
+ end
13
+ end
14
+
15
+ def unsubscribed
16
+ # Any cleanup needed when channel is unsubscribed
17
+ end
18
+
19
+ private
20
+
21
+ def subscription_model
22
+ model = params[:model].singularize.camelize.constantize
23
+ raise "Model '#{params[:model]}' is not set up for syncing model" unless model.sync_model?
24
+ model
25
+ end
26
+ end
27
+ # end
@@ -1,5 +1,5 @@
1
1
  module ActiveSync
2
2
  class ApplicationController < ActionController::Base
3
- protect_from_forgery with: :exception
3
+ # protect_from_forgery with: :exception
4
4
  end
5
5
  end
@@ -1,10 +1,22 @@
1
1
  module ActiveSync
2
2
  class ModelsController < ApplicationController
3
3
 
4
- def index
4
+ def update
5
+ #TODO some oversite on what can be edited for sync records
6
+ model.find(params[:id]).update(params.permit(model.sync_attributes))
7
+ head :no_content
8
+ end
5
9
 
6
- render json: ActiveSync::Sync.model_descriptions
10
+ def create
11
+ #TODO some oversite on what can be created for sync records
12
+ render json: model.create(params.permit(model.sync_attributes)).id
13
+ end
7
14
 
15
+ private
16
+ def model
17
+ m = params[:model].singularize.camelize.safe_constantize || params[:model].camelize.safe_constantize
18
+ raise "Cannot edit #{params[:model]} as it is not a sync model" unless m.sync_model?
19
+ m
8
20
  end
9
21
  end
10
22
  end
@@ -0,0 +1,9 @@
1
+ class BroadcastChangeJob < ApplicationJob
2
+ queue_as :active_sync
3
+
4
+ include ActionCable::Channel::Broadcasting
5
+
6
+ def perform record
7
+ ActionCable.server.broadcast(record.class.name, record.sync_record)
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveSync
2
+ class Broadcaster
3
+ include 'singleton'
4
+ end
5
+ end
@@ -0,0 +1,63 @@
1
+ module ActiveSync
2
+ module Model
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ # after_update :sync_update
7
+ after_commit :sync_change
8
+ end
9
+
10
+ def sync_change
11
+ if sync_model?
12
+ BroadcastChangeJob.perform_later(self)
13
+ end
14
+ end
15
+
16
+ def sync_model?
17
+ true
18
+ end
19
+
20
+
21
+ class_methods do
22
+
23
+ def sync_model?
24
+ true
25
+ end
26
+
27
+ # #sync sets the #sync_record method that renders the hash to create the JSON object that is broadcast and sets
28
+ # #sync_associations which returns a list of associations that are permitted to be broadcast for this model.
29
+ # define these methods directly in your model if the record sent to the font end needs to be different to what's
30
+ # available with the below configurations
31
+ #
32
+ # This can be passed the following options:
33
+ #
34
+ # Example use in Model:
35
+ # sync :all_attributes, associations: [ :sites ]
36
+
37
+ # ATTRIBUTE OPTIONS
38
+ # Attributes are data that is sent in the actual sync data (this will always include the ID)
39
+
40
+ # :all_attributes - sync data will have all attributes
41
+ # :attributes - an array of symbols that will be called on the record and sent as attributes
42
+
43
+ # ASSOCIATION OPTIONS
44
+ # Associations are lazy loaded, data will not go with the record but if the front end has the association described
45
+ # then records can be subscribed to through the association.
46
+
47
+ # :all_associations - sync data will be associated
48
+ # :associations - an array of symbols
49
+
50
+ def sync *attributes
51
+ self.class.define_method(:sync_attributes) do
52
+ ActiveSync::Sync.sync_attributes(self, attributes)
53
+ end
54
+ define_method(:sync_record) do
55
+ ActiveSync::Sync.sync_record(self, attributes)
56
+ end
57
+ define_method(:sync_associations) do
58
+ ActiveSync::Sync.sync_associations(self, attributes)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,125 +1,45 @@
1
1
  module ActiveSync
2
2
  class Sync
3
3
 
4
- # Describes what of each model should be sent as the sync records,
5
- # this is populated through calls to 'sync' in the model class
6
- @@model_descriptions = {}
7
- @@loaded = false
8
-
9
- def self.model_descriptions
10
- ( Rails.application.eager_load! && @@loaded = true ) unless Rails.application.config.cache_classes || @@loaded
11
- @@model_descriptions
12
- end
13
-
14
- def self.is_sync_model? model
15
-
16
- model_name = model.class == String ? model : model.name
17
-
18
- model_descriptions.keys.include? model_name
19
-
20
- end
21
-
22
-
23
- #Hash used in all general sync communication for a given model.
24
- def self.sync_record record
25
- @@model_descriptions[ record.class.name ][ :attributes ].reduce( {} ) do | hash, attribute |
26
- hash[ attribute ] = record.send( attribute )
27
- hash
28
- end
29
- end
30
-
31
- def self.configure_model_description model, options
32
-
33
- @@model_descriptions[ model.name ] = {
34
- attributes: [],
35
- associations: []
36
- }
37
-
38
- options.each do | option |
39
-
40
- case
41
- when option == :all_attributes_and_associations
42
-
43
- add_attributes_to_description model, model.attribute_names
44
- add_associations_to_description model, model.reflect_on_all_associations.map( &:name )
45
-
46
- when option == :all_attributes
47
-
48
- add_attributes_to_description model, model.attribute_names
49
-
50
- when first_key( option ) == :attributes
51
-
52
- add_attributes_to_description model, option[:attributes].map(&:to_s)
53
-
54
- when option == :all_associations
55
-
56
- add_associations_to_description model, model.reflect_on_all_associations.map( &:name )
57
-
58
- when first_key( option ) == :associations
59
-
60
- add_associations_to_description model, option[ :associations ]
61
-
4
+ def self.sync_attributes model, args
5
+ @@sync_attributes ||= args.reduce([]) do |array, option|
6
+ case option
7
+ when :all_attributes_and_associations, :all_attributes
8
+ array + model.column_names.map(&:to_sym)
9
+ when ->(option){ option.is_a?(Hash) }
10
+ array + option[:attributes]
62
11
  else
63
-
64
- throw "Unknown sync option: #{option}"
65
-
12
+ raise "Unknown sync record option #{option.inspect}"
66
13
  end
67
14
  end
68
15
  end
69
16
 
70
- def self.add_attributes_to_description model, attributes
71
-
72
- attributes.each{ |attribute| @@model_descriptions[ model.name ][ :attributes ] << attribute.to_s }
73
-
74
- end
75
-
76
- def self.add_associations_to_description model, association_names
77
- association_names.each do |association_name|
78
-
79
- association = model.reflect_on_all_associations.find{ |a| a.name == association_name }
80
-
81
- unless association.nil?
82
- begin
83
-
84
- @@model_descriptions[ model.name ][ :associations ] << { name: association.name.to_s, class: association.class_name, type: association.association_class.name }
85
-
86
- rescue NotImplementedError
87
-
88
- @@model_descriptions[ model.name ][ :associations ] << { name: association.name.to_s, class: association.class_name, type: 'ActiveRecord::Associations::HasManyThroughAssociation' }
89
-
90
- end
91
-
17
+ #Hash used in all general sync communication for a given model.
18
+ def self.sync_record record, args
19
+ args.reduce({}) do |hash, option|
20
+ case option
21
+ when :all_attributes_and_associations, :all_attributes
22
+ hash.merge(record.attributes)
23
+ when ->(option){ option.is_a?(Hash) }
24
+ option[:attributes]&.reduce(hash) { |h, attr| h[attr] = record.call(attr) }
92
25
  else
93
-
94
- throw "Association #{ association_name } not found for #{ model.name }"
95
-
26
+ raise "Unknown sync record option #{option.inspect}"
96
27
  end
97
28
  end
98
29
  end
99
30
 
100
- def self.association_record model_association, record
101
- {
102
- IsReference: true,
103
- id: record.id,
104
- model_association[:name] => associated_ids( record, model_association )
105
- }
106
- end
107
-
108
- def self.associated_ids record, model_association
109
- if defined? record.send( model_association[:name] ).pluck
110
-
111
- record.send( model_association[:name] ).pluck(:id)
112
- else
113
- record.send( model_association[:name] ).id
31
+ def self.sync_associations record, args
32
+ args.reduce([]) do |associations, option|
33
+ case option
34
+ when :all_attributes_and_associations, :all_attributes
35
+ associations + record.class.reflect_on_all_associations.map { |a| a.name }
36
+ when ->(option){ option.is_a?(Hash) }
37
+ associations + option[:associations]
38
+ else
39
+ raise "Unknown sync associations option #{option.inspect}"
40
+ end
41
+ associations
114
42
  end
115
43
  end
116
-
117
- def self.get_model_association model, association_name
118
- @@model_descriptions[ model.name ][:associations].find{ |a| a[:name] == association_name }
119
- end
120
-
121
- def self.first_key obj
122
- obj.respond_to?( :keys ) ? obj.keys.first : nil
123
- end
124
44
  end
125
45
  end
data/config/routes.rb CHANGED
@@ -1,3 +1,4 @@
1
1
  ActiveSync::Engine.routes.draw do
2
- resources :models, only: [ :index ]
2
+ put '/:model/:id', to: 'models#update'
3
+ post '/:model', to: 'models#create'
3
4
  end
@@ -7,7 +7,6 @@ module ActiveSync
7
7
  mount ActiveSync::Engine, at: "/active_sync"
8
8
  end
9
9
 
10
- ActiveRecord::Base.class_eval { include ActiveSync::ActiveRecordExtension }
11
10
  end
12
11
  end
13
12
  end
@@ -1,3 +1,3 @@
1
1
  module ActiveSync
2
- VERSION = '0.1.1'
2
+ VERSION = '0.2.0'
3
3
  end
@@ -1,68 +1,95 @@
1
- import ActionCable from 'actioncable'
2
1
  import Model from './model.js'
2
+ import CamelCase from 'camelcase'
3
+ import Pluralize from 'pluralize'
4
+ import SnakeCase from "snake-case";
3
5
 
4
6
  export default class ActiveSync {
5
- constructor( args ){
6
-
7
- this._cable = ActionCable.createConsumer()
8
- this._models = []
9
- this._customModels = args.customModels || []
10
7
 
11
- this._modelOptions = {
12
- addNewRecord: args.addNewRecord,
13
- afterFind: args.afterFind
14
- }
8
+ static models = []
9
+
10
+ // ActiveSync dynamically creates model classes when a new instance of it is created
11
+ // Valid arguments are:
12
+ // models: Used to define custom model. Pass an array of classes in and they will be set up as ActiveSync models.
13
+ // It's expected that models passed in extend ActiveSync's Model class. They will then receive extra functions
14
+ // as per anything defined in the modelDescriptions argument (such as associations).
15
+ //
16
+ // modelDescriptions: An object that describes all the models for ActiveSync to create. If the model is not defined in
17
+ // the model then an empty model is created. Then all the described associations are added to their
18
+ // respective models.
19
+ // IE { Customer: { hasMany: ['sites']}, Site: { belongsTo: 'customer' }
20
+ // Will add a 'sites' method to the Customer class and a 'customer' method to Site.
21
+ //
15
22
 
16
- var modelDescriptions = this.requestModelDescriptions()
17
-
18
- Object.keys( modelDescriptions ).forEach( ( modelName ) =>{
19
- modelDescriptions[modelName].name = modelName
20
- this.setupModel( modelDescriptions[modelName] )
21
- })
23
+ constructor( args ){
24
+ this._models = args.models || [];
25
+ this.buildModels(args.modelDescriptions)
26
+ }
22
27
 
23
- this._models.forEach( ( model ) => model.setAssociatedModels( this._models))
24
- args.afterSetup( this._models )
28
+ install( vue ){
29
+ this._models.forEach((model)=> vue.prototype["$" + model.className] = model)
30
+ }
25
31
 
32
+ models(){
33
+ return this._models
26
34
  }
27
35
 
28
- static install( Vue, options ){
29
- var rs = new ActiveSync({
30
- addNewRecord: ( records, id, record ) => {
31
- if( records[ id ] ){
32
- Object.keys( record ).forEach( (key) => Vue.set( records[ id ], key, record[key] ) )
33
- } else {
34
- Vue.set( records, id, record )
35
- }
36
- },
36
+ // Creates the models from the modelDescription arg passed in to the constructor
37
+ buildModels(modelDescriptions){
38
+ let modelNames = Object.keys(modelDescriptions || {});
37
39
 
38
- afterSetup: ( models ) => {
39
- models.forEach( ( model ) => {
40
- Vue.prototype[ '$' + model.name ] = model
41
- })
42
- },
40
+ modelNames.forEach((modelName) => {
41
+ if(!this._models.find((model) => model.className === modelName)){
42
+ this._models.push(this.createModel(modelName))
43
+ }
44
+ })
43
45
 
44
- customModels: (options || {}).customModels
46
+ this._models.forEach((model) => {
47
+ this.addBelongsToModel(modelDescriptions[model.className], model)
48
+ this.addHasManyToModel(modelDescriptions[model.className], model)
45
49
  })
46
50
  }
47
51
 
48
- requestModelDescriptions(){
49
- var xmlHttp = new XMLHttpRequest()
50
- xmlHttp.open( "GET", 'active_sync/models', false ) // false for synchronous request
51
- xmlHttp.send( null )
52
- return JSON.parse(xmlHttp.responseText)
52
+ createModel(name){
53
+ let modelClass = Model
54
+ return eval(`(class ${name} extends modelClass { static className = '${name}' })`)
53
55
  }
56
+
57
+ addBelongsToModel(modelDescription, model){
58
+ ((modelDescription || {}).belongsTo || []).forEach((association) => {
59
+ let associatedModel = this._models.find((model) => model.className === association)
60
+ model[association] = function () {
61
+ return associatedModel.find(this[association + '_id'])
62
+ }
63
+ });
64
+ }
65
+
66
+ addHasManyToModel(modelDescription, model){
67
+ ((modelDescription || {}).hasMany || []).forEach((association) => {
68
+ let associatedModel = this._models.find((model) => model.className === CamelCase(Pluralize.singular(association), {pascalCase: true}))
69
+ model.prototype[association] = function () {
70
+ let associationQuery = {}
71
+ associationQuery[SnakeCase(model.className) + '_id'] = this.id
72
+ return associatedModel.where(associationQuery)
73
+ }
74
+ });
75
+ }
76
+ }
54
77
 
55
- setupModel( modelDescription ){
56
- var CustomModel = this._customModels.find(( m ) => m.name == modelDescription.name )
57
-
58
- if( CustomModel ){
59
-
60
- this._models.push( new CustomModel( modelDescription, this._cable, this._modelOptions ) )
61
78
 
62
- } else {
63
79
 
64
- this._models.push( new Model( modelDescription, this._cable, this._modelOptions ) )
80
+ // Code for dynamically requesting Model names and associations.
81
+ //
82
+ // Object.keys( modelDescriptions ).forEach( ( modelName ) =>{
83
+ // modelDescriptions[modelName].name = modelName
84
+ // this.setupModel( modelDescriptions[modelName] )
85
+ // })
65
86
 
66
- }
67
- }
68
- }
87
+ // var modelDescriptions = this.requestModelDescriptions()
88
+ // this._models.forEach( ( model ) => model.setAssociatedModels( this._models))
89
+ // args.afterSetup( this._models )
90
+ // requestModelDescriptions(){
91
+ // var xmlHttp = new XMLHttpRequest()
92
+ // xmlHttp.open( "GET", 'active_sync/models', false ) // false for synchronous request
93
+ // xmlHttp.send( null )
94
+ // return JSON.parse(xmlHttp.responseText)
95
+ // }
@@ -1,63 +1,128 @@
1
- import Records from './records.js'
2
-
3
- export default class Model{
4
- constructor( description, cable, options ){
5
- this._name = description.name
6
- this._afterFind = options.afterFind || (( record ) => {})
7
- this._records = new Records( {
8
- cable: cable,
9
- modelName: description.name,
10
- addNewRecord: options.addNewRecord,
11
- references: new Records( {
12
- cable: cable,
13
- addNewRecord: options.addNewRecord
14
- }),
15
- associations: description.associations
16
- })
17
- }
1
+ import Axios from 'axios'
2
+ import SnakeCase from 'snake-case'
3
+ import CamelCase from 'camelcase'
4
+ import Pluralize from 'pluralize'
18
5
 
19
- setAssociatedModels( models ){
20
- this._records.setAssociatedModels( models )
21
- }
6
+ import Actioncable from "actioncable"
7
+
8
+ import Util from './util'
22
9
 
23
- get name(){
24
- return this._name
10
+ export default class Model {
11
+
12
+ // static records = {}
13
+ static recordsLoaded = false
14
+ static urlPathBase = 'active_sync/'
15
+
16
+ static consumer = Actioncable.createConsumer()
17
+
18
+ constructor(args){
19
+ if(!args.id) throw 'Can not create record without an id'
20
+ if(this.constructor.records[args.id]){
21
+ this.constructor.records[args.id].setProperties(args)
22
+ } else {
23
+ this.setProperties(args)
24
+ this.constructor.records[this.id] = this
25
+ }
25
26
  }
26
27
 
27
- get all() {
28
- var allRecords = []
29
- this._records.loadRecords().then( () => {
30
- this._records.forEach( ( record ) => {
31
- allRecords.push( this._records.getRecord( record.id ) )
32
- })
28
+ setProperties(args){
29
+ Object.keys(args).forEach((property)=>{
30
+ this[property] = args[property]
33
31
  })
34
-
35
- return allRecords
36
32
  }
37
33
 
38
- find( id ){
34
+ static get records(){
35
+ if(!this.recordsObject) this.recordsObject = {}
36
+ return this.recordsObject
37
+ }
39
38
 
40
- if( !this._records.getRecord( id ) ){
39
+ static get all(){
40
+ return this.loadOrSearch()
41
+ }
41
42
 
42
- this._records.push( { id: id } )
43
- this._records.loadRecords( { id: id } )
44
- // .then(()=> this._afterFind( this._records.getRecord( id ) ))
43
+ static modelUrlPath(singular = false){
44
+ if(singular) {
45
+ return this.urlPathBase + SnakeCase(this.className)
46
+ } else {
47
+ return this.urlPathBase + SnakeCase(Pluralize(this.className))
45
48
  }
49
+ }
46
50
 
51
+ static find(id){
52
+ return new Promise((resolve,reject)=>{
53
+ if(!this.records[id]){
54
+ resolve(this.loadRecords({ id: id }).then(()=> this.records[id]))
55
+ } else {
56
+ resolve(this.records[id])
57
+ }
58
+ })
59
+ }
60
+
61
+ static where(args){
62
+ return this.loadOrSearch(Util.snakeCaseKeys(args))
63
+ }
47
64
 
48
- return this._records.getRecord( id )
65
+ static through(model, args){
66
+ return model.loadRecords(args).then(()=>{
67
+ var linkingIds = [...new Set(model.searchRecords(args).map((record)=> record[CamelCase(this.className)+'Id']))]
68
+ return this.where({id: linkingIds})
69
+ })
49
70
  }
50
71
 
51
- where( properties ){
72
+ static create(data){
73
+ return Axios.post(this.modelUrlPath(), Util.snakeCaseKeys(data))
74
+ .then((response) => {
75
+ new this(response.data)
76
+ return response
77
+ })
78
+ }
52
79
 
53
- var records = []
80
+ static update(data){
81
+ return Axios.put( `${this.modelUrlPath(true)}/${data.id}`, Util.snakeCaseKeys(data))
82
+ .then((response) => {
83
+ // new this(response.data)
84
+ return response
85
+ })
86
+ }
54
87
 
55
- this._records.loadRecords( properties ).then( () => {
56
- this._records.forEachMatch( properties, (record) => {
57
- records.push( this._records.getRecord( record.id ) )
88
+ //Intended as private below here
89
+ static loadOrSearch(args={}){
90
+ let subscriptionParams = { channel: 'ModelsChannel', model: this.className, filter: args }
91
+ if(this.consumer.subscriptions.findAll(JSON.stringify(subscriptionParams)).length === 0){
92
+ return this.loadRecords(subscriptionParams)
93
+ } else {
94
+ return new Promise((resolve, reject) => { resolve(this.searchRecords(args)) } )
95
+ }
96
+ }
97
+
98
+ static loadRecords(args){
99
+ return new Promise((resolve, reject) => {
100
+ this.consumer.subscriptions.create(args, {
101
+ received: (data) => {
102
+ let records = []
103
+ data.forEach((datum) => {
104
+ records.push(new this(datum))
105
+ })
106
+ resolve(records)
107
+ }
58
108
  })
59
109
  })
110
+ }
60
111
 
61
- return records
112
+ static searchRecords(args){
113
+ var results = []
114
+ Object.keys(this.records).forEach((id)=>{
115
+ // Its a match if none of the keys don't match (IE terminates when a property doesn't match), if a property is an array it still matches if the corresponding arg is within it.
116
+ var match = !Object.keys(args).some((arg)=> {
117
+ return !(this.records[id][arg] == args[arg] ||
118
+ (Array.isArray(this.records[id][arg]) && this.records[id][arg].some((i)=> typeof i == 'object' && i.id == args[arg])) ||
119
+ (Array.isArray(args[arg]) && args[arg].some((a)=> typeof a == 'object' && a.id == this.records[id][arg] || a == this.records[id][arg]))
120
+ )
121
+ })
122
+ if(match) {
123
+ results.push(this.records[id])
124
+ }
125
+ })
126
+ return results
62
127
  }
63
- }
128
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rails-active-sync",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Javascript for interacting with active-sync ruby gem.",
5
5
  "main": "active-sync.js",
6
6
  "scripts": {
@@ -20,7 +20,8 @@
20
20
  "dependencies": {
21
21
  "actioncable": "^5.2.2-1",
22
22
  "camelcase": "^5.2.0",
23
- "snake-case": "^2.1.0"
23
+ "snake-case": "^2.1.0",
24
+ "axios": "^0.21.1"
24
25
  },
25
26
  "author": "Crammaman",
26
27
  "license": "MIT"
@@ -0,0 +1,10 @@
1
+
2
+ import SnakeCase from 'snake-case'
3
+ export default{
4
+ snakeCaseKeys: function( object){
5
+ return Object.keys( object ).reduce( ( acc, key ) => {
6
+ acc[ SnakeCase(key) ] = object[key]
7
+ return acc
8
+ }, {})
9
+ },
10
+ }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active-sync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crammaman
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-05-17 00:00:00.000000000 Z
11
+ date: 2021-12-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -25,47 +25,61 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: 5.1.3
27
27
  - !ruby/object:Gem::Dependency
28
- name: puma
28
+ name: webpacker
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '3.11'
34
- type: :runtime
33
+ version: 3.5.5
34
+ type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '3.11'
40
+ version: 3.5.5
41
41
  - !ruby/object:Gem::Dependency
42
- name: webpacker
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.4'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.4'
55
+ - !ruby/object:Gem::Dependency
56
+ name: foreman
43
57
  requirement: !ruby/object:Gem::Requirement
44
58
  requirements:
45
59
  - - ">="
46
60
  - !ruby/object:Gem::Version
47
- version: 3.5.5
61
+ version: '0'
48
62
  type: :development
49
63
  prerelease: false
50
64
  version_requirements: !ruby/object:Gem::Requirement
51
65
  requirements:
52
66
  - - ">="
53
67
  - !ruby/object:Gem::Version
54
- version: 3.5.5
68
+ version: '0'
55
69
  - !ruby/object:Gem::Dependency
56
- name: sqlite3
70
+ name: puma
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
- - - "~>"
73
+ - - ">="
60
74
  - !ruby/object:Gem::Version
61
- version: 1.3.6
75
+ version: '0'
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
- - - "~>"
80
+ - - ">="
67
81
  - !ruby/object:Gem::Version
68
- version: 1.3.6
82
+ version: '0'
69
83
  description: With minimal set up ActiveSync presents limited rails model interfaces
70
84
  within the JS font end. Records accessed are kept updated through action cable.
71
85
  email:
@@ -77,11 +91,13 @@ files:
77
91
  - MIT-LICENSE
78
92
  - README.md
79
93
  - Rakefile
80
- - app/channels/active_sync_channel.rb
94
+ - app/channels/models_channel.rb
81
95
  - app/controllers/active_sync/application_controller.rb
82
96
  - app/controllers/active_sync/models_controller.rb
83
97
  - app/helpers/active_sync/models_helper.rb
84
- - app/models/active_sync/active_record_extension.rb
98
+ - app/jobs/broadcast_change_job.rb
99
+ - app/models/active_sync/broadcaster.rb
100
+ - app/models/active_sync/model.rb
85
101
  - app/models/active_sync/sync.rb
86
102
  - config/routes.rb
87
103
  - lib/active-sync.rb
@@ -90,7 +106,7 @@ files:
90
106
  - lib/javascript/active-sync.js
91
107
  - lib/javascript/model.js
92
108
  - lib/javascript/package.json
93
- - lib/javascript/records.js
109
+ - lib/javascript/util.js
94
110
  - lib/tasks/rails_sync_tasks.rake
95
111
  homepage: https://github.com/Crammaman/rails-sync
96
112
  licenses:
@@ -111,8 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
111
127
  - !ruby/object:Gem::Version
112
128
  version: '0'
113
129
  requirements: []
114
- rubyforge_project:
115
- rubygems_version: 2.6.10
130
+ rubygems_version: 3.1.6
116
131
  signing_key:
117
132
  specification_version: 4
118
133
  summary: Live updated JS objects for use in reactive JS frameworks
@@ -1,89 +0,0 @@
1
- # Rails currently doesn't allow namespacing channels in an engine
2
- # module ActiveSync
3
- class ActiveSyncChannel < ActionCable::Channel::Base
4
- # For providing DashData with data from rails models
5
- # To change the data sent (like reducing how much is sent)
6
- # implement broadcast_model in the respective modelc
7
-
8
- def subscribed
9
-
10
- if filter && filter[:IsReference]
11
-
12
- subscribe_references
13
-
14
- else
15
-
16
- subscribe_models
17
-
18
- end
19
- end
20
-
21
- def unsubscribed
22
- # Any cleanup needed when channel is unsubscribed
23
- end
24
-
25
- private
26
- def subscribe_models
27
- if filter.nil?
28
-
29
- stream_from "#{subscription_model.name}_All"
30
- transmit( subscription_model.sync_all )
31
-
32
- else
33
-
34
- subscription_model.register_sync_subscription "#{subscription_model.name}_#{checksum}", filter
35
- stream_from "#{subscription_model.name}_#{checksum}"
36
-
37
- # TODO ensure that params are safe to pass to the model then register for syncing to.
38
- transmit( subscription_model.sync_filtered( filter.to_h ) )
39
-
40
- end
41
- end
42
-
43
- def subscribe_references
44
-
45
- record = subscription_model.find( filter[:record_id] )
46
-
47
- if model_association
48
-
49
- transmit( ActiveSync::Sync.association_record( model_association, record) )
50
-
51
- else
52
-
53
- raise "#{subscription_model} does not reference #{ filter[:association_name] }"
54
-
55
- end
56
-
57
- subscription_model.register_sync_subscription "#{subscription_model.name}_#{checksum}", filter.merge( subscribed_model: subscription_model )
58
- eval( model_association[:class] ).register_sync_subscription "#{subscription_model.name}_#{checksum}", filter.merge( subscribed_model: subscription_model )
59
- stream_from "#{subscription_model.name}_#{checksum}"
60
-
61
- end
62
-
63
- def subscription_model
64
-
65
- if ActiveSync::Sync.is_sync_model?( params[:model] )
66
-
67
- eval( params[:model] )
68
-
69
- else
70
-
71
- raise "Model parameter: #{params[:model]} is not a registered sync model"
72
-
73
- end
74
- end
75
-
76
- def model_association
77
- ActiveSync::Sync.get_model_association( subscription_model, filter[:association_name] )
78
- end
79
-
80
- def filter
81
- params[:filter]
82
- end
83
-
84
- def checksum
85
- # A checksum is generated and used in the stream name so all of the same filtered subscriptions should be on the same Stream
86
- Digest::MD5.hexdigest( Marshal::dump( filter ) )
87
- end
88
- end
89
- # end
@@ -1,110 +0,0 @@
1
- module ActiveSync
2
- module ActiveRecordExtension
3
- extend ActiveSupport::Concern
4
-
5
- included do
6
- # after_update :sync_update
7
- after_commit :sync_change
8
-
9
- @@sync_record_subscriptions = {}
10
- end
11
-
12
- def sync_update
13
- sync_change if saved_changes?
14
- end
15
-
16
- def sync_change
17
- if ActiveSync::Sync.is_sync_model? self.class
18
- #TODO properly accommodate multi process environment, since sync sync_subscriptions
19
- # exists only in one process, if one process has a filter sub but another does not
20
- # not all users will necessarily get broadcast to.
21
- ActionCable.server.broadcast("#{self.class}_All", ActiveSync::Sync.sync_record( self ) )
22
- self.class.sync_record_subscriptions.each do | stream, filter |
23
- unless filter[:IsReference]
24
-
25
- match = true
26
- filter.each do | key, value |
27
- unless self.send( key ) == value
28
- match = false
29
- break
30
- end
31
- end
32
-
33
- ActionCable.server.broadcast( stream, ActiveSync::Sync.sync_record( self ) ) if match
34
-
35
- else
36
-
37
- model_association = ActiveSync::Sync.get_model_association( filter[:subscribed_model], filter[:association_name] )
38
-
39
- record = filter[:subscribed_model].find( filter[:record_id] )
40
-
41
- if defined? record.send( model_association[:name] ).pluck
42
-
43
- referenced = record.send( model_association[:name] ).pluck(:id).include? id
44
- else
45
- referenced = record.send( model_association[:name] ).id == id
46
- end
47
-
48
- if referenced
49
- ActionCable.server.broadcast( stream, ActiveSync::Sync.association_record( model_association, record ))
50
- end
51
- end
52
- end
53
- end
54
- end
55
-
56
-
57
- class_methods do
58
-
59
- def register_sync_subscription stream, filter
60
- @@sync_record_subscriptions[ self.name ] = {} if @@sync_record_subscriptions[ self.name ].nil?
61
- @@sync_record_subscriptions[ self.name ][ stream ] = filter
62
- end
63
-
64
- def sync_record_subscriptions
65
- @@sync_record_subscriptions[ self.name ] || {}
66
- end
67
-
68
- # Sync configures the data that is used in general sync communication
69
- # This can be passed the following options:
70
- #
71
- # Example use in Model:
72
- # sync :all_references, associations: [ :sites ]
73
-
74
- # ATTRIBUTE OPTIONS
75
- # Attributes are data that is sent in the actual sync data (this will always include the ID)
76
-
77
- # :all_attributes - sync data will have all attributes
78
- # :attributes - an array of symbols that will be called on the record and sent as attributes
79
-
80
- # ASSOCIATION OPTIONS
81
- # Associations are lazy loaded, data will not go with the record but the front end will be told that
82
- # there is an association to load the data of when accessed.
83
-
84
- # :all_associations - sync data will be associated
85
- # :associations - an array of symbols
86
-
87
- def sync *attributes
88
-
89
- ActiveSync::Sync.configure_model_description self, attributes
90
-
91
- end
92
-
93
- # Sync hash for all of self records
94
- def sync_all
95
-
96
- self.all.map do |record|
97
- ActiveSync::Sync.sync_record record
98
- end
99
-
100
- end
101
-
102
- def sync_filtered filter
103
-
104
- self.where( filter ).map do |record|
105
- ActiveSync::Sync.sync_record record
106
- end
107
- end
108
- end
109
- end
110
- end
@@ -1,216 +0,0 @@
1
- import CamelCase from 'camelcase'
2
- import SnakeCase from 'snake-case'
3
-
4
- export default class Records {
5
- constructor( args = {} ){
6
- this._records = {}
7
- this._addNewRecord = args.addNewRecord || this.addNewRecord
8
- this._associations = args.associations || []
9
- this._cable = args.cable
10
- this._modelName = args.modelName
11
- this._references = args.references
12
- // A subscription is just it's filter{}
13
- this._subscriptions = []
14
- // An ugly object of { filter{}: boolean }
15
- this._dataLoading = {}
16
- }
17
-
18
- setAssociatedModels( models ){
19
- this._associations.forEach( ( association ) => {
20
- var model = models.find( ( model ) => model.name == association.class )
21
- if( model ){
22
- association.model = model
23
- } else {
24
- throw `Model ${this._modelName} is set up for association with ${association.class} but ${association.class} is not available through Active Sync`
25
- }
26
- } )
27
- }
28
-
29
- getRecord( id ){
30
- return this._records[id]
31
- }
32
-
33
- push( record ){
34
-
35
- this._associations.forEach( ( association ) => {
36
- switch(association.type){
37
- case 'ActiveRecord::Associations::HasManyAssociation':
38
- case 'ActiveRecord::Associations::HasManyThroughAssociation':
39
- record[ association.name ] = [1]
40
- break
41
- case 'ActiveRecord::Associations::BelongsToAssociation':
42
- var standIn = {}
43
- Object.defineProperty(standIn, "$count", {
44
- enumerable: false,
45
- writable: true
46
- })
47
- standIn.$count = 1
48
- //this._addNewRecord( record, association.name, standIn )
49
- record[ association.name ] = standIn
50
- break
51
-
52
- default:
53
- console.log('Unknown know association type ' + association.type)
54
- }
55
- } )
56
-
57
- var newRecord = new Proxy( this.camelCaseKeys( record ), {
58
- get: ( record, property )=> this.getFromRecord( record, property, this )
59
- } )
60
-
61
- this._addNewRecord( this._records, newRecord.id, newRecord )
62
- }
63
-
64
- addNewRecord( records, recordId, record ){
65
- if( records[ recordId ] ){
66
- Object.keys( record ).forEach( (key) => records[ recordId ][ key ] = record[ key ] )
67
- } else {
68
- records[ recordId ] = record
69
- }
70
- }
71
-
72
- forEach( func ){
73
- Object.keys( this._records ).forEach(( id ) => func( this._records[id] ) )
74
- }
75
-
76
- camelCaseKeys( record ){
77
- return Object.keys( record ).reduce( ( camelRecord, key ) => {
78
- camelRecord[ CamelCase(key) ] = record[key]
79
- return camelRecord
80
- }, {} )
81
- }
82
- snakeCaseKeys( record ){
83
- return Object.keys( record ).reduce( ( camelRecord, key ) => {
84
- camelRecord[ SnakeCase(key) ] = record[key]
85
- return camelRecord
86
- }, {} )
87
- }
88
-
89
- getFromRecord( record, property, self ){
90
- self.loadIfAssociation( record, property )
91
- return record[ property ]
92
- }
93
- //If the records already exist it will return an instantaneously resolving promise
94
- loadRecords( filter = null ){
95
- if( !this.isSubscribed( filter ) ){
96
-
97
- this._subscriptions.push( filter ? filter : 'all' )
98
- this.subscribeToRecords( filter )
99
-
100
- }
101
-
102
- return new Promise( (resolve, reject)=> this.awaitData(resolve, reject, filter) )
103
- }
104
-
105
- awaitData( resolve, reject, filter ){
106
- setTimeout( ()=>{
107
- if( this._dataLoading[ filter ] ){
108
- this.awaitData( resolve, reject, filter )
109
- } else {
110
- resolve()
111
- }
112
- //TODO there's something wrong with _dataLoading not working so this wait time has been cranked up.
113
- }, 200 )
114
- }
115
-
116
- // Adds records that match properties into records
117
- forEachMatch( properties, func ){
118
-
119
- let records = []
120
-
121
- this.forEach( ( record ) => {
122
-
123
- var match = true
124
- Object.keys( properties ).forEach( ( property ) => {
125
- if( properties[ property ] != record[ property ] ){
126
- match = false
127
- }
128
- })
129
-
130
- if( match ){
131
- func(record)
132
- }
133
- })
134
-
135
- return records
136
- }
137
-
138
- isSubscribed( filter ){
139
- filter = filter ? filter : 'all'
140
- if( this._subscriptions.includes( 'all' ) && !filter.IsReference ) {
141
- return true
142
- } else {
143
- return !!this._subscriptions.find( ( sub ) => JSON.stringify(sub) == JSON.stringify(filter) )
144
- }
145
- }
146
-
147
- // Subscribing to a record is the source of all data communication. With no filter
148
- // all records are subscribed to, this is done without checking for existing subscriptions
149
- // so that needs to be done before getting here.
150
- subscribeToRecords( filter = null ){
151
-
152
- let subscriptionParameters = { channel: 'ActiveSyncChannel', model: this._modelName }
153
- this._dataLoading[ filter ] = true
154
-
155
- if ( filter !== null ) {
156
- subscriptionParameters.filter = filter.IsReference ? filter : this.snakeCaseKeys(filter)
157
- }
158
-
159
- this._cable.subscriptions.create( subscriptionParameters ,{
160
- received: (data) => {
161
- var records = data.IsReference ? this._references : this
162
- // Will find records and update them, if not found will add them to
163
- // _records or references.
164
- if( data.length > 0){
165
-
166
- // data is a promise so might not have anything at this point,
167
- // adding with a forEach allows promises to be handled (is there a better way?)
168
- data.forEach((addModel) => {
169
- records.push(addModel)
170
- })
171
-
172
-
173
- } else {
174
-
175
- records.push( data )
176
-
177
- }
178
-
179
- this._dataLoading[ filter ] = false
180
-
181
- }
182
- })
183
- }
184
-
185
- loadIfAssociation( record, property ){
186
- var association = this._associations.find( ( a ) => a.name == property )
187
-
188
- if( association ){
189
-
190
- var referencedRecords = []
191
-
192
- if( record[property][0] == 1 ){
193
- record[property].pop()
194
- } else if( record[property].$count > 0 ){
195
- record[property].$count--
196
- } else {
197
- this.loadRecords({ IsReference: true, record_id: record.id, association_name: property })
198
- .then( () => {
199
- var references = this._references.getRecord( record.id )[ property ]
200
-
201
- if( references.length > 0 && references.length !== record[property].length ){
202
- record[property] = []
203
- references.forEach( ( reference ) => {
204
- record[property].push( association.model.find( reference ))
205
- } )
206
-
207
- } else if( typeof references.length === 'undefined' && record[property].$count == 0 ) {
208
-
209
- this._addNewRecord(record,property, association.model.find( references ))
210
-
211
- }
212
- })
213
- }
214
- }
215
- }
216
- }