Skip to content

Commit 55525c4

Browse files
fix(thing_helper): Add ilike back for partial searches
1 parent f30cce4 commit 55525c4

4 files changed

Lines changed: 101 additions & 22 deletions

File tree

db/engine.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def getconn():
179179

180180
conn = connector.connect(
181181
instance_name, # The Cloud SQL instance name
182-
"pg8000",
182+
"psycopg",
183183
**connect_kwargs,
184184
)
185185
return conn
@@ -190,7 +190,7 @@ def getconn():
190190
pool_timeout = int(os.environ.get("DB_POOL_TIMEOUT", "30"))
191191

192192
engine = create_engine(
193-
"postgresql+pg8000://",
193+
"postgresql+psycopg://",
194194
creator=getconn,
195195
echo=False,
196196
pool_size=pool_size,
@@ -220,7 +220,7 @@ def getconn():
220220

221221
auth = f"{user}:{password}@" if user and password else ""
222222
port_part = f":{port}" if port else ""
223-
url = f"postgresql+pg8000://{auth}{host}{port_part}/{name}"
223+
url = f"postgresql+psycopg://{auth}{host}{port_part}/{name}"
224224
# else:
225225
# url = "sqlite:///./development.db"
226226

@@ -243,7 +243,7 @@ def getconn():
243243
_install_pool_logging(engine)
244244

245245
async_engine = create_async_engine(
246-
url.replace("postgresql+pg8000", "postgresql+asyncpg"),
246+
url.replace("postgresql+psycopg", "postgresql+asyncpg"),
247247
plugins=["geoalchemy2"],
248248
)
249249
# if "postgresql" not in url:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ dependencies = [
103103
"utm==0.8.1",
104104
"uvicorn==0.42.0",
105105
"yarl==1.23.0",
106+
"psycopg[binary]>=3.3.3",
106107
]
107108

108109
[tool.uv]

services/thing_helper.py

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -130,25 +130,36 @@ def get_db_things(
130130

131131
# Querying logic
132132
#
133-
# We combine multiple search strategies:
133+
# Combines multiple search strategies so the global search supports:
134134
#
135-
# 1. Full-text search (tsvector)
136-
# - Good for word-based and multi-word searches
137-
# - Uses indexed search_vector column
135+
# 1. Full-text search using Thing.search_vector
136+
# - Best for normal word-based and multi-word searches.
138137
#
139-
# 2. Trigram fuzzy matching (% operator from pg_trgm)
140-
# - Handles typos (e.g. "Aron" vs "Aaron")
138+
# 2. Trigram fuzzy search using pg_trgm operators
139+
# - "%" compares similarity across the full string.
140+
# - "<%" compares similarity against a word/substring extent,
141+
# which helps with names like "cary" matching "john carry".
142+
#
143+
# 3. Partial substring search using ILIKE
144+
# - Handles very short partial inputs like "ty" matching "tyler".
145+
# - This is useful because trigram matching is less effective for
146+
# short queries under 3 characters.
141147
#
142148
# OR is used so any matching strategy can return a result.
143149
if query and query.strip():
144150
clean_query = query.strip()
151+
partial_query = f"%{clean_query}%"
145152

146-
# Similarity scores (used ONLY for ranking, not filtering)
153+
# Ranking scores
147154
#
148-
# These use pg_trgm's similarity() to compute how close each field
149-
# is to the search query. Higher = more similar.
150-
name_sim = func.similarity(Thing.name, clean_query)
151-
type_sim = func.similarity(Thing.thing_type, clean_query)
155+
# These are used only for ordering results, not for filtering.
156+
# similarity() ranks whole-string similarity.
157+
# word_similarity() ranks best word/span similarity.
158+
name_sim = func.coalesce(func.similarity(Thing.name, clean_query), 0)
159+
type_sim = func.coalesce(func.similarity(Thing.thing_type, clean_query), 0)
160+
161+
name_word_sim = func.coalesce(func.word_similarity(Thing.name, clean_query), 0)
162+
type_word_sim = func.coalesce(func.word_similarity(Thing.thing_type, clean_query), 0)
152163

153164
search_conditions = [
154165
Thing.search_vector.op("@@")(
@@ -159,27 +170,46 @@ def get_db_things(
159170
),
160171
Thing.name.op("%")(clean_query),
161172
Thing.thing_type.op("%")(clean_query),
173+
Thing.name.op("<%")(clean_query),
174+
Thing.thing_type.op("<%")(clean_query),
175+
Thing.name.ilike(partial_query),
176+
Thing.thing_type.ilike(partial_query),
162177
]
163178

164179
rank_expressions = [
165180
name_sim,
166181
type_sim,
182+
name_word_sim,
183+
type_word_sim,
167184
]
168185

169186
if include_contacts:
170187
contact_sim = func.coalesce(func.similarity(Contact.name, clean_query), 0)
188+
contact_word_sim = func.coalesce(
189+
func.word_similarity(clean_query, Contact.name), 0
190+
)
171191

172192
sql = sql.outerjoin(Thing.contact_associations).outerjoin(
173193
ThingContactAssociation.contact
174194
)
175195

176-
search_conditions.append(Contact.name.op("%")(clean_query))
177-
rank_expressions.append(contact_sim)
196+
search_conditions.extend(
197+
[
198+
Contact.name.op("%")(clean_query),
199+
Contact.name.op("<%")(clean_query),
200+
Contact.name.ilike(partial_query),
201+
]
202+
)
203+
204+
rank_expressions.extend(
205+
[
206+
contact_sim,
207+
contact_word_sim,
208+
]
209+
)
178210

179-
sql = (
180-
sql.where(or_(*search_conditions))
181-
.order_by(desc(func.greatest(*rank_expressions)))
182-
.distinct(Thing.id)
211+
sql = sql.where(or_(*search_conditions)).order_by(
212+
desc(func.greatest(*rank_expressions))
183213
)
184214

185215
if include_contacts:
@@ -238,7 +268,7 @@ def get_db_things(
238268

239269
sql = order_sort_filter(sql, Thing, sort, order, filters=merged_filters)
240270

241-
return paginate(query=sql, conn=session)
271+
return paginate(query=sql, conn=session, unique=True)
242272

243273

244274
def get_thing_type_from_request(request: Request) -> str:

uv.lock

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)