Skip to content

Commit d98dd84

Browse files
committed
opus and codex review suggestions
1 parent f6e3ec7 commit d98dd84

5 files changed

Lines changed: 269 additions & 4 deletions

File tree

lib/parade_db.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
require_relative "parade_db/search_methods"
66

77
module ParadeDB
8+
class FacetQueryError < ArgumentError; end
89
end

lib/parade_db/search_methods.rb

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def more_like_this(key, fields: nil)
103103

104104
def with_score
105105
score_sql = %(pdb.score("#{table_name}"."#{primary_key}") AS search_score)
106-
except(:select).select(::Arel.sql("#{table_name}.*"), ::Arel.sql(score_sql))
106+
with_projection(score_sql)
107107
end
108108

109109
def with_snippet(column, start_tag: nil, end_tag: nil, max_chars: nil)
@@ -118,7 +118,7 @@ def with_snippet(column, start_tag: nil, end_tag: nil, max_chars: nil)
118118
%(pdb.snippet("#{table_name}"."#{column}", #{formatted_args.join(', ')}))
119119
end
120120

121-
except(:select).select(::Arel.sql("#{table_name}.*"), ::Arel.sql("#{call} AS #{column}_snippet"))
121+
with_projection("#{call} AS #{column}_snippet")
122122
end
123123

124124
# ---- Facets ----
@@ -184,6 +184,12 @@ def ensure_paradedb_predicate
184184

185185
private
186186

187+
def with_projection(sql_fragment)
188+
rel = self
189+
rel = rel.select(::Arel.sql("#{table_name}.*")) if rel.select_values.empty?
190+
rel.select(::Arel.sql(sql_fragment))
191+
end
192+
187193
def where_values_as_sql
188194
# Extract WHERE clause SQL from the relation
189195
# For facet queries that need the predicate SQL
@@ -282,23 +288,52 @@ def parse_facets(row)
282288
row.each do |key, value|
283289
if key.end_with?("_facet")
284290
field_name = key.delete_suffix("_facet")
285-
facets[field_name] = JSON.parse(value) if value
291+
parsed = parse_facet_value(value)
292+
facets[field_name] = parsed unless parsed.nil?
286293
end
287294
end
288295
facets
289296
end
297+
298+
def parse_facet_value(value)
299+
case value
300+
when nil
301+
nil
302+
when String
303+
JSON.parse(value)
304+
else
305+
value
306+
end
307+
end
290308
end
291309

292310
# Module to add .facets accessor to relations
293311
module FacetRelation
294312
attr_accessor :_paradedb_facet_fields, :_paradedb_facet_opts
295313

314+
def load(*)
315+
validate_facet_query_shape!
316+
super
317+
end
318+
296319
def facets
320+
validate_facet_query_shape!
297321
@_facets_cache ||= extract_facets_from_results
298322
end
299323

300324
private
301325

326+
def validate_facet_query_shape!
327+
missing = []
328+
missing << "ORDER BY" if order_values.empty?
329+
missing << "LIMIT" if limit_value.nil?
330+
return if missing.empty?
331+
332+
raise ParadeDB::FacetQueryError,
333+
"with_facets requires #{missing.join(' and ')} for ParadeDB TopN pushdown. " \
334+
"Use .order(...).limit(...)."
335+
end
336+
302337
def extract_facets_from_results
303338
# Execute query and extract facet columns
304339
first_row = limit(1).first
@@ -309,11 +344,23 @@ def extract_facets_from_results
309344
facet_col = "_#{field}_facet"
310345
if first_row.respond_to?(facet_col)
311346
value = first_row.public_send(facet_col)
312-
facets[field.to_s] = JSON.parse(value) if value
347+
parsed = parse_facet_value(value)
348+
facets[field.to_s] = parsed unless parsed.nil?
313349
end
314350
end
315351
facets
316352
end
353+
354+
def parse_facet_value(value)
355+
case value
356+
when nil
357+
nil
358+
when String
359+
JSON.parse(value)
360+
else
361+
value
362+
end
363+
end
317364
end
318365
end
319366
end
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
class BehaviorProduct < ActiveRecord::Base
6+
include ParadeDB::Model
7+
self.table_name = :products
8+
self.has_paradedb_index = true
9+
end
10+
11+
class UserApiBehaviorIntegrationTest < Minitest::Test
12+
def setup
13+
skip "Behavior integration tests require PostgreSQL" unless postgresql?
14+
15+
ensure_paradedb_setup!
16+
seed_products!
17+
end
18+
19+
def test_matching_all_executes_and_returns_rows
20+
ids = BehaviorProduct.search(:description)
21+
.matching_all("running", "shoes")
22+
.order(:id)
23+
.pluck(:id)
24+
25+
assert_equal [1, 2], ids
26+
end
27+
28+
def test_matching_any_executes_and_returns_rows
29+
ids = BehaviorProduct.search(:description)
30+
.matching_any("wireless", "hiking")
31+
.order(:id)
32+
.pluck(:id)
33+
34+
assert_equal [3, 5], ids
35+
end
36+
37+
def test_phrase_near_and_phrase_prefix_execute
38+
phrase_ids = BehaviorProduct.search(:description).phrase("running shoes").order(:id).pluck(:id)
39+
near_ids = BehaviorProduct.search(:description).near("running", "shoes", distance: 1).order(:id).pluck(:id)
40+
prefix_ids = BehaviorProduct.search(:category).phrase_prefix("foot").order(:id).pluck(:id)
41+
42+
assert_equal [1, 2], phrase_ids
43+
assert_equal [1, 2], near_ids
44+
assert_equal [1, 2, 5], prefix_ids
45+
end
46+
47+
def test_term_regex_and_fuzzy_execute
48+
term_ids = BehaviorProduct.search(:category).term("audio").order(:id).pluck(:id)
49+
regex_ids = BehaviorProduct.search(:description).regex("run.*").order(:id).pluck(:id)
50+
fuzzy_ids = BehaviorProduct.search(:description).fuzzy("shose", distance: 2).order(:id).pluck(:id)
51+
52+
assert_equal [3, 4], term_ids
53+
assert_equal [1, 2, 6], regex_ids
54+
assert_equal [1, 2], fuzzy_ids
55+
end
56+
57+
def test_more_like_this_executes_and_returns_similar_rows
58+
ids = BehaviorProduct.more_like_this(3, fields: [:description]).limit(5).pluck(:id)
59+
60+
assert_includes ids, 4
61+
end
62+
63+
def test_with_score_and_with_snippet_materialize_columns
64+
rows = BehaviorProduct.search(:description)
65+
.matching_all("running shoes")
66+
.with_score
67+
.with_snippet(:description, start_tag: "<b>", end_tag: "</b>", max_chars: 60)
68+
.order(:id)
69+
.to_a
70+
71+
refute_empty rows
72+
rows.each do |row|
73+
refute_nil row.search_score
74+
refute_nil row.description_snippet
75+
end
76+
end
77+
78+
def test_facets_and_with_facets_execute_and_parse_results
79+
facet_hash = BehaviorProduct.search(:description).matching_all("earbuds").facets(:rating)
80+
assert_kind_of Hash, facet_hash
81+
assert_includes facet_hash, "rating"
82+
assert_match(/[35]/, facet_hash["rating"].to_json)
83+
84+
rel = BehaviorProduct.search(:description)
85+
.matching_all("running shoes")
86+
.with_facets(:rating, size: 10)
87+
.order(:id)
88+
.limit(10)
89+
rows = rel.to_a
90+
assert_equal [1, 2], rows.map(&:id)
91+
92+
rel_facets = rel.facets
93+
assert_kind_of Hash, rel_facets
94+
assert_includes rel_facets, "rating"
95+
assert_match(/[45]/, rel_facets["rating"].to_json)
96+
end
97+
98+
def test_with_facets_without_topn_shape_raises_friendly_error
99+
rel = BehaviorProduct.search(:description).matching_all("running shoes").with_facets(:rating, size: 10)
100+
error = assert_raises(ParadeDB::FacetQueryError) { rel.to_a }
101+
assert_includes error.message, "ORDER BY and LIMIT"
102+
end
103+
104+
private
105+
106+
def postgresql?
107+
ActiveRecord::Base.connection.adapter_name.downcase.include?("postgres")
108+
end
109+
110+
def ensure_paradedb_setup!
111+
return if self.class.instance_variable_get(:@paradedb_setup_done)
112+
113+
conn = ActiveRecord::Base.connection
114+
conn.execute("CREATE EXTENSION IF NOT EXISTS pg_search;")
115+
conn.execute("DROP INDEX IF EXISTS products_bm25_idx;")
116+
conn.execute(<<~SQL)
117+
CREATE INDEX products_bm25_idx ON products
118+
USING bm25 (id, description, category, rating, in_stock, price)
119+
WITH (key_field='id');
120+
SQL
121+
122+
self.class.instance_variable_set(:@paradedb_setup_done, true)
123+
end
124+
125+
def seed_products!
126+
conn = ActiveRecord::Base.connection
127+
conn.execute("TRUNCATE TABLE products RESTART IDENTITY;")
128+
129+
BehaviorProduct.create!(description: "running shoes lightweight", category: "footwear", rating: 5, in_stock: true, price: 120)
130+
BehaviorProduct.create!(description: "trail running shoes grip", category: "footwear", rating: 4, in_stock: true, price: 90)
131+
BehaviorProduct.create!(description: "wireless bluetooth earbuds", category: "audio", rating: 5, in_stock: true, price: 80)
132+
BehaviorProduct.create!(description: "budget wired earbuds", category: "audio", rating: 3, in_stock: false, price: 20)
133+
BehaviorProduct.create!(description: "hiking boots waterproof", category: "footwear", rating: 4, in_stock: true, price: 110)
134+
BehaviorProduct.create!(description: "running socks breathable", category: "apparel", rating: 2, in_stock: true, price: 15)
135+
end
136+
end

spec/user_api_integration_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,36 @@ def test_with_snippet_custom
175175
assert_sql_equal expected, sql
176176
end
177177

178+
def test_with_score_then_with_snippet_keeps_both_projections
179+
sql = Product.search(:description)
180+
.matching_all("shoes")
181+
.with_score
182+
.with_snippet(:description)
183+
.to_sql
184+
185+
expected = <<~SQL.strip
186+
SELECT products.*, pdb.score("products"."id") AS search_score, pdb.snippet("products"."description") AS description_snippet FROM products
187+
WHERE ("products"."description" &&& 'shoes')
188+
SQL
189+
190+
assert_sql_equal expected, sql
191+
end
192+
193+
def test_with_snippet_then_with_score_keeps_both_projections
194+
sql = Product.search(:description)
195+
.matching_all("shoes")
196+
.with_snippet(:description)
197+
.with_score
198+
.to_sql
199+
200+
expected = <<~SQL.strip
201+
SELECT products.*, pdb.snippet("products"."description") AS description_snippet, pdb.score("products"."id") AS search_score FROM products
202+
WHERE ("products"."description" &&& 'shoes')
203+
SQL
204+
205+
assert_sql_equal expected, sql
206+
end
207+
178208
def test_or_across_fields
179209
base = Product.where(in_stock: true).order(id: :desc).limit(10)
180210
left = base.search(:description).matching_all("shoes")

spec/user_api_unit_spec.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,28 @@ def test_with_snippet_custom
9696
WHERE ("products"."description" &&& 'shoes')), sql
9797
end
9898

99+
def test_with_score_then_with_snippet_keeps_both_projections
100+
sql = UnitProduct.search(:description)
101+
.matching_all("shoes")
102+
.with_score
103+
.with_snippet(:description)
104+
.to_sql
105+
106+
assert_sql_equal %(SELECT products.*, pdb.score("products"."id") AS search_score, pdb.snippet("products"."description") AS description_snippet FROM products
107+
WHERE ("products"."description" &&& 'shoes')), sql
108+
end
109+
110+
def test_with_snippet_then_with_score_keeps_both_projections
111+
sql = UnitProduct.search(:description)
112+
.matching_all("shoes")
113+
.with_snippet(:description)
114+
.with_score
115+
.to_sql
116+
117+
assert_sql_equal %(SELECT products.*, pdb.snippet("products"."description") AS description_snippet, pdb.score("products"."id") AS search_score FROM products
118+
WHERE ("products"."description" &&& 'shoes')), sql
119+
end
120+
99121
def test_facets_only
100122
facet_sql = UnitProduct.search(:description).matching_all("shoes")
101123
.build_facet_query(fields: [:category, :brand], size: 10, order: "-count")
@@ -158,6 +180,35 @@ def test_with_facets_without_paradedb_predicates
158180
assert_sql_equal expected, sql
159181
end
160182

183+
def test_with_facets_load_requires_order_and_limit
184+
rel = UnitProduct.search(:description)
185+
.matching_all("shoes")
186+
.with_facets(:category, size: 10)
187+
188+
error = assert_raises(ParadeDB::FacetQueryError) { rel.load }
189+
assert_includes error.message, "ORDER BY and LIMIT"
190+
end
191+
192+
def test_with_facets_load_requires_limit
193+
rel = UnitProduct.search(:description)
194+
.matching_all("shoes")
195+
.with_facets(:category, size: 10)
196+
.order(:id)
197+
198+
error = assert_raises(ParadeDB::FacetQueryError) { rel.load }
199+
assert_includes error.message, "LIMIT"
200+
end
201+
202+
def test_with_facets_load_requires_order
203+
rel = UnitProduct.search(:description)
204+
.matching_all("shoes")
205+
.with_facets(:category, size: 10)
206+
.limit(10)
207+
208+
error = assert_raises(ParadeDB::FacetQueryError) { rel.load }
209+
assert_includes error.message, "ORDER BY"
210+
end
211+
161212
def test_search_on_relation_preserves_where
162213
# Verify .search() works on relations and preserves WHERE clauses
163214
sql = UnitProduct.where(in_stock: true)

0 commit comments

Comments
 (0)