forest_liana 9.20.7 → 9.20.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed69fd6aba8fea8a03a02738a6f9ca6e75ae334980e501470dbabe1d58c37497
4
- data.tar.gz: a6439711199628972e57f782968244c096566919801510d2193e3d16fbe5d800
3
+ metadata.gz: 8081936e6d6a125213c778a94576d1eaaf5560eb838a89563a6e113ab79a2e46
4
+ data.tar.gz: b5cb7df552d00872c40981ee37009b534bb5a254ee637d7dd39dd71b840f6124
5
5
  SHA512:
6
- metadata.gz: e6101bb2d0ea3dfe2b3944cdf5d2ca3391ecd26450653517dd30553ffd4075dcb0cb23b6659996303259f6bd931c8054a58986f9337adc5417f94df8f1aac8b2
7
- data.tar.gz: 3de7e6bc1710e6e8f8e1c8b1857775091e7d5d707d57c9f7f4dd1a993c7fbac111e350a88d4623e90357b8815aaf74a1de6a01710c01a81bd039b465ca19f600
6
+ metadata.gz: a1e231f576b02caed5a80f12fff4d6baabd4355ef9f5aae79afb3f4a6663e29cda2cdd078088e4949d42435c1bcf8f28b82bc7c90c619f971e17da7fa81bd45a
7
+ data.tar.gz: 0a93106150c9ead61198c464baaab09c0eb0974abc5c78570e980baa48bd3a05bdb4677720fa8b35704375665c06b297cad763325249601d08962222a9c444d4
@@ -83,6 +83,10 @@ module ForestLiana
83
83
  value = condition['value']
84
84
  field_name = condition['field']
85
85
 
86
+ # NOTICE: captured before the enum mapping below, which turns an unknown
87
+ # enum value into nil and would otherwise look like a null filter.
88
+ null_value = value.nil?
89
+
86
90
  if @operator_date_parser.is_date_operator?(operator)
87
91
  field_schema = SchemaUtils.find_column_schema_by_name(ForestLiana.name_for(@resource), field_name)
88
92
  condition = @operator_date_parser.get_date_filter(operator, value, field_schema)
@@ -99,6 +103,14 @@ module ForestLiana
99
103
 
100
104
  case_insensitive = operator == 'i_contains'
101
105
  parsed_field = parse_field_name(field_name, case_insensitive)
106
+
107
+ # NOTICE: the UI sends a null equality filter as a JSON null; `= NULL` and
108
+ # `!= NULL` never match anything in SQL.
109
+ if null_value
110
+ return "#{parsed_field} IS NULL" if operator == 'equal'
111
+ return "#{parsed_field} IS NOT NULL" if operator == 'not_equal'
112
+ end
113
+
102
114
  parsed_operator = parse_operator(operator)
103
115
  parsed_value = parse_value(operator, value)
104
116
  field_and_operator = "#{parsed_field} #{parsed_operator}"
@@ -85,7 +85,7 @@ module ForestLiana
85
85
  !@resource.defined_enums[column.name][@search.downcase].nil?
86
86
  conditions << "#{column_name} =
87
87
  #{@resource.defined_enums[column.name][@search.downcase]}"
88
- elsif !(column.respond_to?(:array) && column.array) && text_type?(column.type)
88
+ elsif !(column.respond_to?(:array) && column.array) && text_type?(column.type) && !malformed_uuid_search?
89
89
  conditions << "LOWER(#{column_name}) LIKE :search_value_for_string"
90
90
  end
91
91
  end
@@ -114,7 +114,7 @@ module ForestLiana
114
114
  resource = @resource.reflect_on_association(association.to_sym)
115
115
  unless (SchemaUtils.polymorphic?(resource))
116
116
  resource.klass.columns.each do |column|
117
- if !(column.respond_to?(:array) && column.array) && text_type?(column.type)
117
+ if !(column.respond_to?(:array) && column.array) && text_type?(column.type) && !malformed_uuid_search?
118
118
  if @collection.search_fields.nil? || (association_search &&
119
119
  association_search.include?(column.name))
