diff --git a/Gemfile.lock b/Gemfile.lock index 3fb7dea..7ad49a9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active-sync (0.1.1) + active-sync (0.2.0) rails (>= 5.1.3) GEM @@ -89,6 +89,8 @@ GEM nokogiri (1.11.3) mini_portile2 (~> 2.5.0) racc (~> 1.4) + puma (5.5.2) + nio4r (~> 2.0) racc (1.5.2) rack (2.2.3) rack-proxy (0.6.5) @@ -139,7 +141,7 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - websocket-driver (0.7.3) + websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) zeitwerk (2.4.2) @@ -150,6 +152,7 @@ PLATFORMS DEPENDENCIES active-sync! foreman + puma sqlite3 (~> 1.4) webpacker (>= 3.5.5) diff --git a/README.md b/README.md index be7715f..189f3c3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ActiveSync Dynamically created and updated front end models. -Active record adds an active record interface to your front end. Configured models are available +Active sync adds a limited active record like interface to your front end. Configured models are available within your Javascript application with many active record type functions such as 'where'. Records are lazy loaded and then dynamically updated through actioncable. So any records looked up diff --git a/active_sync.gemspec b/active_sync.gemspec index b265af4..8550911 100644 --- a/active_sync.gemspec +++ b/active_sync.gemspec @@ -21,4 +21,5 @@ Gem::Specification.new do |s| s.add_development_dependency "webpacker", ">= 3.5.5" s.add_development_dependency "sqlite3", "~> 1.4" s.add_development_dependency "foreman" + s.add_development_dependency "puma" end diff --git a/app/channels/models_channel.rb b/app/channels/models_channel.rb index fcb797a..48b5831 100644 --- a/app/channels/models_channel.rb +++ b/app/channels/models_channel.rb @@ -1,12 +1,15 @@ # Rails currently doesn't allow namespacing channels in an engine # module ActiveSync class ModelsChannel < ActionCable::Channel::Base - # For providing DashData with data from rails models - # To change the data sent (like reducing how much is sent) - # implement broadcast_model in the respective modelc + # To change the data sent implement sync_record in the respective model def subscribed - subscribe_models + transmit(subscription_model.where(params[:filter]).map(&:sync_record)) + stream_from params[:model], coder: ActiveSupport::JSON do |message| + if (params[:filter].to_a - message.to_a).blank? + transmit([message]) + end + end end def unsubscribed @@ -14,35 +17,11 @@ def unsubscribed end private - def subscribe_models - if filter.nil? - - stream_from "#{subscription_model.name}_All" - transmit( subscription_model.sync_all ) - - else - - subscription_model.register_sync_subscription "#{subscription_model.name}_#{checksum}", filter - stream_from "#{subscription_model.name}_#{checksum}" - - # TODO ensure that params are safe to pass to the model then register for syncing to. - transmit( subscription_model.sync_filtered( filter.to_h ) ) - - end - end def subscription_model - model = params[:model].camelize.constantize - model.sync_model? ? model : raise "Model '#{params[:model]}' is not a registered sync model" - end - - def model_association - ActiveSync::Sync.get_model_association( subscription_model, filter[:association_name] ) - end - - def checksum - # A checksum is generated and used in the stream name so all of the same filtered subscriptions should be on the same Stream - Digest::MD5.hexdigest( Marshal::dump( filter ) ) + model = params[:model].singularize.camelize.constantize + raise "Model '#{params[:model]}' is not set up for syncing model" unless model.sync_model? + model end end # end diff --git a/app/controllers/active_sync/application_controller.rb b/app/controllers/active_sync/application_controller.rb index eec3652..84dafbf 100644 --- a/app/controllers/active_sync/application_controller.rb +++ b/app/controllers/active_sync/application_controller.rb @@ -1,5 +1,5 @@ module ActiveSync class ApplicationController < ActionController::Base - protect_from_forgery with: :exception + # protect_from_forgery with: :exception end end diff --git a/app/controllers/active_sync/models_controller.rb b/app/controllers/active_sync/models_controller.rb index 0d46c67..b999449 100644 --- a/app/controllers/active_sync/models_controller.rb +++ b/app/controllers/active_sync/models_controller.rb @@ -1,16 +1,22 @@ module ActiveSync class ModelsController < ApplicationController - def index - render json: model.sync_filtered(properties) + def update + #TODO some oversite on what can be edited for sync records + model.find(params[:id]).update(params.permit(model.sync_attributes)) + head :no_content end - def properties - params.permit(ActiveSync::Sync.model_descriptions[model.name][:attributes]) + def create + #TODO some oversite on what can be created for sync records + render json: model.create(params.permit(model.sync_attributes)).id end + private def model - params[:model].singularize.camelize.safe_constantize || params[:model].camelize.safe_constantize + m = params[:model].singularize.camelize.safe_constantize || params[:model].camelize.safe_constantize + raise "Cannot edit #{params[:model]} as it is not a sync model" unless m.sync_model? + m end end end diff --git a/app/jobs/broadcast_change_job.rb b/app/jobs/broadcast_change_job.rb index d2464dd..f6d6d76 100644 --- a/app/jobs/broadcast_change_job.rb +++ b/app/jobs/broadcast_change_job.rb @@ -1,20 +1,9 @@ class BroadcastChangeJob < ApplicationJob - queue_as :default + queue_as :active_sync - def perform record - SyncSubscriptions.all.each do | subscription | - unless filter[:IsReference] - - match = true - filter.each do | key, value | - unless self.send( key ) == value - match = false - break - end - end + include ActionCable::Channel::Broadcasting - ActionCable.server.broadcast( stream, ActiveSync::Sync.sync_record( self ) ) if match - end - end + def perform record + ActionCable.server.broadcast(record.class.name, record.sync_record) end end diff --git a/app/models/active_sync/model.rb b/app/models/active_sync/model.rb index 86f844e..f486716 100644 --- a/app/models/active_sync/model.rb +++ b/app/models/active_sync/model.rb @@ -24,15 +24,6 @@ def sync_model? true end - def register_sync_subscription stream, filter - @@sync_record_subscriptions[ self.name ] = {} if @@sync_record_subscriptions[ self.name ].nil? - @@sync_record_subscriptions[ self.name ][ stream ] = filter - end - - def sync_record_subscriptions - @@sync_record_subscriptions[ self.name ] || {} - end - # #sync sets the #sync_record method that renders the hash to create the JSON object that is broadcast and sets # #sync_associations which returns a list of associations that are permitted to be broadcast for this model. # define these methods directly in your model if the record sent to the font end needs to be different to what's @@ -57,6 +48,9 @@ def sync_record_subscriptions # :associations - an array of symbols def sync *attributes + self.class.define_method(:sync_attributes) do + ActiveSync::Sync.sync_attributes(self, attributes) + end define_method(:sync_record) do ActiveSync::Sync.sync_record(self, attributes) end @@ -64,19 +58,6 @@ def sync *attributes ActiveSync::Sync.sync_associations(self, attributes) end end - - # Sync hash for all of self records - def sync_all - self.all.map do |record| - ActiveSync::Sync.sync_record record - end - end - - def sync_filtered filter - self.where( filter ).map do |record| - ActiveSync::Sync.sync_record record - end - end end end end diff --git a/app/models/active_sync/sync.rb b/app/models/active_sync/sync.rb index a3a7518..6d067b5 100644 --- a/app/models/active_sync/sync.rb +++ b/app/models/active_sync/sync.rb @@ -1,17 +1,30 @@ module ActiveSync class Sync + + def self.sync_attributes model, args + @@sync_attributes ||= args.reduce([]) do |array, option| + case option + when :all_attributes_and_associations, :all_attributes + array + model.column_names.map(&:to_sym) + when ->(option){ option.is_a?(Hash) } + array + option[:attributes] + else + raise "Unknown sync record option #{option.inspect}" + end + end + end + #Hash used in all general sync communication for a given model. def self.sync_record record, args args.reduce({}) do |hash, option| case option when :all_attributes_and_associations, :all_attributes - hash.merge(model.attributes) + hash.merge(record.attributes) when ->(option){ option.is_a?(Hash) } - option[:attributes]&.each { |attribute| hash[attribute] = record.call(attribute) } + option[:attributes]&.reduce(hash) { |h, attr| h[attr] = record.call(attr) } else raise "Unknown sync record option #{option.inspect}" end - hash end end diff --git a/config/routes.rb b/config/routes.rb index a98f682..69bcbc7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,4 @@ ActiveSync::Engine.routes.draw do - get '/:model', to: 'models#index' - get '/:model/:id', to: 'models#show' + put '/:model/:id', to: 'models#update' + post '/:model', to: 'models#create' end diff --git a/lib/active_sync/version.rb b/lib/active_sync/version.rb index 0a6ef89..5a9b833 100644 --- a/lib/active_sync/version.rb +++ b/lib/active_sync/version.rb @@ -1,3 +1,3 @@ module ActiveSync - VERSION = '0.1.1' + VERSION = '0.2.0' end diff --git a/lib/javascript/active-sync.js b/lib/javascript/active-sync.js index 100d56d..0f42b8d 100644 --- a/lib/javascript/active-sync.js +++ b/lib/javascript/active-sync.js @@ -1,6 +1,7 @@ import Model from './model.js' import CamelCase from 'camelcase' import Pluralize from 'pluralize' +import SnakeCase from "snake-case"; export default class ActiveSync { @@ -19,8 +20,6 @@ export default class ActiveSync { // Will add a 'sites' method to the Customer class and a 'customer' method to Site. // - - constructor( args ){ this._models = args.models || []; this.buildModels(args.modelDescriptions) @@ -34,7 +33,7 @@ export default class ActiveSync { return this._models } - // Creates the models from the modelDescription arg passed int to the constructor + // Creates the models from the modelDescription arg passed in to the constructor buildModels(modelDescriptions){ let modelNames = Object.keys(modelDescriptions || {}); @@ -45,21 +44,8 @@ export default class ActiveSync { }) this._models.forEach((model) => { - ((modelDescriptions[model.className] || {}).belongsTo || []).forEach((association) => { - let associatedModel = this._models.find((model) => model.className === CamelCase(association, {pascalCase: true})) - model[association] = function () { - return associatedModel.find(this[association + 'Id']) - } - }); - - ((modelDescriptions[model.className] || {}).hasMany || []).forEach((association) => { - let associatedModel = this._models.find((model) => model.className === CamelCase(Pluralize.singular(association), {pascalCase: true})) - model.prototype[association] = function () { - let associationQuery = {} - associationQuery[CamelCase(model.className) + 'Id'] = this.id - return associatedModel.where(associationQuery) - } - }); + this.addBelongsToModel(modelDescriptions[model.className], model) + this.addHasManyToModel(modelDescriptions[model.className], model) }) } @@ -67,6 +53,26 @@ export default class ActiveSync { let modelClass = Model return eval(`(class ${name} extends modelClass { static className = '${name}' })`) } + + addBelongsToModel(modelDescription, model){ + ((modelDescription || {}).belongsTo || []).forEach((association) => { + let associatedModel = this._models.find((model) => model.className === association) + model[association] = function () { + return associatedModel.find(this[association + '_id']) + } + }); + } + + addHasManyToModel(modelDescription, model){ + ((modelDescription || {}).hasMany || []).forEach((association) => { + let associatedModel = this._models.find((model) => model.className === CamelCase(Pluralize.singular(association), {pascalCase: true})) + model.prototype[association] = function () { + let associationQuery = {} + associationQuery[SnakeCase(model.className) + '_id'] = this.id + return associatedModel.where(associationQuery) + } + }); + } } diff --git a/lib/javascript/model.js b/lib/javascript/model.js index 8a3b52c..114f7f7 100644 --- a/lib/javascript/model.js +++ b/lib/javascript/model.js @@ -3,14 +3,18 @@ import SnakeCase from 'snake-case' import CamelCase from 'camelcase' import Pluralize from 'pluralize' +import Actioncable from "actioncable" + import Util from './util' export default class Model { // static records = {} static recordsLoaded = false - static url_path_base = 'active_sync/' + static urlPathBase = 'active_sync/' + static consumer = Actioncable.createConsumer() + constructor(args){ if(!args.id) throw 'Can not create record without an id' if(this.constructor.records[args.id]){ @@ -23,7 +27,7 @@ export default class Model { setProperties(args){ Object.keys(args).forEach((property)=>{ - this[CamelCase(property)] = args[property] + this[property] = args[property] }) } @@ -33,29 +37,21 @@ export default class Model { } static get all(){ - return new Promise((resolve,reject)=>{ - if(this.recordsLoaded){ - resolve(Object.keys(this.records).map((id) => this.records[id])) - } else { - return this.loadRecords().then(()=>{ - resolve(Object.keys(this.records).map((id) => this.records[id])) - }) - } - }) + return this.loadOrSearch() } - static model_url_path(singular = false){ + static modelUrlPath(singular = false){ if(singular) { - return this.url_path_base + SnakeCase(this.className) + return this.urlPathBase + SnakeCase(this.className) } else { - return this.url_path_base + SnakeCase(Pluralize(this.className)) + return this.urlPathBase + SnakeCase(Pluralize(this.className)) } } static find(id){ return new Promise((resolve,reject)=>{ if(!this.records[id]){ - resolve(this.loadRecords(id).then(()=> this.records[id])) + resolve(this.loadRecords({ id: id }).then(()=> this.records[id])) } else { resolve(this.records[id]) } @@ -63,13 +59,7 @@ export default class Model { } static where(args){ - return new Promise((resolve,reject)=>{ - if(this.recordsLoaded){ - resolve(this.searchRecords(args)) - } else { - this.loadRecords(args).then(() => resolve(this.searchRecords(args))) - } - }) + return this.loadOrSearch(Util.snakeCaseKeys(args)) } static through(model, args){ @@ -80,45 +70,44 @@ export default class Model { } static create(data){ - return Axios.post(this.model_url_path(), Util.snakeCaseKeys(data)) - .then((response) => { - new this(response.data) - return response - }) + return Axios.post(this.modelUrlPath(), Util.snakeCaseKeys(data)) + .then((response) => { + new this(response.data) + return response + }) } static update(data){ - return Axios.put( `${this.model_url_path(true)}/${data.id}`, Util.snakeCaseKeys(data)) + return Axios.put( `${this.modelUrlPath(true)}/${data.id}`, Util.snakeCaseKeys(data)) .then((response) => { - new this(response.data) + // new this(response.data) return response }) } //Intended as private below here - static loadRecords(args = {}){ - console.log(args) - //No args is interpretted as load all. - if(typeof args === 'number') { - return Axios.get(this.model_url_path(true) + '/' + args) - .then((response) => { - new this(response.data) - }) - .catch((error) => { - console.log(error) - }) + static loadOrSearch(args={}){ + let subscriptionParams = { channel: 'ModelsChannel', model: this.className, filter: args } + if(this.consumer.subscriptions.findAll(JSON.stringify(subscriptionParams)).length === 0){ + return this.loadRecords(subscriptionParams) } else { - return Axios.get( this.model_url_path(), { params: Util.snakeCaseKeys(args) } ) - .then((response) => { - response.data.forEach((record)=> { - new this(record) - }) - }) - .catch((error) => { - console.log(error) - }) + return new Promise((resolve, reject) => { resolve(this.searchRecords(args)) } ) } } + + static loadRecords(args){ + return new Promise((resolve, reject) => { + this.consumer.subscriptions.create(args, { + received: (data) => { + let records = [] + data.forEach((datum) => { + records.push(new this(datum)) + }) + resolve(records) + } + }) + }) + } static searchRecords(args){ var results = [] @@ -130,7 +119,9 @@ export default class Model { (Array.isArray(args[arg]) && args[arg].some((a)=> typeof a == 'object' && a.id == this.records[id][arg] || a == this.records[id][arg])) ) }) - if(match) results.push(this.records[id]) + if(match) { + results.push(this.records[id]) + } }) return results } diff --git a/lib/javascript/package.json b/lib/javascript/package.json index 436f76c..fdb8eb6 100644 --- a/lib/javascript/package.json +++ b/lib/javascript/package.json @@ -1,6 +1,6 @@ { "name": "rails-active-sync", - "version": "0.1.3", + "version": "0.2.0", "description": "Javascript for interacting with active-sync ruby gem.", "main": "active-sync.js", "scripts": { @@ -20,7 +20,8 @@ "dependencies": { "actioncable": "^5.2.2-1", "camelcase": "^5.2.0", - "snake-case": "^2.1.0" + "snake-case": "^2.1.0", + "axios": "^0.21.1" }, "author": "Crammaman", "license": "MIT" diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb index 1c07694..4355099 100644 --- a/test/dummy/app/controllers/application_controller.rb +++ b/test/dummy/app/controllers/application_controller.rb @@ -1,3 +1,3 @@ class ApplicationController < ActionController::Base - protect_from_forgery with: :exception + # protect_from_forgery with: :exception end diff --git a/test/dummy/app/javascript/customer.vue b/test/dummy/app/javascript/customer.vue index 811cfa5..c6c7a22 100644 --- a/test/dummy/app/javascript/customer.vue +++ b/test/dummy/app/javascript/customer.vue @@ -1,6 +1,7 @@