rails-paradedb 0.11.0 → 0.12.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: 61970bcefe75f0514836a81ab38dd8e39fe1c6f311d55f35366120d2285325b9
4
- data.tar.gz: 6179c3b6d0814dcaae1179977548c4b47e6f6447110596293f242d668727d79a
3
+ metadata.gz: 6cec3ad6c144476e82c57a3f9cba2b9ff497a838c34c032919b1eb785d4da435
4
+ data.tar.gz: 2af9e08e2bf7b9a12f9c005e99bc73f4a804f8a0c23f50c1fbc57637d6b87109
5
5
  SHA512:
6
- metadata.gz: 38471cccf3d392cd481dbb22cde9f35c37f09a555c4e6081c5484d9c5ad1920082172276447a2401a22c0dd46bcd1a0846f2dee26678f556f913373996a91da0
7
- data.tar.gz: 6176c6252901db66d8f8dd72972f8053fc6a187d0d10391705dde18fabe16a4992d60655a351b3af8643e9ca4a57c2baff58a11a2dd93cb63a708750785034c6
6
+ metadata.gz: 1af9bfba690e6ba816fc5972f576885b72408594e68504e6d3bbf063944f6392debcf3cf7a028b5d08ba626d6acfaaeb7dbcb526db4adafa96cec987902fb6fa
7
+ data.tar.gz: 384b58ba9857bc2edb9a984ab71bd60d455daf5add4b3fc83b0c53458892496d21959b84e71680daddf7acc48f7c2832dbf930185661fb3ff587a22399a4bd84
data/CHANGELOG.md CHANGED
@@ -2,13 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.12.0] - 2026-08-13
8
+
9
+ ### Changed
10
+
11
+ - **BREAKING**: Search modifiers are now composable functions: `ParadeDB.boost`, `ParadeDB.constant`, `ParadeDB.fuzzy`, `ParadeDB.slop`, and `ParadeDB.tokenize`. Pass the wrapped value to search methods instead of using modifier keyword arguments.
12
+ - **BREAKING**: Removed the public `ParadeDB::Arel` builder, Arel attribute predications, and `Model.paradedb_arel`. ParadeDB queries now use a private builder composed from standard Active Record nodes.
13
+
5
14
  ## [0.11.0] - 2026-08-04
6
15
 
7
16
  ### Added
8
17
 
9
18
  - Vector index build options `centroid_ratio`, `training_samples_per_centroid`, and `cluster_replication` in `ParadeDB::Index` `index_options` and `add_paradedb_index` (pg_search 0.25.0+). They are emitted in the `WITH (...)` clause and round-tripped through the schema dumper.
10
19
 
11
- [0.10.0] - 2026-08-04
20
+ ## [0.10.0] - 2026-08-04
12
21
 
13
22
  ### Added
14
23
 
@@ -24,7 +33,7 @@ All notable changes to this project will be documented in this file. The format
24
33
  - **BREAKING**: Index creation always emits `USING paradedb`, which requires pg_search 0.25.0+. There is no option to select the legacy `bm25` access method.
25
34
  - **BREAKING**: The default index name is now `<table>_search_idx` (previously `<table>_bm25_idx`), and the index generator emits `Create<Model>SearchIndex` migrations named `create_<table>_search_index.rb`.
26
35
 
27
- [0.9.0] - 2026-07-14
36
+ ## [0.9.0] - 2026-07-14
28
37
 
29
38
  ### Added
30
39
 
@@ -174,6 +183,7 @@ All notable changes to this project will be documented in this file. The format
174
183
  - Schema dump/load round-trip for tokenizer configuration and index options
175
184
  (including `target_segment_count`)
176
185
 
186
+ [0.12.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.12.0
177
187
  [0.11.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.11.0
178
188
  [0.10.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.10.0
179
189
  [0.9.0]: https://github.com/paradedb/rails-paradedb/releases/tag/v0.9.0
data/README.md CHANGED
@@ -36,47 +36,45 @@
36
36
 
37
37
  ## ParadeDB for Rails
38
38
 
39
- The official ActiveRecord integration for [ParadeDB](https://paradedb.com) (powered by the [`pg_search`](https://github.com/paradedb/paradedb) Postgres extension), including first-class support for managing ParadeDB indexes and running queries using the full ParadeDB API. Follow the [getting started guide](https://docs.paradedb.com/documentation/getting-started/environment#rails) to begin.
39
+ The official [ActiveRecord](https://guides.rubyonrails.org/active_record_basics.html) integration for [ParadeDB](https://paradedb.com) (powered by the [`pg_search`](https://github.com/paradedb/paradedb) Postgres extension), including first-class support for managing ParadeDB indexes and running queries using the full ParadeDB API. The integration covers both [full-text search](https://docs.paradedb.com/documentation/full-text/overview) and [vector search](https://docs.paradedb.com/documentation/vector/overview) over pgvector `vector` types. Follow the [getting started guide](https://docs.paradedb.com/documentation/getting-started/environment#rails) to begin.
40
40
 
41
41
  ## Requirements & Compatibility
42
42
 
43
- | Component | Supported |
44
- | ---------- | ----------------------------------------------------------------- |
45
- | Ruby | 3.2+ |
46
- | Rails | 7.2+ |
47
- | ParadeDB | 0.25.0+ |
48
- | PostgreSQL | 15+ (PostgreSQL adapter with ParadeDB extension) |
49
- | pgvector | Required for vector search; included in the ParadeDB Docker image |
50
-
51
- ## Vector Search
52
-
53
- rails-paradedb supports full-text search and vector search over pgvector `vector(n)` columns. See the [vector search documentation](https://docs.paradedb.com/documentation/vector/overview) for details.
43
+ | Component | Supported |
44
+ | ---------- | ------------------------------------------------------------------ |
45
+ | Ruby | 3.2+ |
46
+ | Rails | 7.2+ |
47
+ | ParadeDB | 0.25.0+ |
48
+ | PostgreSQL | 15+ (with the ParadeDB pg_search extension) |
49
+ | pgvector | Required for vector search (included in the ParadeDB Docker image) |
54
50
 
55
51
  ## Examples
56
52
 
57
53
  - [Quickstart](examples/quickstart/quickstart.rb)
58
54
  - [Vector Search](examples/vector_search/vector_search.rb)
59
55
  - [Faceted Search](examples/faceted_search/faceted_search.rb)
56
+ - [Hybrid Search (RRF)](examples/hybrid_rrf/hybrid_rrf.rb)
57
+ - [Retrieval-Augmented Generation (RAG)](examples/rag/rag.rb)
60
58
  - [Autocomplete](examples/autocomplete/autocomplete.rb)
61
59
  - [More Like This](examples/more_like_this/more_like_this.rb)
62
- - [Hybrid Search (RRF)](examples/hybrid_rrf/hybrid_rrf.rb)
63
- - [RAG](examples/rag/rag.rb)
60
+
61
+ See [examples/README.md](examples/README.md) for setup instructions and a description of each example.
64
62
 
65
63
  ## Contributing
66
64
 
67
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, test commands, linting, and PR workflow.
65
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, running tests, linting, and the PR workflow.
68
66
 
69
67
  ## Support
70
68
 
71
- If you're missing a feature or have found a bug, open a
69
+ If you're missing a feature or have found a bug, please open a
72
70
  [GitHub Issue](https://github.com/paradedb/rails-paradedb/issues/new/choose).
73
71
 
74
- For community support:
72
+ To get community support, you can:
75
73
 
76
- - Join the [ParadeDB Slack Community](https://paradedb.com/slack)
77
- - Ask in [ParadeDB Discussions](https://github.com/paradedb/paradedb/discussions)
74
+ - Post a question in the [ParadeDB Slack Community](https://paradedb.com/slack)
75
+ - Ask for help on our [GitHub Discussions](https://github.com/paradedb/paradedb/discussions)
78
76
 
79
- For commercial support, contact [sales@paradedb.com](mailto:sales@paradedb.com).
77
+ If you need commercial support, please [contact the ParadeDB team](mailto:sales@paradedb.com).
80
78
 
81
79
  ## Acknowledgments
82
80
 
@@ -7,14 +7,7 @@ module ParadeDB
7
7
  # Backward-compatible reader for code that accessed `filtered_spec.filter`.
8
8
  alias filter agg_filter
9
9
  end
10
- FieldTermFilter = Struct.new(
11
- :field,
12
- :term,
13
- :distance,
14
- :prefix,
15
- :transposition_cost_one,
16
- keyword_init: true
17
- )
10
+ FieldTermFilter = Struct.new(:field, :term, keyword_init: true)
18
11
 
19
12
  TERMS_ORDER = {
20
13
  count_desc: { "_count" => "desc" },
@@ -154,16 +147,9 @@ module ParadeDB
154
147
  { "top_hits" => payload }
155
148
  end
156
149
 
157
- def filtered(spec, filter: nil, field: nil, term: nil, distance: nil, prefix: nil, transposition_cost_one: nil)
150
+ def filtered(spec, filter: nil, field: nil, term: nil)
158
151
  normalized_spec = normalize_spec(spec)
159
- normalized_filter = normalize_filter(
160
- filter: filter,
161
- field: field,
162
- term: term,
163
- distance: distance,
164
- prefix: prefix,
165
- transposition_cost_one: transposition_cost_one
166
- )
152
+ normalized_filter = normalize_filter(filter: filter, field: field, term: term)
167
153
  FilteredSpec.new(spec: normalized_spec, agg_filter: normalized_filter)
168
154
  end
169
155
 
@@ -211,7 +197,7 @@ module ParadeDB
211
197
  end
212
198
  private_class_method :normalize_spec
213
199
 
214
- def normalize_filter(filter:, field:, term:, distance:, prefix:, transposition_cost_one:)
200
+ def normalize_filter(filter:, field:, term:)
215
201
  if filter
216
202
  if !field.nil? || !term.nil?
217
203
  raise ArgumentError, "filtered aggregation accepts either filter: or field/term arguments, not both"
@@ -223,17 +209,7 @@ module ParadeDB
223
209
  raise ArgumentError, "filtered aggregation requires filter: or both field: and term:"
224
210
  end
225
211
 
226
- normalized_distance = distance.nil? ? nil : normalize_non_negative_integer(distance, "distance")
227
- normalized_prefix = normalize_boolean_option(prefix, "prefix")
228
- normalized_transposition = normalize_boolean_option(transposition_cost_one, "transposition_cost_one")
229
-
230
- FieldTermFilter.new(
231
- field: normalize_field(field),
232
- term: term,
233
- distance: normalized_distance,
234
- prefix: normalized_prefix,
235
- transposition_cost_one: normalized_transposition
236
- )
212
+ FieldTermFilter.new(field: normalize_field(field), term: term)
237
213
  end
238
214
  private_class_method :normalize_filter
239
215
 
@@ -318,14 +294,6 @@ module ParadeDB
318
294
  end
319
295
  private_class_method :normalize_sort_direction
320
296
 
321
- def normalize_boolean_option(value, name)
322
- return nil if value.nil?
323
- return value if value == true || value == false
324
-
325
- raise ArgumentError, "#{name} must be true, false, or nil"
326
- end
327
- private_class_method :normalize_boolean_option
328
-
329
297
  def deep_stringify(value)
330
298
  case value
331
299
  when Hash
@@ -11,12 +11,14 @@ module ParadeDB
11
11
  :paradedb_search,
12
12
  :more_like_this,
13
13
  :nearest,
14
+ :l2_distance,
15
+ :cosine_distance,
16
+ :inner_product,
14
17
  :with_facets,
15
18
  :facets,
16
19
  :with_agg,
17
20
  :facets_agg,
18
21
  :aggregate_by,
19
- :paradedb_arel,
20
22
  :paradedb_index,
21
23
  :paradedb_index_class,
22
24
  :paradedb_index_classes,
@@ -74,6 +76,21 @@ module ParadeDB
74
76
  all.extending(SearchMethods).nearest(column, vector, metric: metric)
75
77
  end
76
78
 
79
+ def l2_distance(column, vector)
80
+ ensure_postgres!
81
+ QueryBuilder.new(table_name).vector_distance(column, vector, metric: :l2)
82
+ end
83
+
84
+ def cosine_distance(column, vector)
85
+ ensure_postgres!
86
+ QueryBuilder.new(table_name).vector_distance(column, vector, metric: :cosine)
87
+ end
88
+
89
+ def inner_product(column, vector)
90
+ ensure_postgres!
91
+ QueryBuilder.new(table_name).vector_distance(column, vector, metric: :ip)
92
+ end
93
+
77
94
  def with_facets(*fields, **opts)
78
95
  ensure_postgres!
79
96
  paradedb_validate_index!
@@ -108,11 +125,6 @@ module ParadeDB
108
125
  )
109
126
  end
110
127
 
111
- def paradedb_arel
112
- ensure_postgres!
113
- @paradedb_arel ||= ParadeDB::Arel::Builder.new(table_name)
114
- end
115
-
116
128
  def paradedb_index(index_class)
117
129
  @paradedb_explicit_index_classes ||= []
118
130
  @paradedb_explicit_index_classes << index_class unless @paradedb_explicit_index_classes.include?(index_class)
@@ -0,0 +1,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "vector"
5
+
6
+ module ParadeDB
7
+ class QueryBuilder
8
+ RANGE_TYPES = %w[int4range int8range numrange daterange tsrange tstzrange].freeze
9
+ RANGE_RELATIONS = %w[Intersects Contains Within].freeze
10
+ Modifier = Data.define(:type, :value, :options)
11
+ attr_reader :table
12
+
13
+ def initialize(table = nil)
14
+ @table = table
15
+ end
16
+
17
+ def [](column)
18
+ column_node(column)
19
+ end
20
+
21
+ def boost(value, factor)
22
+ validate_numeric!(factor, :factor)
23
+ Modifier.new(type: :boost, value: value, options: { factor: factor })
24
+ end
25
+
26
+ def constant(value, score)
27
+ validate_numeric!(score, :score)
28
+ Modifier.new(type: :constant, value: value, options: { score: score })
29
+ end
30
+
31
+ def fuzzy(value, distance, prefix: nil, transposition_cost_one: nil)
32
+ validate_fuzzy_distance!(distance)
33
+ Modifier.new(
34
+ type: :fuzzy,
35
+ value: value,
36
+ options: { distance: distance, prefix: prefix, transposition_cost_one: transposition_cost_one }
37
+ )
38
+ end
39
+
40
+ def slop(value, distance)
41
+ validate_numeric!(distance, :distance)
42
+ Modifier.new(type: :slop, value: value, options: { distance: distance })
43
+ end
44
+
45
+ def tokenize(value, tokenizer)
46
+ raise ArgumentError, "tokenizer must be a Tokenizer" unless tokenizer.is_a?(ParadeDB::Tokenizer)
47
+
48
+ Modifier.new(type: :tokenizer, value: value, options: { tokenizer: tokenizer })
49
+ end
50
+
51
+ def match(column, term = nil)
52
+ infix("&&&", column_node(column), term_query_node(term))
53
+ end
54
+
55
+ def match_any(column, term = nil)
56
+ infix("|||", column_node(column), term_query_node(term))
57
+ end
58
+
59
+ def phrase(column, text)
60
+ rhs = text.is_a?(Array) ? array_node(normalize_phrase_terms(text).map { |term| quoted_value(term) }) : search_value_node(text)
61
+ infix("###", column_node(column), rhs)
62
+ end
63
+
64
+ def term(column, term)
65
+ infix("===", column_node(column), search_value_node(term))
66
+ end
67
+
68
+ def term_set(column, *terms)
69
+ array = array_node(normalize_term_set_terms(terms).map { |term| quoted_value(term) })
70
+ infix("@@@", column_node(column), ::Arel::Nodes::NamedFunction.new("pdb.term_set", [array]))
71
+ end
72
+
73
+ def regex(column, pattern)
74
+ infix("@@@", column_node(column), ::Arel::Nodes::NamedFunction.new("pdb.regex", [search_value_node(pattern)]))
75
+ end
76
+
77
+ def regex_phrase(column, *patterns, slop: nil, max_expansions: nil)
78
+ args = [array_node(normalize_regex_patterns(patterns).map { |pattern| quoted_value(pattern) })]
79
+ unless slop.nil?
80
+ validate_numeric!(slop, :slop)
81
+ args << keyword_arg_node("slop", slop)
82
+ end
83
+ unless max_expansions.nil?
84
+ validate_integer!(max_expansions, :max_expansions)
85
+ args << keyword_arg_node("max_expansions", max_expansions)
86
+ end
87
+ infix("@@@", column_node(column), ::Arel::Nodes::NamedFunction.new("pdb.regex_phrase", args))
88
+ end
89
+
90
+ def near(column, proximity)
91
+ rhs = proximity.is_a?(Modifier) || arel_expression?(proximity) ? search_value_node(proximity) : proximity_query_node(proximity)
92
+ infix("@@@", column_node(column), rhs)
93
+ end
94
+
95
+ def phrase_prefix(column, *terms, max_expansion: nil)
96
+ flat = terms.flatten.compact
97
+ raise ArgumentError, "phrase_prefix requires at least one term" if flat.empty?
98
+
99
+ args = [array_node(flat.map { |term| quoted_value(term) })]
100
+ unless max_expansion.nil?
101
+ validate_integer!(max_expansion, :max_expansion)
102
+ args << quoted_value(max_expansion)
103
+ end
104
+ infix("@@@", column_node(column), ::Arel::Nodes::NamedFunction.new("pdb.phrase_prefix", args))
105
+ end
106
+
107
+ def parse(column, query, lenient: nil, conjunction_mode: nil)
108
+ args = [quoted_value(query)]
109
+ args << keyword_arg_node("lenient", lenient) unless lenient.nil?
110
+ args << keyword_arg_node("conjunction_mode", conjunction_mode) unless conjunction_mode.nil?
111
+ infix("@@@", column_node(column), ::Arel::Nodes::NamedFunction.new("pdb.parse", args))
112
+ end
113
+
114
+ def match_all(column)
115
+ infix("@@@", column_node(column), ::Arel::Nodes::NamedFunction.new("pdb.all", []))
116
+ end
117
+
118
+ def exists(column)
119
+ infix("@@@", column_node(column), ::Arel::Nodes::NamedFunction.new("pdb.exists", []))
120
+ end
121
+
122
+ def range(column, value = nil, gte: nil, gt: nil, lte: nil, lt: nil, type: nil)
123
+ range_node = build_range_node(value, gte: gte, gt: gt, lte: lte, lt: lt, type: type)
124
+ infix("@@@", column_node(column), ::Arel::Nodes::NamedFunction.new("pdb.range", [range_node]))
125
+ end
126
+
127
+ def range_term(column, value, relation: nil, range_type: nil)
128
+ infix("@@@", column_node(column), build_range_term_node(value, relation: relation, range_type: range_type))
129
+ end
130
+
131
+ def more_like_this(column, key, fields: nil, options: {})
132
+ args = [quoted_value(key)]
133
+ args << array_node(Array(fields).map { |field| quoted_value(field.to_s) }) unless fields.nil?
134
+ options.each { |name, value| args << mlt_option_node(name, value) }
135
+ infix("@@@", column_node(column), ::Arel::Nodes::NamedFunction.new("pdb.more_like_this", args))
136
+ end
137
+
138
+ def vector_distance(column, vector, metric: ParadeDB::Vector::DEFAULT_METRIC)
139
+ operator = ParadeDB::Vector::DISTANCE_OPERATORS.fetch(ParadeDB::Vector.normalize_metric(metric))
140
+ infix(operator, column_node(column), vector_operand(vector))
141
+ end
142
+
143
+ def score(key)
144
+ ::Arel::Nodes::NamedFunction.new("pdb.score", [column_node(key)])
145
+ end
146
+
147
+ def snippet(column, *args)
148
+ ::Arel::Nodes::NamedFunction.new("pdb.snippet", [column_node(column)] + args.map { |arg| quoted_value(arg) })
149
+ end
150
+
151
+ def snippets(column, start_tag: nil, end_tag: nil, max_num_chars: nil, limit: nil, offset: nil, sort_by: nil)
152
+ args = [column_node(column)]
153
+ args << keyword_arg_node("start_tag", start_tag) unless start_tag.nil?
154
+ args << keyword_arg_node("end_tag", end_tag) unless end_tag.nil?
155
+ args << keyword_arg_node("max_num_chars", max_num_chars) unless max_num_chars.nil?
156
+ args << keyword_arg_node("limit", limit, quoted_name: true) unless limit.nil?
157
+ args << keyword_arg_node("offset", offset, quoted_name: true) unless offset.nil?
158
+ args << keyword_arg_node("sort_by", sort_by) unless sort_by.nil?
159
+ ::Arel::Nodes::NamedFunction.new("pdb.snippets", args)
160
+ end
161
+
162
+ def snippet_positions(column)
163
+ ::Arel::Nodes::NamedFunction.new("pdb.snippet_positions", [column_node(column)])
164
+ end
165
+
166
+ def agg(json, exact: nil)
167
+ raise ArgumentError, "exact must be true, false, or nil" unless exact.nil? || exact == true || exact == false
168
+
169
+ args = [quoted_value(json)]
170
+ args << quoted_value(false) if exact == false
171
+ ::Arel::Nodes::NamedFunction.new("pdb.agg", args)
172
+ end
173
+
174
+ private
175
+
176
+ def infix(operator, left, right)
177
+ ::Arel::Nodes::InfixOperation.new(operator, left, right)
178
+ end
179
+
180
+ def column_node(column)
181
+ case column
182
+ when ::Arel::Attributes::Attribute, ::Arel::Nodes::Node, ::Arel::Nodes::SqlLiteral
183
+ column
184
+ when Symbol, String
185
+ @table ? arel_table[column.to_sym] : ::Arel.sql(column.to_s)
186
+ else
187
+ raise ArgumentError, "Unsupported column type: #{column.class}"
188
+ end
189
+ end
190
+
191
+ def quoted_value(value)
192
+ ::Arel::Nodes.build_quoted(value)
193
+ end
194
+
195
+ def array_node(values)
196
+ ::Arel.sql("ARRAY[#{Array.new(values.length, "?").join(", ")}]", *values)
197
+ end
198
+
199
+ def cast_node(node, type)
200
+ ::Arel.sql("?::#{type}", node)
201
+ end
202
+
203
+ def modifier_node(modifier)
204
+ node = search_value_node(modifier.value)
205
+
206
+ case modifier.type
207
+ when :boost
208
+ cast_node(node, "pdb.boost(#{modifier.options[:factor]})")
209
+ when :constant
210
+ node = cast_node(node, "pdb.query") if modifier.value.is_a?(Modifier) && %i[fuzzy slop].include?(modifier.value.type)
211
+ cast_node(node, "pdb.const(#{modifier.options[:score]})")
212
+ when :fuzzy
213
+ args = [modifier.options[:distance]]
214
+ if modifier.options[:transposition_cost_one]
215
+ args << %("#{modifier.options[:prefix] ? 'true' : 'false'}") << '"true"'
216
+ elsif modifier.options[:prefix]
217
+ args << '"true"'
218
+ end
219
+ cast_node(node, "pdb.fuzzy(#{args.join(", ")})")
220
+ when :slop
221
+ cast_node(node, "pdb.slop(#{modifier.options[:distance]})")
222
+ when :tokenizer
223
+ cast_node(node, modifier.options[:tokenizer].render)
224
+ end
225
+ end
226
+
227
+ def vector_operand(vector)
228
+ return vector if arel_expression?(vector)
229
+
230
+ cast_node(quoted_value(vector.is_a?(String) ? vector : ParadeDB::Vector.literal(vector)), "vector")
231
+ end
232
+
233
+ def proximity_query_node(proximity)
234
+ raise ArgumentError, "near requires a ParadeDB.proximity(...) clause" unless proximity.is_a?(ParadeDB::Proximity::Clause)
235
+ raise ArgumentError, "near requires at least one within clause" if proximity.clauses.empty?
236
+
237
+ compile_proximity_clause(proximity)
238
+ end
239
+
240
+ def compile_proximity_clause(clause)
241
+ current = proximity_operand_node(clause.operand, empty_message: "proximity requires at least one term")
242
+ clause.clauses.each do |within_clause|
243
+ validate_numeric!(within_clause.distance, :distance)
244
+ operator = within_clause.ordered ? "##>" : "##"
245
+ right = proximity_operand_node(within_clause.operand, empty_message: "within requires at least one term")
246
+ current = ::Arel::Nodes::Grouping.new(infix(operator, infix(operator, current, quoted_value(within_clause.distance)), right))
247
+ end
248
+ current
249
+ end
250
+
251
+ def prox_regex_node(pattern, max_expansions)
252
+ args = [quoted_value(pattern)]
253
+ unless max_expansions.nil?
254
+ validate_integer!(max_expansions, :max_expansions)
255
+ args << quoted_value(max_expansions)
256
+ end
257
+ ::Arel::Nodes::NamedFunction.new("pdb.prox_regex", args)
258
+ end
259
+
260
+ def proximity_operand_node(terms, empty_message:)
261
+ return compile_proximity_clause(terms) if terms.is_a?(ParadeDB::Proximity::Clause)
262
+
263
+ values = Array(terms).flatten.compact
264
+ raise ArgumentError, empty_message if values.empty?
265
+ return proximity_term_node(values.first) if values.length == 1
266
+
267
+ ::Arel::Nodes::NamedFunction.new("pdb.prox_array", values.map { |term| proximity_term_node(term) })
268
+ end
269
+
270
+ def proximity_term_node(term)
271
+ if term.is_a?(ParadeDB::Proximity::Clause)
272
+ raise ArgumentError, "nested proximity clauses must be passed directly, not inside an array"
273
+ end
274
+ return prox_regex_node(term.pattern, term.max_expansions) if term.is_a?(ParadeDB::Proximity::RegexTerm)
275
+
276
+ quoted_value(term)
277
+ end
278
+
279
+ def build_range_node(value, gte:, gt:, lte:, lt:, type:)
280
+ lower, upper, lower_inclusive, upper_inclusive = normalize_range_bounds(value, gte: gte, gt: gt, lte: lte, lt: lt)
281
+ range_type = normalize_range_type(type || infer_range_type(lower, upper))
282
+ bounds = "#{lower_inclusive ? "[" : "("}#{upper_inclusive ? "]" : ")"}"
283
+ ::Arel::Nodes::NamedFunction.new(range_type, [range_bound_node(lower), range_bound_node(upper), quoted_value(bounds)])
284
+ end
285
+
286
+ def normalize_range_bounds(value, gte:, gt:, lte:, lt:)
287
+ if value.is_a?(Range)
288
+ raise ArgumentError, "range bounds cannot be mixed with a Ruby Range value" if [gte, gt, lte, lt].any? { |bound| !bound.nil? }
289
+
290
+ return [value.begin, value.end, true, !value.exclude_end?]
291
+ end
292
+
293
+ raise ArgumentError, "range expects a Ruby Range or bound options (gte/gt/lte/lt)" unless value.nil?
294
+ raise ArgumentError, "range lower bound cannot include both gte and gt" if !gte.nil? && !gt.nil?
295
+ raise ArgumentError, "range upper bound cannot include both lte and lt" if !lte.nil? && !lt.nil?
296
+
297
+ lower = gt.nil? ? gte : gt
298
+ upper = lt.nil? ? lte : lt
299
+ raise ArgumentError, "range requires at least one bound" if lower.nil? && upper.nil?
300
+
301
+ [lower, upper, gt.nil?, lt.nil?]
302
+ end
303
+
304
+ def infer_range_type(lower, upper)
305
+ values = [lower, upper].compact
306
+ raise ArgumentError, "range requires at least one non-nil bound to infer type" if values.empty?
307
+ return "int8range" if values.all? { |value| value.is_a?(Integer) }
308
+ return "numrange" if values.all? { |value| value.is_a?(Numeric) }
309
+ return "daterange" if values.all? { |value| value.is_a?(Date) && !value.is_a?(DateTime) }
310
+ return "tsrange" if values.all? { |value| value.is_a?(Time) || value.is_a?(DateTime) }
311
+
312
+ raise ArgumentError, "Unable to infer range type from bound values; pass type: explicitly"
313
+ end
314
+
315
+ def normalize_range_type(range_type)
316
+ value = range_type.to_s
317
+ raise ArgumentError, "Unknown range type: #{range_type.inspect}. Expected one of: #{RANGE_TYPES.join(', ')}" unless RANGE_TYPES.include?(value)
318
+
319
+ value
320
+ end
321
+
322
+ def normalize_range_relation(relation)
323
+ value = relation.to_s.capitalize
324
+ raise ArgumentError, "Unknown range relation: #{relation.inspect}. Expected one of: #{RANGE_RELATIONS.join(', ')}" unless RANGE_RELATIONS.include?(value)
325
+
326
+ value
327
+ end
328
+
329
+ def build_range_term_node(value, relation:, range_type:)
330
+ if relation.nil?
331
+ raise ArgumentError, "range_type is only valid when relation is provided" unless range_type.nil?
332
+
333
+ return ::Arel::Nodes::NamedFunction.new("pdb.range_term", [quoted_value(value)])
334
+ end
335
+
336
+ raise ArgumentError, "relation requires range_type" if range_type.nil?
337
+
338
+ cast_value = cast_node(quoted_value(value), normalize_range_type(range_type))
339
+ ::Arel::Nodes::NamedFunction.new("pdb.range_term", [cast_value, quoted_value(normalize_range_relation(relation))])
340
+ end
341
+
342
+ def range_bound_node(value)
343
+ value.nil? ? ::Arel.sql("NULL") : quoted_value(value)
344
+ end
345
+
346
+ def mlt_option_node(name, value)
347
+ rendered = name.to_sym == :stopwords ? array_node(Array(value).map { |term| quoted_value(term.to_s) }) : quoted_value(value)
348
+ ::Arel::Nodes::InfixOperation.new("=>", ::Arel.sql(name.to_s), rendered)
349
+ end
350
+
351
+ def keyword_arg_node(name, value, quoted_name: false)
352
+ ::Arel::Nodes::InfixOperation.new("=>", ::Arel.sql(quoted_name ? %("#{name}") : name), quoted_value(value))
353
+ end
354
+
355
+ def term_query_node(term)
356
+ return search_value_node(term) if term.is_a?(Modifier) || arel_expression?(term)
357
+
358
+ joined = term.to_s
359
+ raise ArgumentError, "at least one search term is required" if joined.strip.empty?
360
+
361
+ quoted_value(joined)
362
+ end
363
+
364
+ def search_value_node(value)
365
+ return value if arel_expression?(value)
366
+ return modifier_node(value) if value.is_a?(Modifier)
367
+ return array_node(value.map { |item| quoted_value(item) }) if value.is_a?(Array)
368
+ return proximity_query_node(value) if value.is_a?(ParadeDB::Proximity::Clause)
369
+
370
+ quoted_value(value)
371
+ end
372
+
373
+ def arel_expression?(value)
374
+ value.is_a?(::Arel::Nodes::Node) || value.is_a?(::Arel::Attributes::Attribute)
375
+ end
376
+
377
+ def normalize_term_set_terms(terms)
378
+ values = Array(terms).flatten.compact
379
+ raise ArgumentError, "term_set requires at least one value" if values.empty?
380
+
381
+ values
382
+ end
383
+
384
+ def normalize_phrase_terms(terms)
385
+ values = Array(terms).flatten.compact.map(&:to_s)
386
+ raise ArgumentError, "phrase array input requires at least one term" if values.empty? || values.all?(&:empty?)
387
+
388
+ values
389
+ end
390
+
391
+ def normalize_regex_patterns(patterns)
392
+ values = Array(patterns).flatten.compact.map(&:to_s)
393
+ raise ArgumentError, "regex_phrase requires at least one pattern" if values.empty? || values.all?(&:empty?)
394
+
395
+ values
396
+ end
397
+
398
+ def validate_numeric!(value, name)
399
+ raise ArgumentError, "#{name} must be numeric, got #{value.class}" unless value.is_a?(Numeric)
400
+ end
401
+
402
+ def validate_fuzzy_distance!(distance)
403
+ validate_numeric!(distance, :distance)
404
+ raise ArgumentError, "distance must be between 0 and 2" unless (0..2).cover?(distance)
405
+ end
406
+
407
+ def validate_integer!(value, name)
408
+ raise ArgumentError, "#{name} must be an integer" unless value.is_a?(Integer)
409
+ end
410
+
411
+ def arel_table
412
+ @arel_table ||= ::Arel::Table.new(@table.to_s)
413
+ end
414
+ end
415
+ end