sqlalchemy-searchable 2.0.0__py3-none-any.whl
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.
- sqlalchemy_searchable/__init__.py +468 -0
- sqlalchemy_searchable/expressions.sql +27 -0
- sqlalchemy_searchable/vectorizers.py +80 -0
- sqlalchemy_searchable-2.0.0.dist-info/METADATA +47 -0
- sqlalchemy_searchable-2.0.0.dist-info/RECORD +7 -0
- sqlalchemy_searchable-2.0.0.dist-info/WHEEL +4 -0
- sqlalchemy_searchable-2.0.0.dist-info/licenses/LICENSE +27 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from functools import reduce
|
|
3
|
+
|
|
4
|
+
import sqlalchemy as sa
|
|
5
|
+
from sqlalchemy import event
|
|
6
|
+
from sqlalchemy.ext.compiler import compiles
|
|
7
|
+
from sqlalchemy.schema import DDL, DDLElement
|
|
8
|
+
from sqlalchemy.sql.expression import Executable
|
|
9
|
+
from sqlalchemy_utils import TSVectorType
|
|
10
|
+
|
|
11
|
+
from .vectorizers import Vectorizer
|
|
12
|
+
|
|
13
|
+
__version__ = "2.0.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
vectorizer = Vectorizer()
|
|
17
|
+
"""
|
|
18
|
+
An instance of :class:`Vectorizer` that keeps a track of the registered vectorizers. Use
|
|
19
|
+
this as a decorator to register a function as a vectorizer.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SearchQueryMixin:
|
|
24
|
+
def search(self, search_query, vector=None, regconfig=None, sort=False):
|
|
25
|
+
"""
|
|
26
|
+
Search given query with full text search.
|
|
27
|
+
|
|
28
|
+
:param search_query: the search query
|
|
29
|
+
:param vector: search vector to use
|
|
30
|
+
:param regconfig: postgresql regconfig to be used
|
|
31
|
+
:param sort: order results by relevance (quality of hit)
|
|
32
|
+
"""
|
|
33
|
+
return search(self, search_query, vector=vector, regconfig=regconfig, sort=sort)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def inspect_search_vectors(entity):
|
|
37
|
+
return [
|
|
38
|
+
getattr(entity, key).property.columns[0]
|
|
39
|
+
for key, column in sa.inspect(entity).columns.items()
|
|
40
|
+
if isinstance(column.type, TSVectorType)
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def search(query, search_query, vector=None, regconfig=None, sort=False):
|
|
45
|
+
"""
|
|
46
|
+
Search given query with full text search.
|
|
47
|
+
|
|
48
|
+
:param search_query: the search query
|
|
49
|
+
:param vector: search vector to use
|
|
50
|
+
:param regconfig: postgresql regconfig to be used
|
|
51
|
+
:param sort: Order the results by relevance. This uses `cover density`_ ranking
|
|
52
|
+
algorithm (``ts_rank_cd``) for sorting.
|
|
53
|
+
|
|
54
|
+
.. _cover density: https://www.postgresql.org/docs/devel/textsearch-controls.html#TEXTSEARCH-RANKING
|
|
55
|
+
"""
|
|
56
|
+
if not search_query.strip():
|
|
57
|
+
return query
|
|
58
|
+
|
|
59
|
+
if vector is None:
|
|
60
|
+
entity = query.column_descriptions[0]["entity"]
|
|
61
|
+
search_vectors = inspect_search_vectors(entity)
|
|
62
|
+
vector = search_vectors[0]
|
|
63
|
+
|
|
64
|
+
if regconfig is None:
|
|
65
|
+
regconfig = search_manager.options["regconfig"]
|
|
66
|
+
|
|
67
|
+
query = query.filter(
|
|
68
|
+
vector.op("@@")(sa.func.parse_websearch(regconfig, search_query))
|
|
69
|
+
)
|
|
70
|
+
if sort:
|
|
71
|
+
query = query.order_by(
|
|
72
|
+
sa.desc(sa.func.ts_rank_cd(vector, sa.func.parse_websearch(search_query)))
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return query.params(term=search_query)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SQLConstruct:
|
|
79
|
+
def __init__(self, tsvector_column, indexed_columns=None, options=None):
|
|
80
|
+
self.table = tsvector_column.table
|
|
81
|
+
self.tsvector_column = tsvector_column
|
|
82
|
+
self.options = self.init_options(options)
|
|
83
|
+
if indexed_columns:
|
|
84
|
+
self.indexed_columns = list(indexed_columns)
|
|
85
|
+
elif hasattr(self.tsvector_column.type, "columns"):
|
|
86
|
+
self.indexed_columns = list(self.tsvector_column.type.columns)
|
|
87
|
+
else:
|
|
88
|
+
self.indexed_columns = None
|
|
89
|
+
|
|
90
|
+
def init_options(self, options=None):
|
|
91
|
+
if not options:
|
|
92
|
+
options = {}
|
|
93
|
+
for key, value in SearchManager.default_options.items():
|
|
94
|
+
try:
|
|
95
|
+
option = self.tsvector_column.type.options[key]
|
|
96
|
+
except (KeyError, AttributeError):
|
|
97
|
+
option = value
|
|
98
|
+
options.setdefault(key, option)
|
|
99
|
+
return options
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def table_name(self):
|
|
103
|
+
if self.table.schema:
|
|
104
|
+
return f'{self.table.schema}."{self.table.name}"'
|
|
105
|
+
else:
|
|
106
|
+
return '"' + self.table.name + '"'
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def search_function_name(self):
|
|
110
|
+
return self.options["search_trigger_function_name"].format(
|
|
111
|
+
table=self.table.name, column=self.tsvector_column.name
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def search_trigger_name(self):
|
|
116
|
+
return self.options["search_trigger_name"].format(
|
|
117
|
+
table=self.table.name, column=self.tsvector_column.name
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def column_vector(self, column):
|
|
121
|
+
value = sa.text(f"NEW.{sa.column(column.name)}")
|
|
122
|
+
try:
|
|
123
|
+
vectorizer_func = vectorizer[column]
|
|
124
|
+
except KeyError:
|
|
125
|
+
pass
|
|
126
|
+
else:
|
|
127
|
+
value = vectorizer_func(value)
|
|
128
|
+
value = sa.func.coalesce(value, sa.text("''"))
|
|
129
|
+
value = sa.func.to_tsvector(sa.literal(self.options["regconfig"]), value)
|
|
130
|
+
if column.name in self.options["weights"]:
|
|
131
|
+
weight = self.options["weights"][column.name]
|
|
132
|
+
value = sa.func.setweight(value, weight)
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
def search_vector(self, compiler):
|
|
136
|
+
vectors = (
|
|
137
|
+
self.column_vector(getattr(self.table.c, column_name))
|
|
138
|
+
for column_name in self.indexed_columns
|
|
139
|
+
)
|
|
140
|
+
concatenated = reduce(lambda x, y: x.op("||")(y), vectors)
|
|
141
|
+
return compiler.sql_compiler.process(concatenated, literal_binds=True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CreateSearchFunctionSQL(SQLConstruct, DDLElement, Executable):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@compiles(CreateSearchFunctionSQL)
|
|
149
|
+
def compile_create_search_function_sql(element, compiler):
|
|
150
|
+
return f"""CREATE FUNCTION
|
|
151
|
+
{element.search_function_name}() RETURNS TRIGGER AS $$
|
|
152
|
+
BEGIN
|
|
153
|
+
NEW.{element.tsvector_column.name} = {element.search_vector(compiler)};
|
|
154
|
+
RETURN NEW;
|
|
155
|
+
END
|
|
156
|
+
$$ LANGUAGE 'plpgsql';
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class CreateSearchTriggerSQL(SQLConstruct, DDLElement, Executable):
|
|
161
|
+
@property
|
|
162
|
+
def search_trigger_function_with_trigger_args(self):
|
|
163
|
+
if self.options["weights"] or any(
|
|
164
|
+
getattr(self.table.c, column) in vectorizer
|
|
165
|
+
for column in self.indexed_columns
|
|
166
|
+
):
|
|
167
|
+
return self.search_function_name + "()"
|
|
168
|
+
return "tsvector_update_trigger({arguments})".format(
|
|
169
|
+
arguments=", ".join(
|
|
170
|
+
[self.tsvector_column.name, "'%s'" % self.options["regconfig"]]
|
|
171
|
+
+ self.indexed_columns
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@compiles(CreateSearchTriggerSQL)
|
|
177
|
+
def compile_create_search_trigger_sql(element, compiler):
|
|
178
|
+
return (
|
|
179
|
+
f"CREATE TRIGGER {element.search_trigger_name}"
|
|
180
|
+
f" BEFORE UPDATE OR INSERT ON {element.table_name}"
|
|
181
|
+
" FOR EACH ROW EXECUTE PROCEDURE"
|
|
182
|
+
f" {element.search_trigger_function_with_trigger_args}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class DropSearchFunctionSQL(SQLConstruct, DDLElement, Executable):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@compiles(DropSearchFunctionSQL)
|
|
191
|
+
def compile_drop_search_function_sql(element, compiler):
|
|
192
|
+
return "DROP FUNCTION IF EXISTS %s()" % element.search_function_name
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class DropSearchTriggerSQL(SQLConstruct, DDLElement, Executable):
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@compiles(DropSearchTriggerSQL)
|
|
200
|
+
def compile_drop_search_trigger_sql(element, compiler):
|
|
201
|
+
return (
|
|
202
|
+
f"DROP TRIGGER IF EXISTS {element.search_trigger_name} ON {element.table_name}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class SearchManager:
|
|
207
|
+
default_options = {
|
|
208
|
+
"search_trigger_name": "{table}_{column}_trigger",
|
|
209
|
+
"search_trigger_function_name": "{table}_{column}_update",
|
|
210
|
+
"regconfig": "pg_catalog.english",
|
|
211
|
+
"weights": (),
|
|
212
|
+
"auto_index": True,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def __init__(self, options={}):
|
|
216
|
+
self.options = self.default_options
|
|
217
|
+
self.options.update(options)
|
|
218
|
+
self.processed_columns = []
|
|
219
|
+
self.classes = set()
|
|
220
|
+
self.listeners = []
|
|
221
|
+
|
|
222
|
+
def option(self, column, name):
|
|
223
|
+
try:
|
|
224
|
+
return column.type.options[name]
|
|
225
|
+
except (AttributeError, KeyError):
|
|
226
|
+
return self.options[name]
|
|
227
|
+
|
|
228
|
+
def inspect_columns(self, table):
|
|
229
|
+
"""
|
|
230
|
+
Inspects all searchable columns for given class.
|
|
231
|
+
|
|
232
|
+
:param table: SQLAlchemy Table
|
|
233
|
+
"""
|
|
234
|
+
return [column for column in table.c if isinstance(column.type, TSVectorType)]
|
|
235
|
+
|
|
236
|
+
def append_index(self, cls, column):
|
|
237
|
+
sa.Index(
|
|
238
|
+
"_".join(("ix", column.table.name, column.name)),
|
|
239
|
+
column,
|
|
240
|
+
postgresql_using="gin",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def process_mapper(self, mapper, cls):
|
|
244
|
+
columns = self.inspect_columns(mapper.persist_selectable)
|
|
245
|
+
for column in columns:
|
|
246
|
+
if column in self.processed_columns:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
if self.option(column, "auto_index"):
|
|
250
|
+
self.append_index(cls, column)
|
|
251
|
+
|
|
252
|
+
self.processed_columns.append(column)
|
|
253
|
+
|
|
254
|
+
def add_listener(self, args):
|
|
255
|
+
self.listeners.append(args)
|
|
256
|
+
event.listen(*args)
|
|
257
|
+
|
|
258
|
+
def remove_listeners(self):
|
|
259
|
+
for listener in self.listeners:
|
|
260
|
+
event.remove(*listener)
|
|
261
|
+
self.listeners = []
|
|
262
|
+
|
|
263
|
+
def attach_ddl_listeners(self):
|
|
264
|
+
# Remove all previously added listeners, so that same listener don't
|
|
265
|
+
# get added twice in situations where class configuration happens in
|
|
266
|
+
# multiple phases (issue #31).
|
|
267
|
+
self.remove_listeners()
|
|
268
|
+
|
|
269
|
+
for column in self.processed_columns:
|
|
270
|
+
# This sets up the trigger that keeps the tsvector column up to
|
|
271
|
+
# date.
|
|
272
|
+
if column.type.columns:
|
|
273
|
+
table = column.table
|
|
274
|
+
if self.option(column, "weights") or vectorizer.contains_tsvector(
|
|
275
|
+
column
|
|
276
|
+
):
|
|
277
|
+
self.add_listener(
|
|
278
|
+
(table, "after_create", CreateSearchFunctionSQL(column))
|
|
279
|
+
)
|
|
280
|
+
self.add_listener(
|
|
281
|
+
(table, "after_drop", DropSearchFunctionSQL(column))
|
|
282
|
+
)
|
|
283
|
+
self.add_listener(
|
|
284
|
+
(table, "after_create", CreateSearchTriggerSQL(column))
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
search_manager = SearchManager()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def sync_trigger(
|
|
292
|
+
conn, table_name, tsvector_column, indexed_columns, metadata=None, options=None
|
|
293
|
+
):
|
|
294
|
+
"""Synchronize the search trigger and trigger function for the given table and
|
|
295
|
+
search vector column. Internally, this function executes the following SQL
|
|
296
|
+
queries:
|
|
297
|
+
|
|
298
|
+
- Drop the search trigger for the given table and column if it exists.
|
|
299
|
+
- Drop the search function for the given table and column if it exists.
|
|
300
|
+
- Create the search function for the given table and column.
|
|
301
|
+
- Create the search trigger for the given table and column.
|
|
302
|
+
- Update all rows for the given search vector by executing a column=column update
|
|
303
|
+
query for the given table.
|
|
304
|
+
|
|
305
|
+
Example::
|
|
306
|
+
|
|
307
|
+
from sqlalchemy_searchable import sync_trigger
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
sync_trigger(
|
|
311
|
+
conn,
|
|
312
|
+
'article',
|
|
313
|
+
'search_vector',
|
|
314
|
+
['name', 'content']
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
This function is especially useful when working with Alembic migrations. In the
|
|
318
|
+
following example, we add a ``content`` column to the ``article`` table and then
|
|
319
|
+
synchronize the trigger to contain this new column::
|
|
320
|
+
|
|
321
|
+
from alembic import op
|
|
322
|
+
from sqlalchemy_searchable import sync_trigger
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def upgrade():
|
|
326
|
+
conn = op.get_bind()
|
|
327
|
+
op.add_column('article', sa.Column('content', sa.Text))
|
|
328
|
+
|
|
329
|
+
sync_trigger(conn, 'article', 'search_vector', ['name', 'content'])
|
|
330
|
+
|
|
331
|
+
# ... same for downgrade
|
|
332
|
+
|
|
333
|
+
If you are using vectorizers, you need to initialize them in your migration
|
|
334
|
+
file and pass them to this function::
|
|
335
|
+
|
|
336
|
+
import sqlalchemy as sa
|
|
337
|
+
from alembic import op
|
|
338
|
+
from sqlalchemy.dialects.postgresql import HSTORE
|
|
339
|
+
from sqlalchemy_searchable import sync_trigger, vectorizer
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def upgrade():
|
|
343
|
+
vectorizer.clear()
|
|
344
|
+
|
|
345
|
+
conn = op.get_bind()
|
|
346
|
+
op.add_column('article', sa.Column('name_translations', HSTORE))
|
|
347
|
+
|
|
348
|
+
metadata = sa.MetaData(bind=conn)
|
|
349
|
+
articles = sa.Table('article', metadata, autoload=True)
|
|
350
|
+
|
|
351
|
+
@vectorizer(articles.c.name_translations)
|
|
352
|
+
def hstore_vectorizer(column):
|
|
353
|
+
return sa.cast(sa.func.avals(column), sa.Text)
|
|
354
|
+
|
|
355
|
+
op.add_column('article', sa.Column('content', sa.Text))
|
|
356
|
+
sync_trigger(
|
|
357
|
+
conn,
|
|
358
|
+
'article',
|
|
359
|
+
'search_vector',
|
|
360
|
+
['name_translations', 'content'],
|
|
361
|
+
metadata=metadata
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# ... same for downgrade
|
|
365
|
+
|
|
366
|
+
:param conn: SQLAlchemy Connection object
|
|
367
|
+
:param table_name: name of the table to apply search trigger syncing
|
|
368
|
+
:param tsvector_column:
|
|
369
|
+
TSVector typed column which is used as the search index column
|
|
370
|
+
:param indexed_columns:
|
|
371
|
+
Full text indexed column names as a list
|
|
372
|
+
:param metadata:
|
|
373
|
+
Optional SQLAlchemy metadata object that is being used for autoloaded
|
|
374
|
+
Table. If None is given, then a new MetaData object is initialized within
|
|
375
|
+
this function.
|
|
376
|
+
:param options: Dictionary of configuration options
|
|
377
|
+
"""
|
|
378
|
+
if metadata is None:
|
|
379
|
+
metadata = sa.MetaData()
|
|
380
|
+
table = sa.Table(table_name, metadata, autoload_with=conn)
|
|
381
|
+
params = dict(
|
|
382
|
+
tsvector_column=getattr(table.c, tsvector_column),
|
|
383
|
+
indexed_columns=indexed_columns,
|
|
384
|
+
options=options,
|
|
385
|
+
)
|
|
386
|
+
classes = [
|
|
387
|
+
DropSearchTriggerSQL,
|
|
388
|
+
DropSearchFunctionSQL,
|
|
389
|
+
CreateSearchFunctionSQL,
|
|
390
|
+
CreateSearchTriggerSQL,
|
|
391
|
+
]
|
|
392
|
+
for class_ in classes:
|
|
393
|
+
conn.execute(class_(**params))
|
|
394
|
+
update_sql = table.update().values(
|
|
395
|
+
{indexed_columns[0]: sa.text(indexed_columns[0])}
|
|
396
|
+
)
|
|
397
|
+
conn.execute(update_sql)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def drop_trigger(conn, table_name, tsvector_column, metadata=None, options=None):
|
|
401
|
+
"""
|
|
402
|
+
Drop the search trigger and trigger function for the given table and
|
|
403
|
+
search vector column. Internally, this function executes the following SQL
|
|
404
|
+
queries:
|
|
405
|
+
|
|
406
|
+
- Drop the search trigger for the given table if it exists.
|
|
407
|
+
- Drop the search function for the given table if it exists.
|
|
408
|
+
|
|
409
|
+
Example::
|
|
410
|
+
|
|
411
|
+
from alembic import op
|
|
412
|
+
from sqlalchemy_searchable import drop_trigger
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def downgrade():
|
|
416
|
+
conn = op.get_bind()
|
|
417
|
+
|
|
418
|
+
drop_trigger(conn, 'article', 'search_vector')
|
|
419
|
+
op.drop_index('ix_article_search_vector', table_name='article')
|
|
420
|
+
op.drop_column('article', 'search_vector')
|
|
421
|
+
|
|
422
|
+
:param conn: SQLAlchemy Connection object
|
|
423
|
+
:param table_name: name of the table to apply search trigger dropping
|
|
424
|
+
:param tsvector_column:
|
|
425
|
+
TSVector typed column which is used as the search index column
|
|
426
|
+
:param metadata:
|
|
427
|
+
Optional SQLAlchemy metadata object that is being used for autoloaded
|
|
428
|
+
Table. If None is given, then a new MetaData object is initialized within
|
|
429
|
+
this function.
|
|
430
|
+
:param options: Dictionary of configuration options
|
|
431
|
+
"""
|
|
432
|
+
if metadata is None:
|
|
433
|
+
metadata = sa.MetaData()
|
|
434
|
+
table = sa.Table(table_name, metadata, autoload_with=conn)
|
|
435
|
+
params = dict(tsvector_column=getattr(table.c, tsvector_column), options=options)
|
|
436
|
+
classes = [
|
|
437
|
+
DropSearchTriggerSQL,
|
|
438
|
+
DropSearchFunctionSQL,
|
|
439
|
+
]
|
|
440
|
+
for class_ in classes:
|
|
441
|
+
conn.execute(class_(**params))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
path = os.path.dirname(os.path.abspath(__file__))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
with open(os.path.join(path, "expressions.sql")) as file:
|
|
448
|
+
sql_expressions = DDL(file.read())
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def make_searchable(metadata, mapper=sa.orm.Mapper, manager=search_manager, options={}):
|
|
452
|
+
"""
|
|
453
|
+
Configure SQLAlchemy-Searchable for given SQLAlchemy metadata object.
|
|
454
|
+
|
|
455
|
+
:param metadata: SQLAlchemy metadata object
|
|
456
|
+
:param options: Dictionary of configuration options
|
|
457
|
+
"""
|
|
458
|
+
manager.options.update(options)
|
|
459
|
+
event.listen(mapper, "instrument_class", manager.process_mapper)
|
|
460
|
+
event.listen(mapper, "after_configured", manager.attach_ddl_listeners)
|
|
461
|
+
event.listen(metadata, "before_create", sql_expressions)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def remove_listeners(metadata, manager=search_manager, mapper=sa.orm.Mapper):
|
|
465
|
+
event.remove(mapper, "instrument_class", manager.process_mapper)
|
|
466
|
+
event.remove(mapper, "after_configured", manager.attach_ddl_listeners)
|
|
467
|
+
manager.remove_listeners()
|
|
468
|
+
event.remove(metadata, "before_create", sql_expressions)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
CREATE OR REPLACE FUNCTION parse_websearch(config regconfig, search_query text)
|
|
2
|
+
RETURNS tsquery AS $$
|
|
3
|
+
SELECT
|
|
4
|
+
string_agg(
|
|
5
|
+
(
|
|
6
|
+
CASE
|
|
7
|
+
WHEN position('''' IN words.word) > 0 THEN CONCAT(words.word, ':*')
|
|
8
|
+
ELSE words.word
|
|
9
|
+
END
|
|
10
|
+
),
|
|
11
|
+
' '
|
|
12
|
+
)::tsquery
|
|
13
|
+
FROM (
|
|
14
|
+
SELECT trim(
|
|
15
|
+
regexp_split_to_table(
|
|
16
|
+
websearch_to_tsquery(config, lower(search_query))::text,
|
|
17
|
+
' '
|
|
18
|
+
)
|
|
19
|
+
) AS word
|
|
20
|
+
) AS words
|
|
21
|
+
$$ LANGUAGE SQL IMMUTABLE;
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
CREATE OR REPLACE FUNCTION parse_websearch(search_query text)
|
|
25
|
+
RETURNS tsquery AS $$
|
|
26
|
+
SELECT parse_websearch('pg_catalog.simple', search_query);
|
|
27
|
+
$$ LANGUAGE SQL IMMUTABLE;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from inspect import isclass
|
|
3
|
+
|
|
4
|
+
import sqlalchemy as sa
|
|
5
|
+
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
6
|
+
from sqlalchemy.sql.type_api import TypeEngine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Vectorizer:
|
|
10
|
+
def __init__(self, type_vectorizers=None, column_vectorizers=None):
|
|
11
|
+
self.type_vectorizers = {} if type_vectorizers is None else type_vectorizers
|
|
12
|
+
self.column_vectorizers = (
|
|
13
|
+
{} if column_vectorizers is None else column_vectorizers
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
def clear(self):
|
|
17
|
+
"""Clear all registered vectorizers."""
|
|
18
|
+
self.type_vectorizers = {}
|
|
19
|
+
self.column_vectorizers = {}
|
|
20
|
+
|
|
21
|
+
def contains_tsvector(self, tsvector_column):
|
|
22
|
+
if not hasattr(tsvector_column.type, "columns"):
|
|
23
|
+
return False
|
|
24
|
+
return any(
|
|
25
|
+
getattr(tsvector_column.table.c, column) in self
|
|
26
|
+
for column in tsvector_column.type.columns
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def __contains__(self, column):
|
|
30
|
+
try:
|
|
31
|
+
self[column]
|
|
32
|
+
return True
|
|
33
|
+
except KeyError:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, column):
|
|
37
|
+
if column in self.column_vectorizers:
|
|
38
|
+
return self.column_vectorizers[column]
|
|
39
|
+
type_class = column.type.__class__
|
|
40
|
+
|
|
41
|
+
if type_class in self.type_vectorizers:
|
|
42
|
+
return self.type_vectorizers[type_class]
|
|
43
|
+
raise KeyError(column)
|
|
44
|
+
|
|
45
|
+
def __call__(self, type_or_column):
|
|
46
|
+
"""Decorator to register a function as a vectorizer.
|
|
47
|
+
|
|
48
|
+
:param type_or_column: the SQLAlchemy database data type or the column to
|
|
49
|
+
register a vectorizer for
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def outer(func):
|
|
53
|
+
@wraps(func)
|
|
54
|
+
def wrapper(*args, **kwargs):
|
|
55
|
+
return func(*args, **kwargs)
|
|
56
|
+
|
|
57
|
+
if isclass(type_or_column) and issubclass(type_or_column, TypeEngine):
|
|
58
|
+
self.type_vectorizers[type_or_column] = wrapper
|
|
59
|
+
elif isinstance(type_or_column, sa.Column):
|
|
60
|
+
self.column_vectorizers[type_or_column] = wrapper
|
|
61
|
+
elif isinstance(type_or_column, InstrumentedAttribute):
|
|
62
|
+
prop = type_or_column.property
|
|
63
|
+
if not isinstance(prop, sa.orm.ColumnProperty):
|
|
64
|
+
raise TypeError(
|
|
65
|
+
"Given InstrumentedAttribute does not wrap "
|
|
66
|
+
"ColumnProperty. Only instances of ColumnProperty are "
|
|
67
|
+
"supported for vectorizer."
|
|
68
|
+
)
|
|
69
|
+
column = type_or_column.property.columns[0]
|
|
70
|
+
|
|
71
|
+
self.column_vectorizers[column] = wrapper
|
|
72
|
+
else:
|
|
73
|
+
raise TypeError(
|
|
74
|
+
"First argument should be either valid SQLAlchemy type, "
|
|
75
|
+
"Column, ColumnProperty or InstrumentedAttribute object."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return wrapper
|
|
79
|
+
|
|
80
|
+
return outer
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: sqlalchemy-searchable
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Provides fulltext search capabilities for declarative SQLAlchemy models.
|
|
5
|
+
Project-URL: Code, https://github.com/kvesteri/sqlalchemy-searchable
|
|
6
|
+
Project-URL: Documentation, https://sqlalchemy-searchable.readthedocs.io/
|
|
7
|
+
Project-URL: Issue Tracker, http://github.com/kvesteri/sqlalchemy-searchable/issues
|
|
8
|
+
Author-email: Konsta Vesterinen <konsta@fastmonkeys.com>
|
|
9
|
+
License-Expression: BSD-3-Clause
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Requires-Dist: sqlalchemy-utils>=0.40.0
|
|
25
|
+
Requires-Dist: sqlalchemy>=1.4
|
|
26
|
+
Description-Content-Type: text/x-rst
|
|
27
|
+
|
|
28
|
+
SQLAlchemy-Searchable
|
|
29
|
+
=====================
|
|
30
|
+
|
|
31
|
+
|Version Status| |Downloads|
|
|
32
|
+
|
|
33
|
+
Fulltext searchable models for SQLAlchemy. Only supports PostgreSQL
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
Resources
|
|
37
|
+
---------
|
|
38
|
+
|
|
39
|
+
- `Documentation <https://sqlalchemy-searchable.readthedocs.io/>`_
|
|
40
|
+
- `Issue Tracker <http://github.com/kvesteri/sqlalchemy-searchable/issues>`_
|
|
41
|
+
- `Code <http://github.com/kvesteri/sqlalchemy-searchable/>`_
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
.. |Version Status| image:: https://img.shields.io/pypi/v/SQLAlchemy-Searchable.svg
|
|
45
|
+
:target: https://pypi.python.org/pypi/SQLAlchemy-Searchable/
|
|
46
|
+
.. |Downloads| image:: https://img.shields.io/pypi/dm/SQLAlchemy-Searchable.svg
|
|
47
|
+
:target: https://pypi.python.org/pypi/SQLAlchemy-Searchable/
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
sqlalchemy_searchable/__init__.py,sha256=t0ahfsv2me-nJRHpOb1Zz22fL05s0V_ZIr5tY3t_2Yk,15621
|
|
2
|
+
sqlalchemy_searchable/expressions.sql,sha256=FJPoygBzVbfg3fdyIqWW3Lz118l3CvLYYi8GAEWY2sI,696
|
|
3
|
+
sqlalchemy_searchable/vectorizers.py,sha256=G5f6Qyqm0tcXogL9OPCGXi8d-s92c5489EKicOUDuqc,2835
|
|
4
|
+
sqlalchemy_searchable-2.0.0.dist-info/METADATA,sha256=d4izduiTxs6tu2iEOfIb8mMTYnwPGB_zcdrLEFe3BXQ,1896
|
|
5
|
+
sqlalchemy_searchable-2.0.0.dist-info/WHEEL,sha256=9QBuHhg6FNW7lppboF2vKVbCGTVzsFykgRQjjlajrhA,87
|
|
6
|
+
sqlalchemy_searchable-2.0.0.dist-info/licenses/LICENSE,sha256=aKpRvWCrOmo-gm2RyB2KhgP4FtG6tTWi_xi_fWmqmwo,1437
|
|
7
|
+
sqlalchemy_searchable-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Copyright (c) 2012, Konsta Vesterinen
|
|
2
|
+
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
* The names of the contributors may not be used to endorse or promote products
|
|
16
|
+
derived from this software without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
19
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
20
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
21
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT,
|
|
22
|
+
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
|
23
|
+
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
24
|
+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
25
|
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
|
26
|
+
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
|
|
27
|
+
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|