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 +4 -4
- data/CHANGELOG.md +12 -2
- data/README.md +18 -20
- data/lib/parade_db/aggregations.rb +5 -37
- data/lib/parade_db/model.rb +18 -6
- data/lib/parade_db/query_builder.rb +415 -0
- data/lib/parade_db/search_methods.rb +33 -104
- data/lib/parade_db/version.rb +1 -1
- data/lib/parade_db.rb +23 -2
- metadata +3 -8
- data/lib/parade_db/arel/builder.rb +0 -654
- data/lib/parade_db/arel/nodes.rb +0 -191
- data/lib/parade_db/arel/predications.rb +0 -261
- data/lib/parade_db/arel/visitor.rb +0 -99
- data/lib/parade_db/arel.rb +0 -26
- data/lib/parade_db/railtie.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6cec3ad6c144476e82c57a3f9cba2b9ff497a838c34c032919b1eb785d4da435
|
|
4
|
+
data.tar.gz: 2af9e08e2bf7b9a12f9c005e99bc73f4a804f8a0c23f50c1fbc57637d6b87109
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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+ (
|
|
49
|
-
| pgvector | Required for vector search
|
|
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
|
-
|
|
63
|
-
|
|
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,
|
|
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
|
-
|
|
72
|
+
To get community support, you can:
|
|
75
73
|
|
|
76
|
-
-
|
|
77
|
-
- Ask
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/parade_db/model.rb
CHANGED
|
@@ -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
|