120
120
  conditions << association_search_condition(resource.table_name,
@@ -138,7 +138,7 @@ module ForestLiana
138
138
  unless association_search.empty?
139
139
  resource = @resource.reflect_on_association(association.to_sym)
140
140
  resource.klass.columns.each do |column|
141
- if !(column.respond_to?(:array) && column.array) && text_type?(column.type)
141
+ if !(column.respond_to?(:array) && column.array) && text_type?(column.type) && !malformed_uuid_search?
142
142
  if association_search.include?(column.name)
143
143
  conditions << association_search_condition(resource.table_name,
144
144
  column.name)
@@ -150,11 +150,18 @@ module ForestLiana
150
150
  end
151
151
  end
152
152
 
153
- @records = @resource.where(
154
- conditions.join(' OR '),
155
- search_value_for_string: "%#{@search.downcase}%",
156
- search_value_for_uuid: @search.to_s
157
- ) unless conditions.empty?
153
+ if conditions.empty?
154
+ # NOTICE: a malformed-UUID search suppresses the only conditions it could
155
+ # have produced (text LIKE scans); match nothing rather than fall
156
+ # through to an unfiltered query that returns the whole table.
157
+ @records = @resource.none if malformed_uuid_search?
158
+ else
159
+ @records = @resource.where(
160
+ conditions.join(' OR '),
161
+ search_value_for_string: "%#{@search.downcase}%",
162
+ search_value_for_uuid: @search.to_s
163
+ )
164
+ end
158
165
  end
159
166
 
160
167
  @records
@@ -223,5 +230,17 @@ module ForestLiana
223
230
  def text_type?(type_sym)
224
231
  [:string, :text, :citext].include? type_sym
225
232
  end
233
+
234
+ # NOTICE: A search that is UUID-shaped but fails the strict REGEX_UUID (a
235
+ # truncated or mistyped UUID) can never match a real UUID column and,
236
+ # on text columns, only triggers `LOWER(col) LIKE '%…%'` sequential
237
+ # scans that can hit the statement timeout. Valid UUIDs are excluded
238
+ # so they keep matching UUIDs stored in varchar/text columns.
239
+ def malformed_uuid_search?
240
+ return false unless @search.is_a?(String)
241
+
242
+ @search.match?(/\A[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+\z/i) &&
243
+ !REGEX_UUID.match?(@search)
244
+ end
226
245
  end
227
246
  end
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.20.7"
2
+ VERSION = "9.20.9"
3
3
  end
@@ -45,6 +45,29 @@ module ForestLiana
45
45
  it { expect(parsed_filters.count).to eq 1 }
46
46
  end
47
47
 
48
+ context 'equal on a null value (JSON null from the UI)' do
49
+ before do
50
+ Tree.create!(name: nil, age: 42, island: Island.first)
51
+ Tree.create!(name: 'null', age: 43, island: Island.first)
52
+ end
53
+
54
+ let(:filters) { { 'field' => 'name', 'operator' => 'equal', 'value' => nil } }
55
+
56
+ it 'matches rows where the column IS NULL, not the literal string "null"' do
57
+ expect(parsed_filters.pluck(:name)).to eq([nil])
58
+ end
59
+ end
60
+
61
+ context 'not_equal on a null value (JSON null from the UI)' do
62
+ before { Tree.create!(name: nil, age: 42, island: Island.first) }
63
+
64
+ let(:filters) { { 'field' => 'name', 'operator' => 'not_equal', 'value' => nil } }
65
+
66
+ it 'matches rows where the column IS NOT NULL' do
67
+ expect(parsed_filters.pluck(:name)).to match_array(['Tree n1', 'Tree n2', 'Tree n3'])
68
+ end
69
+ end
70
+
48
71
  context 'greater_than' do
49
72
  let(:filters) { { 'field' => 'age', 'operator' => 'greater_than', 'value' => 2 } }
50
73
  it { expect(parsed_filters.count).to eq 2 }
@@ -107,6 +130,14 @@ module ForestLiana
107
130
  it { expect(parsed_filters.count).to eq 0 }
108
131
  end
109
132
 
133
+ context 'equal on an unknown enum value' do
134
+ # The enum mapping nils an unknown value; the null-filter guard is
135
+ # keyed on the pre-mapping value so it must NOT become `IS NULL`
136
+ # (that would wrongly match rows whose joined cutter is absent).
137
+ let(:filters) { { 'field' => 'cutter:title', 'operator' => 'equal', 'value' => 'not_a_title' } }
138
+ it { expect(parsed_filters.count).to eq 0 }
139
+ end
140
+
110
141
  context 'contains' do
111
142
  let(:filters) { { 'field' => 'owner:title', 'operator' => 'contains', 'value' => 'in' } }
112
143
  it { expect(parsed_filters.count).to eq 3 }
@@ -16,6 +16,50 @@ module ForestLiana
16
16
  end
17
17
 
18
18
  describe '#perform' do
19
+ context 'when the search is a malformed UUID' do
20
+ # UUID-shaped but fails the strict REGEX_UUID (mistyped/truncated group).
21
+ # Starts with a letter so #to_i is 0 and adds no integer-`id` condition,
22
+ # leaving the text LIKE scans as the only possible matches.
23
+ let(:params) { { search: 'abcdef12-3456-4ae-ad4f-5662757713a2', searchExtended: '0' } }
24
+
25
+ before do
26
+ Tree.create!(name: 'Oak')
27
+ Tree.create!(name: 'Elm')
28
+ end
29
+
30
+ after { Tree.destroy_all }
31
+
32
+ it 'matches nothing instead of returning the unfiltered table' do
33
+ expect(builder.perform(Tree.all).count).to eq(0)
34
+ end
35
+ end
36
+
37
+ context 'when searchExtended is on and the search is a malformed UUID' do
38
+ # HashWithIndifferentAccess: the code reads @params['searchExtended'] (string).
39
+ let(:params) do
40
+ ActiveSupport::HashWithIndifferentAccess.new(
41
+ search: 'abcdef12-3456-4ae-ad4f-5662757713a2', searchExtended: '1'
42
+ )
43
+ end
44
+ let(:builder) { described_class.new(params, [:owner], collection, user) }
45
+
46
+ before { Tree.create!(name: 'Oak') }
47
+
48
+ after { Tree.destroy_all }
49
+
50
+ it 'does not build LIKE scans on associated text columns either' do
51
+ expect(builder.perform(Tree.all).to_sql).not_to match(/LIKE/i)
52
+ end
53
+ end
54
+
55
+ context 'when the search is a valid UUID' do
56
+ let(:params) { { search: '75fbcb43-f6f8-4cd1-861f-09a61fd1ddad', searchExtended: '0' } }
57
+
58
+ it 'still builds a LIKE scan on text columns (UUIDs stored as text)' do
59
+ expect(builder.perform(Tree.all).to_sql).to match(/LIKE/i)
60
+ end
61
+ end
62
+
19
63
  context 'when a column is an array uuid type' do
20
64
  let(:array_uuid_column) do
21
65
  double('Column', name: 'attachment_ids', type: :uuid, array: true).tap do |col|
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_liana
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.20.7
4
+ version: 9.20.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sandro Munda
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-03 00:00:00.000000000 Z
11
+ date: 2026-08-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails