arel-extensions 9.0.0 → 9.1.0

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: 2cac2b9e201296fa2c9e058f38cd0acf4835b134f2f98bb74f245af78b58f02c
4
- data.tar.gz: 8b4be319b4037c1dfb2c54d6d9314a6add237ac60ec1fe2e6e1f0d396d67d1bb
3
+ metadata.gz: f3d568adf807cce6bc64a82bdf1ec35047475de73c85d0e4db25a8f452064869
4
+ data.tar.gz: 4a3724dca9138cf0d1220346d010254247ae92c4ebcfd8dae629d0455a2e6b5b
5
5
  SHA512:
6
- metadata.gz: 36818826669a7004657c3362908fc8c11bb683fabbf93183dde33cb4a933ce3e5bd41da637cfd2c48906fab72d8ed22992802aa44daa69d7919cc3ccc62faffe
7
- data.tar.gz: 17ae44d0099f00b6193f3e94aaa50830556e191d728bc3580b4164fae8f4b6b9f808cea2d3925ffe73cd5a30ea5501be6d393b428e23b01d6b4ee63dfd9d5d32
6
+ metadata.gz: 4f2f03dea174a2e6b4d708dc0a6fe7bb6e43af18135a4a4802fbb844278139bb7690d2d2dff38d9436767f49442d2483c1142dfa89c2555d92f97efc82813292
7
+ data.tar.gz: 85e605b87fb83bc2d36edd0ccab58fb85d15bebf0bbb4cf74b05a249354800664d5ae656f09141b7c0a50bb49e5c8b2d0649b331d5ac6d67b78cc378a0296e3f
data/CHANGELOG.md CHANGED
@@ -1,4 +1,46 @@
1
- ## [9.0.0] - Unreleased
1
+ ## [9.1.0] - 2026-09-10
2
+
3
+ ### Added
4
+
5
+ - PostgreSQL's positional range operators on attributes: `strictly_left_of`
6
+ (`<<`), `strictly_right_of` (`>>`), `not_extend_right_of` (`&<`),
7
+ `not_extend_left_of` (`&>`) and `adjacent_to` (`-|-`).
8
+
9
+ ### Changed
10
+
11
+ - The Sunstone visitor handles `Arel::Nodes::Not` in place of the
12
+ `Arel::Nodes::NotOverlaps` it used to name, which never existed — so
13
+ `attribute.overlaps(x).not` now serializes where nothing could before.
14
+
15
+ ### Fixed
16
+
17
+ - `contained_by` now accepts a Ruby Range, so range columns work with all three
18
+ range predicates.
19
+
20
+ ## [9.0.1] - 2026-08-30
21
+
22
+ ### Security
23
+ - Fixed a SQL injection in the PostgreSQL visitor's JSON path handling
24
+ ([GHSA-75hc-9q9v-9cv2], CWE-89, high). `key`/`dig` path segments were
25
+ interpolated straight into a `#>'{...}'` array literal, so a segment
26
+ containing `}'` could close the literal and have the rest of it executed as
27
+ SQL. Most reachable through activerecord-filter, where a filter key like
28
+ `"metadata.<payload>"` on a json/jsonb column puts request input into `dig`.
29
+ Segments are now emitted as a quoted `#> array[...]`, which PostgreSQL folds
30
+ back to the same `text[]` constant (existing expression indexes still match).
31
+ Reported by [@saidM](https://github.com/saidM).
32
+ - `cast_as` now validates the type name and raises `ArgumentError` unless it
33
+ looks like a type identifier. Not reachable from activerecord-filter, but it
34
+ was the same class of raw interpolation.
35
+
36
+ ### Changed
37
+ - A path segment is now always a single segment: `key('a,b')` emits
38
+ `array['a,b']`, where the old raw `'{a,b}'` literal let PostgreSQL split it
39
+ on the comma into two segments. Use `dig('a', 'b')` for multi-segment paths.
40
+
41
+ [GHSA-75hc-9q9v-9cv2]: https://github.com/malomalo/arel-extensions/security/advisories/GHSA-75hc-9q9v-9cv2
42
+
43
+ ## [9.0.0] - 2026-08-27
2
44
 
3
45
  ### Changed
4
46
  - Switched to independent Semantic Versioning. Prior releases tracked the Rails
data/README.md CHANGED
@@ -56,21 +56,88 @@ A `RANDOM()` ordering node is also provided.
56
56
  tags = Post.arel_table[:tags]
57
57
  tags.contained_by(other) # tags <@ other
58
58
  tags.excludes(other) # NOT (tags @> other)
59
+
60
+ # Any predicate negates with Arel's own #not:
61
+ tags.overlaps(other).not # NOT (tags && other)
62
+ ```
63
+
64
+ ### Range columns
65
+
66
+ ActiveRecord types `tsrange`/`tstzrange`/`daterange`/`int4range`/`int8range`/
67
+ `numrange` columns as `OID::Range`, so a plain Ruby Range serializes to a
68
+ PostgreSQL range literal — no special node needed:
69
+
70
+ ```ruby
71
+ period = Property.arel_table[:period]
72
+
73
+ period.contains(t1...t2) # "period" @> '[2026-01-01 00:00:00,2026-12-31 00:00:00)'
74
+ period.overlaps(t1...t2) # "period" && '[...)'
75
+ period.contained_by(t1...t2) # "period" <@ '[...)'
76
+ ```
77
+
78
+ `..` gives an inclusive upper bound (`']'`), `...` an exclusive one (`')'`), and
79
+ a `nil` end is unbounded. PostgreSQL's exclusive *lower* bounds (`'('`) have no
80
+ Ruby Range equivalent; build the range with a `NamedFunction` if you need one.
81
+
82
+ Beyond containment and overlap, PostgreSQL's positional operators ask where two
83
+ ranges sit relative to one another:
84
+
85
+ ```ruby
86
+ period.strictly_left_of(other) # period << other
87
+ period.strictly_right_of(other) # period >> other
88
+ period.not_extend_right_of(other) # period &< other
89
+ period.not_extend_left_of(other) # period &> other
90
+ period.adjacent_to(other) # period -|- other
59
91
  ```
60
92
 
93
+ `<<` and `>>` mean every element is lower (or higher) with no overlap. `&<` asks
94
+ whether the left range stops at or before the right one's upper bound, and `&>`
95
+ whether it starts at or after the right one's lower bound. `-|-` is true when the
96
+ two abut — touching, with no gap and no overlap.
97
+
61
98
  ### JSON / JSONB predicates
62
99
 
63
100
  ```ruby
64
101
  data = User.arel_table[:data]
65
102
 
66
- data.key('name') # data #>'{name}' (aliases: data['name'], data.index('name'))
67
- data.dig('address', 'zip') # data #>'{address,zip}'
103
+ # Keys in a Object
104
+ data['name'] # data #> array['name']
105
+ data.key('name') # data #> array['name']
106
+ data.index('name') # data #> array['name']
107
+
108
+ # Integer index in a Array
109
+ data[0] # data #> array['0']
110
+ data.key(0) # data #> array['0']
111
+ data.index(0) # data #> array['0']
112
+
113
+ # Dig and other operators
114
+ data.dig('address', 'zip') # data #> array['address','zip']
115
+ data.dig('tags', -1) # data #> array['tags','-1']
68
116
  data.has_key('name') # data ? 'name'
69
117
  data.has_keys('a', 'b') # data ?& array['a','b']
70
118
  data.has_any_key('a', 'b') # data ?| array['a','b']
71
- data.cast_as('int') # (data)::int
72
119
  ```
73
120
 
121
+ A segment may be an integer, which PostgreSQL reads as an array index (negative
122
+ counts from the end); out of range yields `NULL`.
123
+
124
+ Path segments are quoted, so they are safe to build from untrusted input.
125
+ PostgreSQL folds the array back to `'{address,zip}'::text[]`, so expression
126
+ indexes written against the literal form still match.
127
+
128
+ ### Casting
129
+
130
+ ```ruby
131
+ User.arel_table[:id].cast_as('text') # (users.id)::text
132
+ User.arel_table[:created_at].cast_as('date') # (users.created_at)::date
133
+ data.dig('age').cast_as('int') # (data #> array['age'])::int
134
+ ```
135
+
136
+ A type name can't be quoted or bound, so `cast_as` accepts only something that
137
+ looks like one; optionally schema qualified, with a modifier. For example:
138
+ `text`, `varchar(255)`, `numeric(10,2)`, `timestamp(6) with time zone`,
139
+ `int[]`, `public.geometry`. An `ArgumentError` will be raised otherwise.
140
+
74
141
  ### Full-text search
75
142
 
76
143
  ```ruby
@@ -3,8 +3,16 @@
3
3
  module Arel
4
4
  module ArrayPredications
5
5
 
6
- # Used by both JSON and ARRAY so it doesn't try to cast to array
6
+ # Used by both JSON and ARRAY so it doesn't try to cast to array — callers
7
+ # there pre-wrap the value themselves.
8
+ #
9
+ # A Ruby Range is the exception: it has no other reading, and leaving it
10
+ # unquoted made `contained_by` the one range predicate that could not take
11
+ # one (`contains` and `overlaps` come from Arel core, which quotes through
12
+ # the attribute). On a range column the attribute's type casts it to a
13
+ # PostgreSQL range literal.
7
14
  def contained_by(value)
15
+ value = Arel::Nodes.build_quoted(value, self) if value.is_a?(::Range)
8
16
  Arel::Nodes::ContainedBy.new(self, value)
9
17
  end
10
18
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Arel
4
4
  module Extensions
5
- VERSION = '9.0.0'
5
+ VERSION = '9.1.0'
6
6
  end
7
7
  end
@@ -9,10 +9,18 @@ require_relative "./nodes/intersects"
9
9
  require_relative "./nodes/within"
10
10
  require_relative "./nodes/excludes"
11
11
  require_relative "./nodes/contained_by"
12
+ require_relative "./nodes/strictly_left_of"
13
+ require_relative "./nodes/strictly_right_of"
14
+ require_relative "./nodes/not_extend_right_of"
15
+ require_relative "./nodes/not_extend_left_of"
16
+ require_relative "./nodes/adjacent_to"
12
17
 
13
18
  require File.expand_path('../array_predications', __FILE__)
14
19
  Arel::Attributes::Attribute.include(Arel::ArrayPredications)
15
20
 
21
+ require File.expand_path('../range_predications', __FILE__)
22
+ Arel::Attributes::Attribute.include(Arel::RangePredications)
23
+
16
24
  require File.expand_path('../nodes/random', __FILE__)
17
25
  require File.expand_path(File.join(__FILE__, '../../../ext/arel/nodes/ascending'))
18
26
  require File.expand_path(File.join(__FILE__, '../../../ext/arel/nodes/descending'))
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arel
4
+ module Nodes
5
+ # PostgreSQL `-|-`. Do the two ranges abut — touching, with no gap and no overlap?
6
+ class AdjacentTo < InfixOperation
7
+ def initialize(left, right)
8
+ super(:"-|-", left, right)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arel
4
+ module Nodes
5
+ # PostgreSQL `&>`. Does the left range start at or after the right one's lower bound?
6
+ class NotExtendLeftOf < InfixOperation
7
+ def initialize(left, right)
8
+ super(:"&>", left, right)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arel
4
+ module Nodes
5
+ # PostgreSQL `&<`. Does the left range stop at or before the right one's upper bound?
6
+ class NotExtendRightOf < InfixOperation
7
+ def initialize(left, right)
8
+ super(:"&<", left, right)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arel
4
+ module Nodes
5
+ # PostgreSQL `<<`. Is the left range strictly left of the right one — every element lower,
6
+ # with no overlap?
7
+ class StrictlyLeftOf < InfixOperation
8
+ def initialize(left, right)
9
+ super(:"<<", left, right)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arel
4
+ module Nodes
5
+ # PostgreSQL `>>`. Is the left range strictly right of the right one — every element higher,
6
+ # with no overlap?
7
+ class StrictlyRightOf < InfixOperation
8
+ def initialize(left, right)
9
+ super(:">>", left, right)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arel
4
+ # PostgreSQL's positional range operators. Each takes a range and answers a
5
+ # question about where the two sit relative to one another, which `contains`,
6
+ # `overlaps` and `contained_by` cannot express.
7
+ #
8
+ # Operands are quoted through the attribute the way Arel core's `overlaps`
9
+ # does, so a Ruby Range works and an already-built node passes through.
10
+ module RangePredications
11
+
12
+ # <<
13
+ def strictly_left_of(value)
14
+ Arel::Nodes::StrictlyLeftOf.new(self, Arel::Nodes.build_quoted(value, self))
15
+ end
16
+
17
+ # >>
18
+ def strictly_right_of(value)
19
+ Arel::Nodes::StrictlyRightOf.new(self, Arel::Nodes.build_quoted(value, self))
20
+ end
21
+
22
+ # &<
23
+ def not_extend_right_of(value)
24
+ Arel::Nodes::NotExtendRightOf.new(self, Arel::Nodes.build_quoted(value, self))
25
+ end
26
+
27
+ # &>
28
+ def not_extend_left_of(value)
29
+ Arel::Nodes::NotExtendLeftOf.new(self, Arel::Nodes.build_quoted(value, self))
30
+ end
31
+
32
+ # -|-
33
+ def adjacent_to(value)
34
+ Arel::Nodes::AdjacentTo.new(self, Arel::Nodes.build_quoted(value, self))
35
+ end
36
+
37
+ end
38
+ end
@@ -39,24 +39,24 @@ module Arel
39
39
  collector
40
40
  end
41
41
 
42
- def visit_Arel_Attributes_Key(o, collector, last_key = true)
43
- if o.relation.is_a?(Arel::Attributes::Key)
44
- visit_Arel_Attributes_Key(o.relation, collector, false)
45
- if last_key
46
- collector << o.name.to_s
47
- collector << "}'"
48
- else
49
- collector << o.name.to_s
50
- collector << ","
51
- end
52
- else
53
- visit(o.relation, collector)
54
- collector << "\#>'{" << o.name.to_s
55
- collector << (last_key ? "}'" : ",")
42
+ # Path segments are emitted as a quoted `array[...]` rather than
43
+ # interpolated into a `'{...}'` array literal, so a segment can never
44
+ # break out of the path and inject SQL (GHSA-75hc-9q9v-9cv2). PostgreSQL
45
+ # const-folds the array back to `'{a,b}'::text[]`, so expression indexes
46
+ # on the literal form still match.
47
+ def visit_Arel_Attributes_Key(o, collector)
48
+ keys = []
49
+ node = o
50
+ while node.is_a?(Arel::Attributes::Key)
51
+ keys.unshift(quote(node.name.to_s))
52
+ node = node.relation
56
53
  end
54
+
55
+ visit(node, collector)
56
+ collector << " #> array[" << keys.join(',') << "]"
57
57
  collector
58
58
  end
59
-
59
+
60
60
  def visit_Arel_Nodes_HasKey(o, collector)
61
61
  right = o.right
62
62
 
@@ -84,10 +84,24 @@ module Arel
84
84
  collector
85
85
  end
86
86
 
87
+ # A type name can't be bound or quoted, so only allow something that
88
+ # actually looks like one -- optionally schema qualified, with a modifier
89
+ # and/or array suffix. Keeps user input from reaching the SQL as-is.
90
+ CAST_TYPE = /\A
91
+ [a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)? # type, optionally schema qualified
92
+ (\(\d+(\s*,\s*\d+)?\))? # "varchar(255)", "numeric(10,2)"
93
+ (\ [a-z]+)* # "timestamp with time zone"
94
+ (\(\d+(\s*,\s*\d+)?\))? # "character varying(255)"
95
+ (\[\])* # "int[]"
96
+ \z/xi
97
+
87
98
  def visit_Arel_Attributes_Cast(o, collector)
99
+ type = o.name.to_s
100
+ raise ArgumentError, "invalid cast type: #{type.inspect}" unless CAST_TYPE.match?(type)
101
+
88
102
  collector << "("
89
103
  visit(o.relation, collector)
90
- collector << ")::#{o.name}"
104
+ collector << ")::#{type}"
91
105
  collector
92
106
  end
93
107
 
@@ -114,10 +114,18 @@ module Arel
114
114
  end
115
115
  end
116
116
 
117
- def visit_Arel_Nodes_NotOverlaps o, collector
118
- key = visit(o.left, collector)
119
- value = { not_overlaps: visit(o.right, collector) }
120
-
117
+ # A negated Overlaps — `attribute.overlaps(x).not` — is the only NOT this
118
+ # adapter knows how to serialize, so the negation is unwrapped here.
119
+ def visit_Arel_Nodes_Not o, collector
120
+ expr = o.expr
121
+
122
+ unless expr.is_a?(Arel::Nodes::Overlaps)
123
+ raise "Not Supported: NOT of #{expr.class}"
124
+ end
125
+
126
+ key = visit(expr.left, collector)
127
+ value = { not_overlaps: visit(expr.right, collector) }
128
+
121
129
  if key.is_a?(Hash)
122
130
  add_to_bottom_of_hash_or_array(key, value)
123
131
  key
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: arel-extensions
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.0.0
4
+ version: 9.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Bracy
@@ -178,6 +178,7 @@ files:
178
178
  - lib/arel/extensions/version.rb
179
179
  - lib/arel/gis_predications.rb
180
180
  - lib/arel/json_predications.rb
181
+ - lib/arel/nodes/adjacent_to.rb
181
182
  - lib/arel/nodes/binary_value.rb
182
183
  - lib/arel/nodes/contained_by.rb
183
184
  - lib/arel/nodes/excludes.rb
@@ -187,14 +188,19 @@ files:
187
188
  - lib/arel/nodes/has_keys.rb
188
189
  - lib/arel/nodes/hex_encoded_binary_value.rb
189
190
  - lib/arel/nodes/intersects.rb
191
+ - lib/arel/nodes/not_extend_left_of.rb
192
+ - lib/arel/nodes/not_extend_right_of.rb
190
193
  - lib/arel/nodes/random.rb
191
194
  - lib/arel/nodes/relation.rb
195
+ - lib/arel/nodes/strictly_left_of.rb
196
+ - lib/arel/nodes/strictly_right_of.rb
192
197
  - lib/arel/nodes/ts_match.rb
193
198
  - lib/arel/nodes/ts_query.rb
194
199
  - lib/arel/nodes/ts_rank.rb
195
200
  - lib/arel/nodes/ts_rank_cd.rb
196
201
  - lib/arel/nodes/ts_vector.rb
197
202
  - lib/arel/nodes/within.rb
203
+ - lib/arel/range_predications.rb
198
204
  - lib/arel/ts_predications.rb
199
205
  - lib/arel/visitors/postgresql_extensions.rb
200
206
  - lib/arel/visitors/sunstone_extensions.rb