Skip to content

Commit

Permalink
Merge db48312 into 3c86a58
Browse files Browse the repository at this point in the history
  • Loading branch information
DmitryTsepelev committed Nov 14, 2018
2 parents 3c86a58 + db48312 commit 0c94d1f
Show file tree
Hide file tree
Showing 18 changed files with 391 additions and 2 deletions.
11 changes: 11 additions & 0 deletions lib/generators/logux/model/USAGE
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Description:
Generates the necessary migration to enable field tracking for logux updates

Examples:
rails generate logux:model User

This will generate the migration to add a column with update time data.

rails generate logux:model User --nullable

This will generate the migration to add a column with update time data and add `null: false` constraint. Be careful, adding the constraint to the table with a big number of rows can cause lots of locks.
28 changes: 28 additions & 0 deletions lib/generators/logux/model/model_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

require 'rails/generators'
require 'rails/generators/active_record/migration/migration_generator'

module Logux
module Generators
class ModelGenerator < ::ActiveRecord::Generators::Base # :nodoc:
source_root File.expand_path('templates', __dir__)

class_option :nullable,
type: :boolean,
optional: true,
desc: 'Define whether field should have not-null constraint'

def generate_migration
migration_template(
'migration.rb.erb',
"db/migrate/add_logux_fields_updated_at_to_#{plural_table_name}.rb"
)
end

def nullable?
options.fetch(:nullable, false)
end
end
end
end
14 changes: 14 additions & 0 deletions lib/generators/logux/model/templates/migration.rb.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class <%= @migration_class_name %> < ActiveRecord::Migration<%= ActiveRecord::VERSION::MAJOR < 5 ? '' : '[5.0]' %>
def up
<% if nullable? %>
add_column :<%= plural_table_name %>, :logux_fields_updated_at, :jsonb, null: false, default: {}
<% else %>
add_column :<%= plural_table_name %>, :logux_fields_updated_at, :jsonb, null: true
change_column_default :<%= plural_table_name %>, :logux_fields_updated_at, {}
<% end %>
end

def down
remove_column :<%= plural_table_name %>, :logux_fields_updated_at
end
end
1 change: 1 addition & 0 deletions lib/logux.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def initialize(msg, meta: nil)
autoload :Version, 'logux/version'
autoload :Test, 'logux/test'
autoload :ErrorRenderer, 'logux/error_renderer'
autoload :Model, 'logux/model'

configurable :logux_host, :verify_authorized,
:password, :logger,
Expand Down
20 changes: 18 additions & 2 deletions lib/logux/action_caller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'logux/model/insecure_update_subscriber'

module Logux
class ActionCaller
attr_reader :action, :meta
Expand All @@ -12,8 +14,12 @@ def initialize(action:, meta:)
end

def call!
logger.info("Searching action for Logux action: #{action}, meta: #{meta}")
format(action_controller.public_send(action.action_type))
detect_insecure_updates do
logger.info(
"Searching action for Logux action: #{action}, meta: #{meta}"
)
format(action_controller.public_send(action.action_type))
end
rescue Logux::UnknownActionError, Logux::UnknownChannelError => e
logger.warn(e)
format(nil)
Expand All @@ -34,5 +40,15 @@ def class_finder
def action_controller
class_finder.find_action_class.new(action: action, meta: meta)
end

def detect_insecure_updates
ActiveSupport::Notifications.subscribe(
'logux.insecure_update',
Logux::Model::InsecureUpdateSubscriber.new
)
yield
ensure
ActiveSupport::Notifications.unsubscribe('logux.insecure_update')
end
end
end
8 changes: 8 additions & 0 deletions lib/logux/meta.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,13 @@ def node_id
def user_id
id&.split(' ')&.second&.split(':')&.first
end

def sequence_id
id&.split(' ')&.third
end

def logux_id
[time, node_id, sequence_id].join(' ')
end
end
end
44 changes: 44 additions & 0 deletions lib/logux/model.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

require_relative 'model/updater'
require_relative 'model/proxy'
require_relative 'model/dsl'

module Logux
module Model
class InsecureUpdateError < StandardError; end

def self.included(base)
base.extend(DSL)

base.before_save :update_logux_id
end

def logux
Proxy.new(self)
end

private

def update_logux_id
return if changes.key?('logux_fields_updated_at')

attributes = changed.each_with_object({}) do |attr, res|
res[attr] = send(attr)
end

updater = Updater.new(model: self, attributes: attributes)
self.logux_fields_updated_at = updater.updated_attributes

trigger_insecure_update_notification
end

