diff --git a/README.md b/README.md index 2761c94..04cfccd 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,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. diff --git a/lib/jsonb_accessor/helpers.rb b/lib/jsonb_accessor/helpers.rb index d26a118..b9bd9f9 100644 --- a/lib/jsonb_accessor/helpers.rb +++ b/lib/jsonb_accessor/helpers.rb @@ -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 diff --git a/lib/jsonb_accessor/macro.rb b/lib/jsonb_accessor/macro.rb index 3d751a5..ed3fb0b 100644 --- a/lib/jsonb_accessor/macro.rb +++ b/lib/jsonb_accessor/macro.rb @@ -9,16 +9,26 @@ 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 @@ -26,6 +36,12 @@ def jsonb_accessor(jsonb_attribute, field_types) 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 @@ -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) @@ -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) @@ -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 @@ -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 diff --git a/spec/jsonb_accessor_spec.rb b/spec/jsonb_accessor_spec.rb index 57ccef6..4a5cdd0 100644 --- a/spec/jsonb_accessor_spec.rb +++ b/spec/jsonb_accessor_spec.rb @@ -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 "#_where" do let(:klass) do build_class( diff --git a/spec/lib/jsonb_accessor/helpers_spec.rb b/spec/lib/jsonb_accessor/helpers_spec.rb index ff53521..90085e4 100644 --- a/spec/lib/jsonb_accessor/helpers_spec.rb +++ b/spec/lib/jsonb_accessor/helpers_spec.rb @@ -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