Skip to content

Commit

Permalink
Use jsonb concat in store_accessors (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
KirIgor authored Jan 16, 2023
1 parent b033731 commit 642dfe0
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 11 deletions.
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
umbrellio-sequel-plugins (0.10.0)
umbrellio-sequel-plugins (0.11.0)
sequel
symbiont-ruby

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ Example:

```ruby
class User < Sequel::Model
store :data, :first_name
store :data, :first_name, :last_name
end
user = User.create(first_name: "John")
Expand Down
4 changes: 4 additions & 0 deletions lib/sequel/extensions/migration_transaction_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ def _use_transaction?(migration)
if migration.use_transactions.nil?
@db.supports_transactional_ddl?
else
# :nocov:
migration.use_transactions
# :nocov:
end
else
# :nocov:
@use_transactions
# :nocov:
end
end

Expand Down
111 changes: 106 additions & 5 deletions lib/sequel/plugins/store_accessors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,30 @@ module ClassMethods
# user.first_name # => "John"
# user.data # => {"first_name": "John"}
def store(column, *fields)
include_accessors_module
include_accessors_module(column)

fields.each do |field|
define_store_getter(column, field)
define_store_setter(column, field)
end
end

def call(_)
super.tap(&:calculate_initial_store)
end

private

def include_accessors_module
return if defined?(@_store_accessors_module)
@_store_accessors_module = Module.new
include @_store_accessors_module
def include_accessors_module(column)
unless defined?(@_store_accessors_module)
@_store_accessors_module = Module.new
include @_store_accessors_module
end

prev_columns = @_store_accessors_module.instance_variable_get(:@_store_columns) || []
new_columns = [*prev_columns, column]
@_store_accessors_module.instance_variable_set(:@_store_columns, new_columns)
@_store_accessors_module.define_method(:store_columns) { new_columns }
end

def define_store_getter(column, field)
Expand All @@ -48,4 +58,95 @@ def define_store_setter(column, field)
end
end
end

module InstanceMethods
def after_update
super
refresh_initial_store
end

def after_create
super
refresh_initial_store
end

def calculate_initial_store
@store_values_hashes || refresh_initial_store
end

def changed_columns
changed = super
return changed unless respond_to?(:store_columns)
changed = changed.dup if frozen?
store_columns.each do |col|
match = patched(col).empty? && deleted(col).empty?
if changed.include?(col)
changed.delete(col) if match
else
changed << col unless match
end
end
changed
end

private

def _update_without_checking(columns)
return super unless respond_to?(:store_columns)

mapped_columns = columns.to_h do |column, v|
next [column, v] unless store_columns.include?(column)

json = Sequel.pg_jsonb_op(
Sequel.function(:coalesce, Sequel[column], Sequel.pg_jsonb({})),
)
updated = deleted(column).inject(json) { |res, k| res.delete_path([k.to_s]) }
[column, updated.concat(patched(column))]
end

super(mapped_columns)
end

def patched(column)
initial_fields = initial_store_fields[column] || []
initial_hashes = store_values_hashes[column] || {}
current = @values[column] || {}

current.dup.delete_if do |k, v|
initial_fields.include?(k) && initial_hashes[k] == v.hash
end
end

def deleted(column)
initial_fields = initial_store_fields[column] || []
current = @values[column] || {}

initial_fields.dup - current.keys
end

def _refresh(dataset)
super
refresh_initial_store
end

def _save_refresh
super
refresh_initial_store
end

def refresh_initial_store
return unless respond_to?(:store_columns)
store_values = @values.slice(*store_columns)
@initial_store_fields = store_values.transform_values { |v| v.to_h.keys }
@store_values_hashes = store_values.transform_values { |v| v.to_h.transform_values(&:hash) }
end

def initial_store_fields
@initial_store_fields || {}
end

def store_values_hashes
@store_values_hashes || {}
end
end
end
73 changes: 71 additions & 2 deletions spec/plugins/store_accessors_spec.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,87 @@
# frozen_string_literal: true

DB.create_table :posts do
primary_key :id
column :data, :jsonb, default: "{}"
column :metadata, :jsonb, default: "{}"
end

class Post < Sequel::Model(:posts)
store :metadata, :tags
store :data, :amount, :project_id
store :metadata, :tags, :marker
end

RSpec.describe "store_accessors" do
let(:post) { Post.create(tags: %w[first second]) }
let!(:post) { Post.create(tags: %w[first second], amount: 10) }

it "stores tags as json" do
expect(post.amount).to eq(10)
expect(post.data).to eq("amount" => 10)
expect(post.tags).to eq(%w[first second])
expect(post.metadata).to eq("tags" => %w[first second])
end

it "updates only changed" do
first_post = Post[post.id]
first_post.marker = true
first_post.project_id = 1
first_post.save_changes
post.tags = %w[first]
post.amount = 5
post.save_changes
expect(post.reload.tags).to eq(%w[first])
expect(post.marker).to eq(true)
expect(post.amount).to eq(5)
expect(post.project_id).to eq(1)
end

it "deletes fields" do
post.update(data: {}, metadata: {})
expect(post.reload.data).to eq({})
expect(post.metadata).to eq({})
expect(post.tags).to eq(nil)
expect(post.amount).to eq(nil)
end

it "directly updates right" do
post.update(
data: { amount: 1, project_id: 2 },
metadata: { tags: %w[first], marker: true },
)
expect(post.reload.data.to_h).to eq("amount" => 1, "project_id" => 2)
expect(post.metadata.to_h).to eq("tags" => %w[first], "marker" => true)
expect(post.amount).to eq(1)
expect(post.project_id).to eq(2)
expect(post.tags).to eq(%w[first])
expect(post.marker).to eq(true)
end

it "updates fields" do
post.update(tags: %w[first])
expect(post.reload.metadata.to_h).to eq("tags" => %w[first])
expect(post.tags).to eq(%w[first])
end

it "updates on mutate fields" do
post.tags.push("third")
post.save_changes
expect(post.reload.metadata.to_h).to eq("tags" => %w[first second third])
expect(post.tags).to eq(%w[first second third])
end

it "updates on mutate store" do
post.metadata[:marker] = true
post.save_changes
expect(post.reload.metadata.to_h).to eq("tags" => %w[first second], "marker" => true)
expect(post.tags).to eq(%w[first second])
expect(post.marker).to eq(true)
end

it "updates from nil" do
post.update(data: nil)
post.amount = 20
post.save_changes
expect(post.reload.data).to eq("amount" => 20)
expect(post.amount).to eq(20)
end
end
2 changes: 1 addition & 1 deletion umbrellio-sequel-plugins.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ lib = File.expand_path("lib", __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)

Gem::Specification.new do |spec|
gem_version = "0.10.0"
gem_version = "0.11.0"

if ENV.fetch("PUBLISH_JOB", nil)
release_version = "#{gem_version}.#{ENV.fetch("GITHUB_RUN_NUMBER")}"
Expand Down
2 changes: 1 addition & 1 deletion utils/database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

require "logger"

DB = Sequel.connect(ENV.fetch("DB_URL", "postgres://localhost/sequel_plugins"))
DB = Sequel.connect(ENV.fetch("DB_URL", "postgres:///sequel_plugins"))
DB.logger = Logger.new("log/db.log")

Sequel::Model.db = DB
Expand Down

0 comments on commit 642dfe0

Please sign in to comment.