Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prefix / Suffix feature #177

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ product.title #=> "Foo"
product.data #=> { "t" => "Foo" }
```

You can also pass in a `prefix` or `suffix` option.

```ruby
class Product < ActiveRecord::Base
jsonb_accessor :data,
title: [:string, prefix: :data],
external_id: [:integer, suffix: :attr]
end
```

This allows you to use `data_title` and `external_id_attr` for your getters and setters, but use `title` and `external_id` as the key in the `jsonb`.
Also, you can pass `true` as a value for `prefix` or `suffix` to use the json_accessor name.

```ruby
product = Product.new(data_title: "Foo", external_id_attr: 12314122)
product.data_title #=> "Foo"
product.external_id_attr #=> 12314122
product.data #=> { "title" => "Foo", "external_id" => 12314122 }
```

## Scopes

Jsonb Accessor provides several scopes to make it easier to query `jsonb` columns. `jsonb_contains`, `jsonb_number_where`, `jsonb_time_where`, and `jsonb_where` are available on all `ActiveRecord::Base` subclasses and don't require that you make use of the `jsonb_accessor` declaration.
Expand Down
23 changes: 23 additions & 0 deletions lib/jsonb_accessor/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,28 @@ def parse_date(datetime)
Time.zone.parse(datetime)
end
end

def define_attribute_name(json_attribute, name, prefix, suffix)
accessor_prefix =
case prefix
when String, Symbol
"#{prefix}_"
when TrueClass
"#{json_attribute}_"
else
""
end
accessor_suffix =
case suffix
when String, Symbol
"_#{suffix}"
when TrueClass
"_#{json_attribute}"
else
""
end

"#{accessor_prefix}#{name}#{accessor_suffix}"
end
end
end
42 changes: 32 additions & 10 deletions lib/jsonb_accessor/macro.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,39 @@ def jsonb_accessor(jsonb_attribute, field_types)
mapping[name.to_s] = (options.try(:delete, :store_key) || name).to_s
end

# Get field names to attribute names
names_and_attribute_names = field_types.each_with_object({}) do |(name, type), mapping|
_type, options = Array(type)
prefix = options.try(:delete, :prefix)
suffix = options.try(:delete, :suffix)
mapping[name.to_s] = JsonbAccessor::Helpers.define_attribute_name(jsonb_attribute, name, prefix, suffix)
end

# Defines virtual attributes for each jsonb field.
field_types.each do |name, type|
next attribute name, type unless type.is_a?(Array)
next attribute name, *type unless type.last.is_a?(Hash)
attribute_name = names_and_attribute_names[name.to_s]
next attribute attribute_name, type unless type.is_a?(Array)
next attribute attribute_name, *type unless type.last.is_a?(Hash)

*args, keyword_args = type
attribute name, *args, **keyword_args
attribute attribute_name, *args, **keyword_args
end

store_key_mapping_method_name = "jsonb_store_key_mapping_for_#{jsonb_attribute}"
attribute_name_mapping_method_name = "jsonb_attribute_name_mapping_for_#{jsonb_attribute}"
# Defines methods on the model class
class_methods = Module.new do
# Allows us to get a mapping of field names to store keys scoped to the column
define_method(store_key_mapping_method_name) do
superclass_mapping = superclass.try(store_key_mapping_method_name) || {}
superclass_mapping.merge(names_and_store_keys)
end

# Allows us to get a mapping of field names to attribute names scoped to the column
define_method(attribute_name_mapping_method_name) do
superclass_mapping = superclass.try(attribute_name_mapping_method_name) || {}
superclass_mapping.merge(names_and_attribute_names)
end
end
# We extend with class methods here so we can use the results of methods it defines to define more useful methods later
extend class_methods
Expand Down Expand Up @@ -63,11 +79,13 @@ def jsonb_accessor(jsonb_attribute, field_types)
setters = Module.new do
# Overrides the setter created by `attribute` above to make sure the jsonb attribute is kept in sync.
names_and_store_keys.each do |name, store_key|
define_method("#{name}=") do |value|
attribute_name = names_and_attribute_names[name]

define_method("#{attribute_name}=") do |value|
super(value)

# If enum was defined, take the value from the enum and not what comes out directly from the getter
attribute_value = defined_enums[name].present? ? defined_enums[name][value] : public_send(name)
attribute_value = defined_enums[attribute_name].present? ? defined_enums[attribute_name][value] : public_send(attribute_name)

# Rails always saves time based on `default_timezone`. Since #as_json considers timezone, manual conversion is needed
if attribute_value.acts_like?(:time)
Expand All @@ -83,6 +101,7 @@ def jsonb_accessor(jsonb_attribute, field_types)
define_method("#{jsonb_attribute}=") do |value|
value ||= {}
names_to_store_keys = self.class.public_send(store_key_mapping_method_name)
names_to_attribute_names = self.class.public_send(attribute_name_mapping_method_name)

# this is the raw hash we want to save in the jsonb_attribute
value_with_store_keys = JsonbAccessor::Helpers.convert_keys_to_store_keys(value, names_to_store_keys)
Expand All @@ -96,7 +115,8 @@ def jsonb_accessor(jsonb_attribute, field_types)
# Only proceed if this attribute has been defined using `jsonb_accessor`.
next unless names_to_store_keys.key?(name)

write_attribute(name, attribute_value)
attribute_name = names_to_attribute_names[name]
write_attribute(attribute_name, attribute_value)
end
end
end
Expand All @@ -109,13 +129,15 @@ def jsonb_accessor(jsonb_attribute, field_types)
jsonb_values = public_send(jsonb_attribute) || {}
jsonb_values.each do |store_key, value|
name = names_and_store_keys.key(store_key)
next unless name
attribute_name = names_and_attribute_names[name]

next unless attribute_name

write_attribute(
name,
JsonbAccessor::Helpers.deserialize_value(value, self.class.type_for_attribute(name).type)
attribute_name,
JsonbAccessor::Helpers.deserialize_value(value, self.class.type_for_attribute(attribute_name).type)
)
clear_attribute_change(name) if persisted?
clear_attribute_change(attribute_name) if persisted?
end
end

Expand Down
188 changes: 188 additions & 0 deletions spec/jsonb_accessor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,194 @@ def build_class(jsonb_accessor_config, &block)
end
end

context "prefixes" do
let(:klass) do
build_class(foo: [:string, { default: "bar", prefix: :a }])
end

it "creates accessor attribute with the given prefix" do
expect(instance.a_foo).to eq("bar")
expect(instance.options).to eq("foo" => "bar")
end

context "when prefix is true" do
let(:klass) do
build_class(foo: [:string, { default: "bar", prefix: true }])
end

it "creates accessor attribute with the json_attribute name" do
expect(instance.options_foo).to eq("bar")
expect(instance.options).to eq("foo" => "bar")
end
end

context "inheritance" do
let(:subklass) do
Class.new(klass) do
jsonb_accessor :options, bar: [:integer, { default: 2 }]
end
end
let(:subklass_instance) { subklass.new }

it "includes default values from the parent in the jsonb hash" do
expect(subklass_instance.a_foo).to eq("bar")
expect(subklass_instance.bar).to eq(2)
expect(subklass_instance.options).to eq("foo" => "bar", "bar" => 2)
end
end

context "inheritance with prefix" do
let(:subklass) do
Class.new(klass) do
jsonb_accessor :options, bar: [:integer, { default: 2, prefix: :b }]
end
end

let(:subklass_instance) { subklass.new }

it "includes default values from the parent in the jsonb hash" do
expect(subklass_instance.a_foo).to eq("bar")
expect(subklass_instance.b_bar).to eq(2)
expect(subklass_instance.options).to eq("foo" => "bar", "bar" => 2)
end
end

context "with store keys" do
let(:klass) do
build_class(foo: [:string, { default: "bar", store_key: :g, prefix: :a }])
end

it "creates accessor attribute with the given prefix and with the given store key" do
expect(instance.a_foo).to eq("bar")
expect(instance.options).to eq("g" => "bar")
end

context "inheritance" do
let(:subklass) do
Class.new(klass) do
jsonb_accessor :options, bar: [:integer, { default: 2, store_key: :h }]
end
end
let(:subklass_instance) { subklass.new }

it "includes default values from the parent in the jsonb hash with the correct store keys" do
expect(subklass_instance.a_foo).to eq("bar")
expect(subklass_instance.bar).to eq(2)
expect(subklass_instance.options).to eq("g" => "bar", "h" => 2)
end
end

context "inheritance with prefix" do
let(:subklass) do
Class.new(klass) do
jsonb_accessor :options, bar: [:integer, { default: 2, store_key: :i, prefix: :b }]
end
end
let(:subklass_instance) { subklass.new }

it "includes default values from the parent in the jsonb hash with the correct store keys" do
expect(subklass_instance.a_foo).to eq("bar")
expect(subklass_instance.b_bar).to eq(2)
expect(subklass_instance.options).to eq("g" => "bar", "i" => 2)
end
end
end
end

context "suffixes" do
let(:klass) do
build_class(foo: [:string, { default: "bar", suffix: :a }])
end

it "creates accessor attribute with the given suffix" do
expect(instance.foo_a).to eq("bar")
expect(instance.options).to eq("foo" => "bar")
end

context "when suffix is true" do
let(:klass) do
build_class(foo: [:string, { default: "bar", suffix: true }])
end

it "creates accessor attribute with the json_attribute name" do
expect(instance.foo_options).to eq("bar")
expect(instance.options).to eq("foo" => "bar")
end
end

context "inheritance" do
let(:subklass) do
Class.new(klass) do
jsonb_accessor :options, bar: [:integer, { default: 2 }]
end
end
let(:subklass_instance) { subklass.new }

it "includes default values from the parent in the jsonb hash" do
expect(subklass_instance.foo_a).to eq("bar")
expect(subklass_instance.bar).to eq(2)
expect(subklass_instance.options).to eq("foo" => "bar", "bar" => 2)
end
end

context "inheritance with suffix" do
let(:subklass) do
Class.new(klass) do
jsonb_accessor :options, bar: [:integer, { default: 2, suffix: :b }]
end
end

let(:subklass_instance) { subklass.new }

it "includes default values from the parent in the jsonb hash" do
expect(subklass_instance.foo_a).to eq("bar")
expect(subklass_instance.bar_b).to eq(2)
expect(subklass_instance.options).to eq("foo" => "bar", "bar" => 2)
end
end

context "with store keys" do
let(:klass) do
build_class(foo: [:string, { default: "bar", store_key: :g, suffix: :a }])
end

it "creates accessor attribute with the given suffix and with the given store key" do
expect(instance.foo_a).to eq("bar")
expect(instance.options).to eq("g" => "bar")
end

context "inheritance" do
let(:subklass) do
Class.new(klass) do
jsonb_accessor :options, bar: [:integer, { default: 2, store_key: :h }]
end
end
let(:subklass_instance) { subklass.new }

it "includes default values from the parent in the jsonb hash with the correct store keys" do
expect(subklass_instance.foo_a).to eq("bar")
expect(subklass_instance.bar).to eq(2)
expect(subklass_instance.options).to eq("g" => "bar", "h" => 2)
end
end

context "inheritance with suffix" do
let(:subklass) do
Class.new(klass) do
jsonb_accessor :options, bar: [:integer, { default: 2, store_key: :i, suffix: :b }]
end
end
let(:subklass_instance) { subklass.new }

it "includes default values from the parent in the jsonb hash with the correct store keys" do
expect(subklass_instance.foo_a).to eq("bar")
expect(subklass_instance.bar_b).to eq(2)
expect(subklass_instance.options).to eq("g" => "bar", "i" => 2)
end
end
end
end

describe "#<jsonb_attribute>_where" do
let(:klass) do
build_class(
Expand Down
32 changes: 32 additions & 0 deletions spec/lib/jsonb_accessor/helpers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,36 @@
expect(subject.convert_store_keys_to_keys(attributes, store_key_mapping)).to eq(expected)
end
end

describe ".define_attribute_name" do
let(:json_attribute) { :options }
let(:name) { :foo }
let(:prefix) { :pref }
let(:suffix) { :suff }
let(:expected) { "#{prefix}_#{name}_#{suffix}" }

it "returns attribute name with prefix and suffix" do
expect(subject.define_attribute_name(json_attribute, name, prefix, suffix)).to eq(expected)
end

context "when affixes is true class" do
let(:prefix) { true }
let(:suffix) { true }
let(:expected) { "#{json_attribute}_#{name}_#{json_attribute}" }

it "returns attribute name with json_attribute prefix and suffix" do
expect(subject.define_attribute_name(json_attribute, name, prefix, suffix)).to eq(expected)
end
end

context "when affixes is nil" do
let(:prefix) { nil }
let(:suffix) { nil }
let(:expected) { name.to_s }

it "returns attribute name without prefix and suffix" do
expect(subject.define_attribute_name(json_attribute, name, prefix, suffix)).to eq(expected)
end
end
end
end
Loading