def trigger_insecure_update_notification
ActiveSupport::Notifications.instrument(
'logux.insecure_update',
model_class: self.class,
attributes: (changed - ['logux_fields_updated_at']).map(&:to_sym)
)
end
end
end
15 changes: 15 additions & 0 deletions lib/logux/model/dsl.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module Logux
module Model
module DSL
def logux_crdt_map_attributes(*attributes)
@logux_crdt_mapped_attributes = attributes
end

def logux_crdt_mapped_attributes
@logux_crdt_mapped_attributes ||= []
end
end
end
end
24 changes: 24 additions & 0 deletions lib/logux/model/insecure_update_subscriber.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Logux
module Model
class InsecureUpdateSubscriber
# rubocop:disable Naming/UncommunicativeMethodParamName
def call(_, _, _, _, args)
model_class = args[:model_class]
attributes = args[:attributes]

logux_attributes =
attributes & model_class.logux_crdt_mapped_attributes
return if logux_attributes.empty?

pluralized_attributes = 'attribute'.pluralize(logux_attributes.count)

raise InsecureUpdateError, <<~TEXT
Logux tracked #{pluralized_attributes} (#{logux_attributes.join(', ')}) should be updated using model.logux.update(...)
TEXT
end
# rubocop:enable Naming/UncommunicativeMethodParamName
end
end
end
24 changes: 24 additions & 0 deletions lib/logux/model/proxy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Logux
module Model
class Proxy
def initialize(model)
@model = model
end

def update(meta, attributes)
updater = Updater.new(
model: @model,
logux_id: meta.logux_id,
attributes: attributes
)
@model.update_attributes(updater.updated_attributes)
end

def updated_at(field)
@model.logux_fields_updated_at[field.to_s]
end
end
end
end
39 changes: 39 additions & 0 deletions lib/logux/model/updater.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module Logux
module Model
class Updater
def initialize(model:, attributes:, logux_id: Logux.generate_action_id)
@model = model
@logux_id = logux_id
@attributes = attributes
end

def updated_attributes
newer_updates.merge(logux_fields_updated_at: fields_updated_at)
end

private

def fields_updated_at
@fields_updated_at ||=
newer_updates.slice(*tracked_fields)
.keys
.reduce(@model.logux_fields_updated_at) do |acc, attr|
acc.merge(attr => @logux_id)
end
end

def newer_updates
@newer_updates ||= @attributes.reject do |attr, _|
field_updated_at = @model.logux.updated_at(attr)
field_updated_at && field_updated_at > @logux_id
end
end

def tracked_fields
@model.class.logux_crdt_mapped_attributes
end
end
end
end
7 changes: 7 additions & 0 deletions spec/dummy/app/models/post.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class Post < ActiveRecord::Base
include Logux::Model

logux_crdt_map_attributes :title, :content
end
10 changes: 10 additions & 0 deletions spec/dummy/db/migrate/20181101131807_create_posts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class CreatePosts < ActiveRecord::Migration[5.2]
def change
create_table :posts do |t|
t.string :title
t.text :content

t.timestamps
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class AddLoguxFieldsUpdatedAtToPosts < ActiveRecord::Migration[5.2]
def up
add_column :posts, :logux_fields_updated_at, :jsonb, null: true
change_column_default :posts, :logux_fields_updated_at, {}
end

def down
remove_column :posts, :logux_fields_updated_at
end
end
9 changes: 9 additions & 0 deletions spec/factories/logux_actions_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,14 @@
)
end
end

factory :logux_actions_post_rename do
skip_create
initialize_with do
new(
{ type: 'post/rename', key: 'name', value: 'test' }.merge(attributes)
)
end
end
end
end
8 changes: 8 additions & 0 deletions spec/factories/post_factory.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

FactoryBot.define do
factory :post, class: Post do
title { 'initial' }
content { 'initial' }
end
end
28 changes: 28 additions & 0 deletions spec/logux/action_caller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,33 @@ def add
expect(result.status).to eq(:ok)
end
end

describe 'when insecure update happens' do
let(:action) { create(:logux_actions_post_rename) }

before do
module Actions
class Post < Logux::ActionController
def rename
post = FactoryBot.create(:post)
post.update_attributes(title: 'new title')
respond :processed
end
end
end
end

after do
Actions::Post.send :undef_method, :rename
Actions.send :remove_const, :Post
Actions.send :const_set, :Post, Class.new
end

it 'raises exception when #update_attributes is called inside action' do
expect do
action_caller.call!
end.to raise_exception(Logux::Model::InsecureUpdateError)
end
end
end
end

0 comments on commit 0c94d1f

Please sign in to comment.