From 5b1d867ed833e4b18372dc6d86c73d93dfbdc4ce Mon Sep 17 00:00:00 2001 From: Andrew Konchin Date: Sat, 5 Oct 2024 23:29:26 +0300 Subject: [PATCH] Fix field names with special characters in where conditions --- .../aws_sdk_v3/filter_expression_convertor.rb | 38 ++-- .../projection_expression_convertor.rb | 16 +- spec/dynamoid/criteria/chain_spec.rb | 170 ++++++++++++++++++ 3 files changed, 200 insertions(+), 24 deletions(-) diff --git a/lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb b/lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb index 7698dcb7..8f9b50ca 100644 --- a/lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb +++ b/lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb @@ -22,36 +22,42 @@ def initialize(conditions, name_placeholders, value_placeholders, name_placehold def build clauses = @conditions.map do |name, attribute_conditions| attribute_conditions.map do |operator, value| - name_or_placeholder = name_or_placeholder_for(name) + # replace attribute names with placeholders unconditionally to support + # - special characters (e.g. '.', ':', and '#') and + # - leading '_' + # See + # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.NamingRules + # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ExpressionAttributeNames.html#Expressions.ExpressionAttributeNames.AttributeNamesContainingSpecialCharacters + name_placeholder = name_placeholder_for(name) case operator when :eq - "#{name_or_placeholder} = #{value_placeholder_for(value)}" + "#{name_placeholder} = #{value_placeholder_for(value)}" when :ne - "#{name_or_placeholder} <> #{value_placeholder_for(value)}" + "#{name_placeholder} <> #{value_placeholder_for(value)}" when :gt - "#{name_or_placeholder} > #{value_placeholder_for(value)}" + "#{name_placeholder} > #{value_placeholder_for(value)}" when :lt - "#{name_or_placeholder} < #{value_placeholder_for(value)}" + "#{name_placeholder} < #{value_placeholder_for(value)}" when :gte - "#{name_or_placeholder} >= #{value_placeholder_for(value)}" + "#{name_placeholder} >= #{value_placeholder_for(value)}" when :lte - "#{name_or_placeholder} <= #{value_placeholder_for(value)}" + "#{name_placeholder} <= #{value_placeholder_for(value)}" when :between - "#{name_or_placeholder} BETWEEN #{value_placeholder_for(value[0])} AND #{value_placeholder_for(value[1])}" + "#{name_placeholder} BETWEEN #{value_placeholder_for(value[0])} AND #{value_placeholder_for(value[1])}" when :begins_with - "begins_with (#{name_or_placeholder}, #{value_placeholder_for(value)})" + "begins_with (#{name_placeholder}, #{value_placeholder_for(value)})" when :in list = value.map(&method(:value_placeholder_for)).join(' , ') - "#{name_or_placeholder} IN (#{list})" + "#{name_placeholder} IN (#{list})" when :contains - "contains (#{name_or_placeholder}, #{value_placeholder_for(value)})" + "contains (#{name_placeholder}, #{value_placeholder_for(value)})" when :not_contains - "NOT contains (#{name_or_placeholder}, #{value_placeholder_for(value)})" + "NOT contains (#{name_placeholder}, #{value_placeholder_for(value)})" when :null - "attribute_not_exists (#{name_or_placeholder})" + "attribute_not_exists (#{name_placeholder})" when :not_null - "attribute_exists (#{name_or_placeholder})" + "attribute_exists (#{name_placeholder})" end end end.flatten @@ -59,9 +65,7 @@ def build @expression = clauses.join(' AND ') end - def name_or_placeholder_for(name) - return name unless name.upcase.in?(Dynamoid::AdapterPlugin::AwsSdkV3::RESERVED_WORDS) - + def name_placeholder_for(name) placeholder = @name_placeholder_sequence.call @name_placeholders[placeholder] = name placeholder diff --git a/lib/dynamoid/adapter_plugin/aws_sdk_v3/projection_expression_convertor.rb b/lib/dynamoid/adapter_plugin/aws_sdk_v3/projection_expression_convertor.rb index 1b71f1c4..aeadf241 100644 --- a/lib/dynamoid/adapter_plugin/aws_sdk_v3/projection_expression_convertor.rb +++ b/lib/dynamoid/adapter_plugin/aws_sdk_v3/projection_expression_convertor.rb @@ -21,13 +21,15 @@ def build return if @names.nil? || @names.empty? clauses = @names.map do |name| - if name.upcase.in?(Dynamoid::AdapterPlugin::AwsSdkV3::RESERVED_WORDS) - placeholder = @name_placeholder_sequence.call - @name_placeholders[placeholder] = name - placeholder - else - name.to_s - end + # replace attribute names with placeholders unconditionally to support + # - special characters (e.g. '.', ':', and '#') and + # - leading '_' + # See + # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.NamingRules + # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ExpressionAttributeNames.html#Expressions.ExpressionAttributeNames.AttributeNamesContainingSpecialCharacters + placeholder = @name_placeholder_sequence.call + @name_placeholders[placeholder] = name + placeholder end @expression = clauses.join(' , ') diff --git a/spec/dynamoid/criteria/chain_spec.rb b/spec/dynamoid/criteria/chain_spec.rb index d611ea9f..a3ca7bcb 100644 --- a/spec/dynamoid/criteria/chain_spec.rb +++ b/spec/dynamoid/criteria/chain_spec.rb @@ -227,6 +227,30 @@ def request_params expect(documents.map(&:id)).to eql ['1'] end + it 'allows conditions with attribute names containing special characters' do + model = new_class do + range :'sort:key' + end + + model.create_table + put_attributes(model.table_name, id: '1', 'sort:key': 'c') + + documents = model.where(id: '1', 'sort:key': 'c').to_a + expect(documents.map(&:id)).to eql ['1'] + end + + it 'allows conditions with attribute names starting with _' do + model = new_class do + range :_sortKey + end + + model.create_table + put_attributes(model.table_name, id: '1', _sortKey: 'c') + + documents = model.where(id: '1', _sortKey: 'c').to_a + expect(documents.map(&:id)).to eql ['1'] + end + it 'raises error when operator is not supported' do expect do model.where(name: 'Bob', 'age.foo': 10).to_a @@ -443,6 +467,30 @@ def request_params expect(documents.map(&:id)).to eql ['1'] end + it 'allows conditions with attribute names containing special characters' do + model = new_class do + field :'last:name' + end + + model.create_table + put_attributes(model.table_name, id: '1', 'last:name': 'c') + + documents = model.where(id: '1', 'last:name': 'c').to_a + expect(documents.map(&:id)).to eql ['1'] + end + + it 'allows conditions with attribute names starting with _' do + model = new_class do + field :_lastName + end + + model.create_table + put_attributes(model.table_name, id: '1', _lastName: 'c') + + documents = model.where(id: '1', _lastName: 'c').to_a + expect(documents.map(&:id)).to eql ['1'] + end + it 'raises error when operator is not supported' do expect do model.where(name: 'a', 'age.foo': 9).to_a @@ -659,6 +707,30 @@ def request_params expect(documents.map(&:id)).to eql ['1'] end + it 'allows conditions with attribute names containing special characters' do + model = new_class do + field :'last:name' + end + + model.create_table + put_attributes(model.table_name, id: '1', 'last:name': 'c') + + documents = model.where('last:name': 'c').to_a + expect(documents.map(&:id)).to eql ['1'] + end + + it 'allows conditions with attribute names starting with _' do + model = new_class do + field :_lastName + end + + model.create_table + put_attributes(model.table_name, id: '1', _lastName: 'c') + + documents = model.where(_lastName: 'c').to_a + expect(documents.map(&:id)).to eql ['1'] + end + it 'raises error when operator is not supported' do expect do model.where('age.foo': 9).to_a @@ -1903,6 +1975,62 @@ def request_params expect(obj.attributes).to eq(bucket: 2) end end + + context 'when attribute name contains special characters' do + let(:model) do + new_class do + field :'first:name' + end + end + + it 'works with Scan' do + model.create('first:name': 'Alex') + + chain = described_class.new(model) + expect(chain).to receive(:raw_pages_via_scan).and_call_original + + obj, = chain.project(:'first:name').to_a + expect(obj.attributes).to eq('first:name': 'Alex') + end + + it 'works with Query' do + object = model.create('first:name': 'Alex') + + chain = described_class.new(model) + expect(chain).to receive(:raw_pages_via_query).and_call_original + + obj, = chain.where(id: object.id).project(:'first:name').to_a + expect(obj.attributes).to eq('first:name': 'Alex') + end + end + + context 'when attribute name starts with _' do + let(:model) do + new_class do + field :_name + end + end + + it 'works with Scan' do + model.create(_name: 'Alex') + + chain = described_class.new(model) + expect(chain).to receive(:raw_pages_via_scan).and_call_original + + obj, = chain.project(:_name).to_a + expect(obj.attributes).to eq(_name: 'Alex') + end + + it 'works with Query' do + object = model.create(_name: 'Alex') + + chain = described_class.new(model) + expect(chain).to receive(:raw_pages_via_query).and_call_original + + obj, = chain.where(id: object.id).project(:_name).to_a + expect(obj.attributes).to eq(_name: 'Alex') + end + end end describe '#pluck' do @@ -2007,6 +2135,48 @@ def request_params expect(model.where(id: object.id).pluck(:bucket)).to eq([1001]) end end + + context 'when attribute name contains special characters' do + let(:model) do + new_class do + field :'first:name' + end + end + + it 'works with Scan' do + model.create('first:name': 'Alice') + model.create('first:name': 'Bob') + + expect(model.pluck(:'first:name')).to contain_exactly('Alice', 'Bob') + end + + it 'works with Query' do + object = model.create('first:name': 'Alice') + + expect(model.where(id: object.id).pluck(:'first:name')).to eq(['Alice']) + end + end + + context 'when attribute name starts with _' do + let(:model) do + new_class do + field :_name + end + end + + it 'works with Scan' do + model.create(_name: 'Alice') + model.create(_name: 'Bob') + + expect(model.pluck(:_name)).to contain_exactly('Alice', 'Bob') + end + + it 'works with Query' do + object = model.create(_name: 'Alice') + + expect(model.where(id: object.id).pluck(:_name)).to eq(['Alice']) + end + end end describe 'User' do