source: main/branches/3D/openPLM/xapian_backend.py @ 662

Revision 662, 49.6 KB checked in by pcosquer, 8 years ago (diff)

3D branch: merge changes from trunk (rev [661])

Line 
1# Copyright (C) 2009, 2010, 2011 David Sauve
2# Copyright (C) 2009, 2010 Trapeze
3# 2011: Modifications by LinObject
4
5__author__ = 'David Sauve'
6__version__ = (1, 1, 6, 'beta')
7
8import time
9import datetime
10import cPickle as pickle
11import os
12import re
13import shutil
14import sys
15import warnings
16
17from django.conf import settings
18from django.core.exceptions import ImproperlyConfigured
19from django.utils.encoding import smart_unicode, force_unicode
20
21from haystack.backends import BaseSearchBackend, BaseSearchQuery, SearchNode, log_query
22from haystack.constants import ID, DJANGO_CT, DJANGO_ID
23from haystack.exceptions import HaystackError, MissingDependency, MoreLikeThisError
24from haystack.fields import DateField, DateTimeField, IntegerField, FloatField, BooleanField, MultiValueField
25from haystack.models import SearchResult
26from haystack.utils import get_identifier
27
28try:
29    import xapian
30except ImportError:
31    raise MissingDependency("The 'xapian' backend requires the installation of 'xapian'. Please refer to the documentation.")
32
33
34DOCUMENT_ID_TERM_PREFIX = 'Q'
35DOCUMENT_CUSTOM_TERM_PREFIX = 'X'
36DOCUMENT_CT_TERM_PREFIX = DOCUMENT_CUSTOM_TERM_PREFIX + 'CONTENTTYPE'
37
38MEMORY_DB_NAME = ':memory:'
39
40BACKEND_NAME = 'xapian'
41
42DEFAULT_XAPIAN_FLAGS = (
43    xapian.QueryParser.FLAG_PHRASE |
44    xapian.QueryParser.FLAG_BOOLEAN |
45    xapian.QueryParser.FLAG_LOVEHATE |
46    xapian.QueryParser.FLAG_WILDCARD |
47    xapian.QueryParser.FLAG_PURE_NOT
48)
49
50
51class InvalidIndexError(HaystackError):
52    """Raised when an index can not be opened."""
53    pass
54
55
56class XHValueRangeProcessor(xapian.ValueRangeProcessor):
57    def __init__(self, backend):
58        self.backend = backend or SearchBackend()
59        xapian.ValueRangeProcessor.__init__(self)
60   
61    def __call__(self, begin, end):
62        """
63        Construct a tuple for value range processing.
64        `begin` -- a string in the format '<field_name>:[low_range]'
65        If 'low_range' is omitted, assume the smallest possible value.
66        `end` -- a string in the the format '[high_range|*]'. If '*', assume
67        the highest possible value.
68        Return a tuple of three strings: (column, low, high)
69        """
70        colon = begin.find(':')
71        if colon == -1:
72            field_name = "text"
73        else:
74            field_name = begin[:colon] or "text"
75            begin = begin[colon + 1:len(begin)]
76        for field_dict in self.backend.schema:
77            if field_dict['field_name'] == field_name:
78                if not begin:
79                    if field_dict['type'] == 'text':
80                        begin = u'a' # TODO: A better way of getting a min text value?
81                    elif field_dict['type'] == 'long':
82                        begin = -sys.maxint - 1
83                    elif field_dict['type'] == 'float':
84                        begin = float('-inf')
85                    elif field_dict['type'] == 'date' or field_dict['type'] == 'datetime':
86                        begin = u'00010101000000'
87                elif end == '*':
88                    if field_dict['type'] == 'text':
89                        end = u'z' * 100 # TODO: A better way of getting a max text value?
90                    elif field_dict['type'] == 'long':
91                        end = sys.maxint
92                    elif field_dict['type'] == 'float':
93                        end = float('inf')
94                    elif field_dict['type'] == 'date' or field_dict['type'] == 'datetime':
95                        end = u'99990101000000'
96                if field_dict['type'] == 'float':
97                    begin = _marshal_value(float(begin))
98                    end = _marshal_value(float(end))
99                elif field_dict['type'] == 'long':
100                    begin = _marshal_value(long(begin))
101                    end = _marshal_value(long(end))
102                return field_dict['column'], str(begin), str(end)
103        return xapian.BAD_VALUENO, str(begin), str(end)
104
105class XHExpandDecider(xapian.ExpandDecider):
106    def __call__(self, term):
107        """
108        Return True if the term should be used for expanding the search
109        query, False otherwise.
110       
111        Currently, we only want to ignore terms beginning with `DOCUMENT_CT_TERM_PREFIX`
112        """
113        if term.startswith(DOCUMENT_CT_TERM_PREFIX):
114            return False
115        return True
116
117
118class SearchBackend(BaseSearchBackend):
119    """
120    `SearchBackend` defines the Xapian search backend for use with the Haystack
121    API for Django search.
122   
123    It uses the Xapian Python bindings to interface with Xapian, and as
124    such is subject to this bug: <http://trac.xapian.org/ticket/364> when
125    Django is running with mod_python or mod_wsgi under Apache.
126   
127    Until this issue has been fixed by Xapian, it is neccessary to set
128    `WSGIApplicationGroup to %{GLOBAL}` when using mod_wsgi, or
129    `PythonInterpreter main_interpreter` when using mod_python.
130   
131    In order to use this backend, `HAYSTACK_XAPIAN_PATH` must be set in
132    your settings.  This should point to a location where you would your
133    indexes to reside.
134    """
135
136    inmemory_db = None
137
138    def __init__(self, site=None, language=None):
139        """
140        Instantiates an instance of `SearchBackend`.
141       
142        Optional arguments:
143            `site` -- The site to associate the backend with (default = None)
144
145        """
146        super(SearchBackend, self).__init__(site)
147       
148        if not hasattr(settings, 'HAYSTACK_XAPIAN_PATH'):
149            raise ImproperlyConfigured('You must specify a HAYSTACK_XAPIAN_PATH in your settings.')
150           
151        if language:
152            raise AttributeError('Language arg is now deprecated. Please use settings.HAYSTACK_XAPIAN_LANGUAGE instead.')
153
154        if settings.HAYSTACK_XAPIAN_PATH != MEMORY_DB_NAME and \
155           not os.path.exists(settings.HAYSTACK_XAPIAN_PATH):
156            os.makedirs(settings.HAYSTACK_XAPIAN_PATH)
157       
158        self.language = getattr(settings, 'HAYSTACK_XAPIAN_LANGUAGE', 'english')
159        self._schema = None
160        self._content_field_name = None
161       
162    @property
163    def schema(self):
164        if not self._schema:
165            self._content_field_name, self._schema = self.build_schema(self.site.all_searchfields())           
166        return self._schema
167
168    @property
169    def content_field_name(self):
170        if not self._content_field_name:
171            self._content_field_name, self._schema = self.build_schema(self.site.all_searchfields())           
172        return self._content_field_name
173   
174    def update(self, index, iterable):
175        """
176        Updates the `index` with any objects in `iterable` by adding/updating
177        the database as needed.
178       
179        Required arguments:
180            `index` -- The `SearchIndex` to process
181            `iterable` -- An iterable of model instances to index
182       
183        For each object in `iterable`, a document is created containing all
184        of the terms extracted from `index.full_prepare(obj)` with field prefixes,
185        and 'as-is' as needed.  Also, if the field type is 'text' it will be
186        stemmed and stored with the 'Z' prefix as well.
187       
188        eg. `content:Testing` ==> `testing, Ztest, ZXCONTENTtest, XCONTENTtest`
189       
190        Each document also contains an extra term in the format:
191       
192        `XCONTENTTYPE<app_name>.<model_name>`
193       
194        As well as a unique identifier in the the format:
195       
196        `Q<app_name>.<model_name>.<pk>`
197       
198        eg.: foo.bar (pk=1) ==> `Qfoo.bar.1`, `XCONTENTTYPEfoo.bar`
199       
200        This is useful for querying for a specific document corresponding to
201        a model instance.
202       
203        The document also contains a pickled version of the object itself and
204        the document ID in the document data field.
205       
206        Finally, we also store field values to be used for sorting data.  We
207        store these in the document value slots (position zero is reserver
208        for the document ID).  All values are stored as unicode strings with
209        conversion of float, int, double, values being done by Xapian itself
210        through the use of the :method:xapian.sortable_serialise method.
211        """
212        database = self._database(writable=True)
213        try:
214            for obj in iterable:
215                document = xapian.Document()
216               
217                term_generator = xapian.TermGenerator()
218                term_generator.set_database(database)
219                term_generator.set_stemmer(xapian.Stem(self.language))
220                if getattr(settings, 'HAYSTACK_INCLUDE_SPELLING', False) is True:
221                    term_generator.set_flags(xapian.TermGenerator.FLAG_SPELLING)
222                term_generator.set_document(document)
223               
224                document_id = DOCUMENT_ID_TERM_PREFIX + get_identifier(obj)
225                data = index.full_prepare(obj)
226                weights = index.get_field_weights()
227                for field in self.schema:
228                    if field['field_name'] in data.keys():
229                        prefix = DOCUMENT_CUSTOM_TERM_PREFIX + field['field_name'].upper()
230                        value = data[field['field_name']]
231                        try:
232                            weight = int(weights[field['field_name']])
233                        except KeyError:
234                            weight = 1
235                        if field['type'] == 'text':
236                            if field['multi_valued'] == 'false':
237                                term = _marshal_term(value)
238                                term_generator.index_text(term, weight)
239                                term_generator.index_text(term, weight, prefix)
240                                if len(term.split()) == 1:
241                                    document.add_term(term, weight)
242                                    document.add_term(prefix + term, weight)
243                                document.add_value(field['column'], _marshal_value(value))
244                            else:
245                                for term in value:
246                                    term = _marshal_term(term)
247                                    term_generator.index_text(term, weight)
248                                    term_generator.index_text(term, weight, prefix)
249                                    if len(term.split()) == 1:
250                                        document.add_term(term, weight)
251                                        document.add_term(prefix + term, weight)
252                        else:
253                            if field['multi_valued'] == 'false':
254                                term = _marshal_term(value)
255                                if len(term.split()) == 1:
256                                    document.add_term(term, weight)
257                                    document.add_term(prefix + term, weight)
258                                    document.add_value(field['column'], _marshal_value(value))
259                            else:
260                                for term in value:
261                                    term = _marshal_term(term)
262                                    if len(term.split()) == 1:
263                                        document.add_term(term, weight)
264                                        document.add_term(prefix + term, weight)
265               
266                document.set_data(pickle.dumps(
267                    (obj._meta.app_label, obj._meta.module_name, obj.pk, data),
268                    pickle.HIGHEST_PROTOCOL
269                ))
270                document.add_term(document_id)
271                document.add_term(
272                    DOCUMENT_CT_TERM_PREFIX + u'%s.%s' %
273                    (obj._meta.app_label, obj._meta.module_name)
274                )
275                database.replace_document(document_id, document)
276       
277        except UnicodeDecodeError:
278            sys.stderr.write('Chunk failed.\n')
279            pass
280       
281        finally:
282            database = None
283   
284    def remove(self, obj):
285        """
286        Remove indexes for `obj` from the database.
287       
288        We delete all instances of `Q<app_name>.<model_name>.<pk>` which
289        should be unique to this object.
290        """
291        database = self._database(writable=True)
292        database.delete_document(DOCUMENT_ID_TERM_PREFIX + get_identifier(obj))
293   
294    def clear(self, models=[]):
295        """
296        Clear all instances of `models` from the database or all models, if
297        not specified.
298       
299        Optional Arguments:
300            `models` -- Models to clear from the database (default = [])
301       
302        If `models` is empty, an empty query is executed which matches all
303        documents in the database.  Afterwards, each match is deleted.
304       
305        Otherwise, for each model, a `delete_document` call is issued with
306        the term `XCONTENTTYPE<app_name>.<model_name>`.  This will delete
307        all documents with the specified model type.
308        """
309        database = self._database(writable=True)
310        if not models:
311            # Because there does not appear to be a "clear all" method,
312            # it's much quicker to remove the contents of the `HAYSTACK_XAPIAN_PATH`
313            # folder than it is to remove each document one at a time.
314            if os.path.exists(settings.HAYSTACK_XAPIAN_PATH):
315                shutil.rmtree(settings.HAYSTACK_XAPIAN_PATH)
316        else:
317            for model in models:
318                database.delete_document(
319                    DOCUMENT_CT_TERM_PREFIX + '%s.%s' %
320                    (model._meta.app_label, model._meta.module_name)
321                )
322
323    def document_count(self):
324        try:
325            return self._database().get_doccount()
326        except InvalidIndexError:
327            return 0
328
329    @log_query
330    def search(self, query, sort_by=None, start_offset=0, end_offset=None,
331               fields='', highlight=False, facets=None, date_facets=None,
332               query_facets=None, narrow_queries=None, spelling_query=None,
333               limit_to_registered_models=True, result_class=None, **kwargs):
334        """
335        Executes the Xapian::query as defined in `query`.
336       
337        Required arguments:
338            `query` -- Search query to execute
339       
340        Optional arguments:
341            `sort_by` -- Sort results by specified field (default = None)
342            `start_offset` -- Slice results from `start_offset` (default = 0)
343            `end_offset` -- Slice results at `end_offset` (default = None), if None, then all documents
344            `fields` -- Filter results on `fields` (default = '')
345            `highlight` -- Highlight terms in results (default = False)
346            `facets` -- Facet results on fields (default = None)
347            `date_facets` -- Facet results on date ranges (default = None)
348            `query_facets` -- Facet results on queries (default = None)
349            `narrow_queries` -- Narrow queries (default = None)
350            `spelling_query` -- An optional query to execute spelling suggestion on
351            `limit_to_registered_models` -- Limit returned results to models registered in the current `SearchSite` (default = True)
352       
353        Returns:
354            A dictionary with the following keys:
355                `results` -- A list of `SearchResult`
356                `hits` -- The total available results
357                `facets` - A dictionary of facets with the following keys:
358                    `fields` -- A list of field facets
359                    `dates` -- A list of date facets
360                    `queries` -- A list of query facets
361            If faceting was not used, the `facets` key will not be present
362       
363        If `query` is None, returns no results.
364       
365        If `HAYSTACK_INCLUDE_SPELLING` was enabled in `settings.py`, the
366        extra flag `FLAG_SPELLING_CORRECTION` will be passed to the query parser
367        and any suggestions for spell correction will be returned as well as
368        the results.
369        """
370        if not self.site:
371            from haystack import site
372        else:
373            site = self.site
374       
375        if xapian.Query.empty(query):
376            return {
377                'results': [],
378                'hits': 0,
379            }
380       
381        database = self._database()
382       
383        if result_class is None:
384            result_class = SearchResult
385       
386        if getattr(settings, 'HAYSTACK_INCLUDE_SPELLING', False) is True:
387            spelling_suggestion = self._do_spelling_suggestion(database, query, spelling_query)
388        else:
389            spelling_suggestion = ''
390       
391        if narrow_queries is not None:
392            query = xapian.Query(
393                xapian.Query.OP_AND, query, xapian.Query(
394                    xapian.Query.OP_OR, [self.parse_query(narrow_query) for narrow_query in narrow_queries]
395                )
396            )
397       
398        if limit_to_registered_models:
399            registered_models = self.build_registered_models_list()
400           
401            if len(registered_models) > 0:
402                query = xapian.Query(
403                    xapian.Query.OP_AND, query,
404                    xapian.Query(
405                        xapian.Query.OP_OR,  [
406                            xapian.Query('%s%s' % (DOCUMENT_CT_TERM_PREFIX, model)) for model in registered_models
407                        ]
408                    )
409                )
410       
411        enquire = xapian.Enquire(database)
412        if hasattr(settings, 'HAYSTACK_XAPIAN_WEIGHTING_SCHEME'):
413            enquire.set_weighting_scheme(xapian.BM25Weight(*settings.HAYSTACK_XAPIAN_WEIGHTING_SCHEME))
414        enquire.set_query(query)
415       
416        if sort_by:
417            sorter = xapian.MultiValueSorter()
418           
419            for sort_field in sort_by:
420                if sort_field.startswith('-'):
421                    reverse = True
422                    sort_field = sort_field[1:] # Strip the '-'
423                else:
424                    reverse = False # Reverse is inverted in Xapian -- http://trac.xapian.org/ticket/311
425                sorter.add(self._value_column(sort_field), reverse)
426           
427            enquire.set_sort_by_key_then_relevance(sorter, True)
428       
429        results = []
430        facets_dict = {
431            'fields': {},
432            'dates': {},
433            'queries': {},
434        }
435       
436        if not end_offset:
437            end_offset = database.get_doccount() - start_offset
438       
439        matches = self._get_enquire_mset(database, enquire, start_offset, end_offset)
440       
441        for match in matches:
442            app_label, module_name, pk, model_data = pickle.loads(self._get_document_data(database, match.document))
443            if highlight:
444                model_data['highlighted'] = {
445                    self.content_field_name: self._do_highlight(
446                        model_data.get(self.content_field_name), query
447                    )
448                }
449            results.append(
450                result_class(app_label, module_name, pk, match.percent, searchsite=site, **model_data)
451            )
452       
453        if facets:
454            facets_dict['fields'] = self._do_field_facets(results, facets)
455        if date_facets:
456            facets_dict['dates'] = self._do_date_facets(results, date_facets)
457        if query_facets:
458            facets_dict['queries'] = self._do_query_facets(results, query_facets)
459       
460        return {
461            'results': results,
462            'hits': self._get_hit_count(database, enquire),
463            'facets': facets_dict,
464            'spelling_suggestion': spelling_suggestion,
465        }
466   
467    def more_like_this(self, model_instance, additional_query=None,
468                       start_offset=0, end_offset=None,
469                       limit_to_registered_models=True, result_class=None, **kwargs):
470        """
471        Given a model instance, returns a result set of similar documents.
472       
473        Required arguments:
474            `model_instance` -- The model instance to use as a basis for
475                                retrieving similar documents.
476       
477        Optional arguments:
478            `additional_query` -- An additional query to narrow results
479            `start_offset` -- The starting offset (default=0)
480            `end_offset` -- The ending offset (default=None), if None, then all documents
481            `limit_to_registered_models` -- Limit returned results to models registered in the current `SearchSite` (default = True)
482       
483        Returns:
484            A dictionary with the following keys:
485                `results` -- A list of `SearchResult`
486                `hits` -- The total available results
487       
488        Opens a database connection, then builds a simple query using the
489        `model_instance` to build the unique identifier.
490       
491        For each document retrieved(should always be one), adds an entry into
492        an RSet (relevance set) with the document id, then, uses the RSet
493        to query for an ESet (A set of terms that can be used to suggest
494        expansions to the original query), omitting any document that was in
495        the original query.
496       
497        Finally, processes the resulting matches and returns.
498        """
499        if not self.site:
500            from haystack import site
501        else:
502            site = self.site
503       
504        database = self._database()
505       
506        if result_class is None:
507            result_class = SearchResult
508       
509        query = xapian.Query(DOCUMENT_ID_TERM_PREFIX + get_identifier(model_instance))
510       
511        enquire = xapian.Enquire(database)
512        enquire.set_query(query)
513       
514        rset = xapian.RSet()
515       
516        if not end_offset:
517            end_offset = database.get_doccount()
518       
519        for match in self._get_enquire_mset(database, enquire, 0, end_offset):
520            rset.add_document(match.docid)
521               
522        query = xapian.Query(
523            xapian.Query.OP_ELITE_SET,
524            [expand.term for expand in enquire.get_eset(match.document.termlist_count(), rset, XHExpandDecider())],
525            match.document.termlist_count()
526        )
527        query = xapian.Query(
528            xapian.Query.OP_AND_NOT, [query, DOCUMENT_ID_TERM_PREFIX + get_identifier(model_instance)]
529        )
530        if limit_to_registered_models:
531            registered_models = self.build_registered_models_list()
532           
533            if len(registered_models) > 0:
534                query = xapian.Query(
535                    xapian.Query.OP_AND, query,
536                    xapian.Query(
537                        xapian.Query.OP_OR,  [
538                            xapian.Query('%s%s' % (DOCUMENT_CT_TERM_PREFIX, model)) for model in registered_models
539                        ]
540                    )
541                )
542        if additional_query:
543            query = xapian.Query(
544                xapian.Query.OP_AND, query, additional_query
545            )
546       
547        enquire.set_query(query)
548       
549        results = []
550        matches = self._get_enquire_mset(database, enquire, start_offset, end_offset)
551       
552        for match in matches:
553            app_label, module_name, pk, model_data = pickle.loads(self._get_document_data(database, match.document))
554            results.append(
555                result_class(app_label, module_name, pk, match.percent, searchsite=site, **model_data)
556            )
557
558        return {
559            'results': results,
560            'hits': self._get_hit_count(database, enquire),
561            'facets': {
562                'fields': {},
563                'dates': {},
564                'queries': {},
565            },
566            'spelling_suggestion': None,
567        }
568   
569    def parse_query(self, query_string):
570        """
571        Given a `query_string`, will attempt to return a xapian.Query
572       
573        Required arguments:
574            ``query_string`` -- A query string to parse
575       
576        Returns a xapian.Query
577        """
578        if query_string == '*':
579            return xapian.Query('') # Match everything
580        elif query_string == '':
581            return xapian.Query()   # Match nothing
582       
583        flags = getattr(settings, 'HAYSTACK_XAPIAN_FLAGS', DEFAULT_XAPIAN_FLAGS)
584        qp = xapian.QueryParser()
585        qp.set_database(self._database())
586        qp.set_stemmer(xapian.Stem(self.language))
587        qp.set_stemming_strategy(xapian.QueryParser.STEM_SOME)
588        qp.add_boolean_prefix('django_ct', DOCUMENT_CT_TERM_PREFIX)
589
590        for field_dict in self.schema:
591            qp.add_prefix(
592                field_dict['field_name'],
593                DOCUMENT_CUSTOM_TERM_PREFIX + field_dict['field_name'].upper()
594            )
595       
596        #vrp = XHValueRangeProcessor(self)
597        #qp.add_valuerangeprocessor(vrp)
598       
599        return qp.parse_query(query_string, flags)
600   
601    def build_schema(self, fields):
602        """
603        Build the schema from fields.
604       
605        Required arguments:
606            ``fields`` -- A list of fields in the index
607       
608        Returns a list of fields in dictionary format ready for inclusion in
609        an indexed meta-data.
610        """
611        content_field_name = ''
612        schema_fields = [
613            {'field_name': ID, 'type': 'text', 'multi_valued': 'false', 'column': 0},
614        ]
615        column = len(schema_fields)
616       
617        for field_name, field_class in sorted(fields.items(), key=lambda n: n[0]):
618            if field_class.document is True:
619                content_field_name = field_class.index_fieldname
620           
621            if field_class.indexed is True:
622                field_data = {
623                    'field_name': field_class.index_fieldname,
624                    'type': 'text',
625                    'multi_valued': 'false',
626                    'column': column,
627                }
628               
629                if field_class.field_type in ['date', 'datetime']:
630                    field_data['type'] = 'date'
631                elif field_class.field_type == 'integer':
632                    field_data['type'] = 'long'
633                elif field_class.field_type == 'float':
634                    field_data['type'] = 'float'
635                elif field_class.field_type == 'boolean':
636                    field_data['type'] = 'boolean'
637               
638                if field_class.is_multivalued:
639                    field_data['multi_valued'] = 'true'
640               
641                schema_fields.append(field_data)
642                column += 1
643
644        return (content_field_name, schema_fields)
645   
646    def _do_highlight(self, content, query, tag='em'):
647        """
648        Highlight `query` terms in `content` with html `tag`.
649       
650        This method assumes that the input text (`content`) does not contain
651        any special formatting.  That is, it does not contain any html tags
652        or similar markup that could be screwed up by the highlighting.
653       
654        Required arguments:
655            `content` -- Content to search for instances of `text`
656            `text` -- The text to be highlighted
657        """
658        for term in query:
659            for match in re.findall('[^A-Z]+', term): # Ignore field identifiers
660                match_re = re.compile(match, re.I)
661                content = match_re.sub('<%s>%s</%s>' % (tag, match, tag), content)
662        # remove non highlighted line
663        content = "...".join(line for line in content.splitlines() if "<em>" in line)
664        return content
665   
666    def _do_field_facets(self, results, field_facets):
667        """
668        Private method that facets a document by field name.
669       
670        Fields of type MultiValueField will be faceted on each item in the
671        (containing) list.
672       
673        Required arguments:
674            `results` -- A list SearchResults to facet
675            `field_facets` -- A list of fields to facet on
676        """
677        facet_dict = {}
678       
679        # DS_TODO: Improve this algorithm.  Currently, runs in O(N^2), ouch.
680        for field in field_facets:
681            facet_list = {}
682           
683            for result in results:
684                field_value = getattr(result, field)
685                if self._multi_value_field(field):
686                    for item in field_value: # Facet each item in a MultiValueField
687                        facet_list[item] = facet_list.get(item, 0) + 1
688                else:
689                    facet_list[field_value] = facet_list.get(field_value, 0) + 1
690           
691            facet_dict[field] = facet_list.items()
692       
693        return facet_dict
694   
695    def _do_date_facets(self, results, date_facets):
696        """
697        Private method that facets a document by date ranges
698       
699        Required arguments:
700            `results` -- A list SearchResults to facet
701            `date_facets` -- A dictionary containing facet parameters:
702                {'field': {'start_date': ..., 'end_date': ...: 'gap_by': '...', 'gap_amount': n}}
703                nb., gap must be one of the following:
704                    year|month|day|hour|minute|second
705       
706        For each date facet field in `date_facets`, generates a list
707        of date ranges (from `start_date` to `end_date` by `gap_by`) then
708        iterates through `results` and tallies the count for each date_facet.
709       
710        Returns a dictionary of date facets (fields) containing a list with
711        entries for each range and a count of documents matching the range.
712       
713        eg. {
714            'pub_date': [
715                ('2009-01-01T00:00:00Z', 5),
716                ('2009-02-01T00:00:00Z', 0),
717                ('2009-03-01T00:00:00Z', 0),
718                ('2009-04-01T00:00:00Z', 1),
719                ('2009-05-01T00:00:00Z', 2),
720            ],
721        }
722        """
723        facet_dict = {}
724       
725        for date_facet, facet_params in date_facets.iteritems():
726            gap_type = facet_params.get('gap_by')
727            gap_value = facet_params.get('gap_amount', 1)
728            date_range = facet_params['start_date']
729            facet_list = []
730            while date_range < facet_params['end_date']:
731                facet_list.append((date_range.isoformat(), 0))
732                if gap_type == 'year':
733                    date_range = date_range.replace(
734                        year=date_range.year + int(gap_value)
735                    )
736                elif gap_type == 'month':
737                    if date_range.month + int(gap_value) > 12:
738                        date_range = date_range.replace(
739                            month=((date_range.month + int(gap_value)) % 12),
740                            year=(date_range.year + (date_range.month + int(gap_value)) / 12)
741                        )
742                    else:
743                        date_range = date_range.replace(
744                            month=date_range.month + int(gap_value)
745                        )
746                elif gap_type == 'day':
747                    date_range += datetime.timedelta(days=int(gap_value))
748                elif gap_type == 'hour':
749                    date_range += datetime.timedelta(hours=int(gap_value))
750                elif gap_type == 'minute':
751                    date_range += datetime.timedelta(minutes=int(gap_value))
752                elif gap_type == 'second':
753                    date_range += datetime.timedelta(seconds=int(gap_value))
754           
755            facet_list = sorted(facet_list, key=lambda n:n[0], reverse=True)
756           
757            for result in results:
758                result_date = getattr(result, date_facet)
759                if result_date:
760                    if not isinstance(result_date, datetime.datetime):
761                        result_date = datetime.datetime(
762                            year=result_date.year,
763                            month=result_date.month,
764                            day=result_date.day,
765                        )
766                    for n, facet_date in enumerate(facet_list):
767                        if result_date > datetime.datetime(*(time.strptime(facet_date[0], '%Y-%m-%dT%H:%M:%S')[0:6])):
768                            facet_list[n] = (facet_list[n][0], (facet_list[n][1] + 1))
769                            break
770           
771            facet_dict[date_facet] = facet_list
772       
773        return facet_dict
774   
775    def _do_query_facets(self, results, query_facets):
776        """
777        Private method that facets a document by query
778       
779        Required arguments:
780            `results` -- A list SearchResults to facet
781            `query_facets` -- A dictionary containing facet parameters:
782                {'field': 'query', [...]}
783       
784        For each query in `query_facets`, generates a dictionary entry with
785        the field name as the key and a tuple with the query and result count
786        as the value.
787       
788        eg. {'name': ('a*', 5)}
789        """
790        facet_dict = {}
791       
792        for field, query in query_facets.iteritems():
793            facet_dict[field] = (query, self.search(self.parse_query(query))['hits'])
794
795        return facet_dict
796   
797    def _do_spelling_suggestion(self, database, query, spelling_query):
798        """
799        Private method that returns a single spelling suggestion based on
800        `spelling_query` or `query`.
801       
802        Required arguments:
803            `database` -- The database to check spelling against
804            `query` -- The query to check
805            `spelling_query` -- If not None, this will be checked instead of `query`
806       
807        Returns a string with a suggested spelling
808        """
809        if spelling_query:
810            if ' ' in spelling_query:
811                return ' '.join([database.get_spelling_suggestion(term) for term in spelling_query.split()])
812            else:
813                return database.get_spelling_suggestion(spelling_query)
814       
815        term_set = set()
816        for term in query:
817            for match in re.findall('[^A-Z]+', term): # Ignore field identifiers
818                term_set.add(database.get_spelling_suggestion(match))
819       
820        return ' '.join(term_set)
821   
822    def _database(self, writable=False):
823        """
824        Private method that returns a xapian.Database for use.
825
826        Optional arguments:
827            ``writable`` -- Open the database in read/write mode (default=False)
828
829        Returns an instance of a xapian.Database or xapian.WritableDatabase
830        """
831        if settings.HAYSTACK_XAPIAN_PATH == MEMORY_DB_NAME:
832            if not SearchBackend.inmemory_db:
833                SearchBackend.inmemory_db = xapian.inmemory_open()
834            return SearchBackend.inmemory_db
835        if writable:
836            database = xapian.WritableDatabase(settings.HAYSTACK_XAPIAN_PATH, xapian.DB_CREATE_OR_OPEN)
837        else:
838            try:
839                database = xapian.Database(settings.HAYSTACK_XAPIAN_PATH)
840            except xapian.DatabaseOpeningError:
841                raise InvalidIndexError(u'Unable to open index at %s' % settings.HAYSTACK_XAPIAN_PATH)
842
843        return database
844
845    def _get_enquire_mset(self, database, enquire, start_offset, end_offset):
846        """
847        A safer version of Xapian.enquire.get_mset
848
849        Simply wraps the Xapian version and catches any `Xapian.DatabaseModifiedError`,
850        attempting a `database.reopen` as needed.
851
852        Required arguments:
853            `database` -- The database to be read
854            `enquire` -- An instance of an Xapian.enquire object
855            `start_offset` -- The start offset to pass to `enquire.get_mset`
856            `end_offset` -- The end offset to pass to `enquire.get_mset`
857        """
858        try:
859            return enquire.get_mset(start_offset, end_offset)
860        except xapian.DatabaseModifiedError:
861            database.reopen()
862            return enquire.get_mset(start_offset, end_offset)
863
864    def _get_document_data(self, database, document):
865        """
866        A safer version of Xapian.document.get_data
867
868        Simply wraps the Xapian version and catches any `Xapian.DatabaseModifiedError`,
869        attempting a `database.reopen` as needed.
870
871        Required arguments:
872            `database` -- The database to be read
873            `document` -- An instance of an Xapian.document object
874        """
875        try:
876            return document.get_data()
877        except xapian.DatabaseModifiedError:
878            database.reopen()
879            return document.get_data()
880
881    def _get_hit_count(self, database, enquire):
882        """
883        Given a database and enquire instance, returns the estimated number
884        of matches.
885       
886        Required arguments:
887            `database` -- The database to be queried
888            `enquire` -- The enquire instance
889        """
890        return self._get_enquire_mset(
891            database, enquire, 0, database.get_doccount()
892        ).size()
893
894    def _value_column(self, field):
895        """
896        Private method that returns the column value slot in the database
897        for a given field.
898       
899        Required arguemnts:
900            `field` -- The field to lookup
901       
902        Returns an integer with the column location (0 indexed).
903        """
904        for field_dict in self.schema:
905            if field_dict['field_name'] == field:
906                return field_dict['column']
907        return 0
908   
909    def _multi_value_field(self, field):
910        """
911        Private method that returns `True` if a field is multi-valued, else
912        `False`.
913       
914        Required arguemnts:
915            `field` -- The field to lookup
916       
917        Returns a boolean value indicating whether the field is multi-valued.
918        """
919        for field_dict in self.schema:
920            if field_dict['field_name'] == field:
921                return field_dict['multi_valued'] == 'true'
922        return False
923
924
925class SearchQuery(BaseSearchQuery):
926    """
927    This class is the Xapian specific version of the SearchQuery class.
928    It acts as an intermediary between the ``SearchQuerySet`` and the
929    ``SearchBackend`` itself.
930    """
931    def __init__(self, backend=None, site=None):
932        """
933        Create a new instance of the SearchQuery setting the backend as
934        specified.  If no backend is set, will use the Xapian `SearchBackend`.
935       
936        Optional arguments:
937            ``backend`` -- The ``SearchBackend`` to use (default = None)
938            ``site`` -- The site to use (default = None)
939        """
940        super(SearchQuery, self).__init__(backend=backend)
941        self.backend = backend or SearchBackend(site=site)
942   
943    def build_params(self, *args, **kwargs):
944        kwargs = super(SearchQuery, self).build_params(*args, **kwargs)
945
946        if self.end_offset is not None:
947            kwargs['end_offset'] = self.end_offset - self.start_offset
948       
949        return kwargs
950
951    def build_query(self):
952        if not self.query_filter:
953            query = xapian.Query('')
954        else:
955            query = self._query_from_search_node(self.query_filter)
956       
957        if self.models:
958            subqueries = [
959                xapian.Query(
960                    xapian.Query.OP_SCALE_WEIGHT, xapian.Query('%s%s.%s' % (
961                            DOCUMENT_CT_TERM_PREFIX,
962                            model._meta.app_label, model._meta.module_name
963                        )
964                    ), 0 # Pure boolean sub-query
965                ) for model in self.models
966            ]
967            query = xapian.Query(
968                xapian.Query.OP_AND, query,
969                xapian.Query(xapian.Query.OP_OR, subqueries)
970            )
971       
972        if self.boost:
973            subqueries = [
974                xapian.Query(
975                    xapian.Query.OP_SCALE_WEIGHT, self._content_field(term, False), value
976                ) for term, value in self.boost.iteritems()
977            ]
978            query = xapian.Query(
979                xapian.Query.OP_AND_MAYBE, query,
980                xapian.Query(xapian.Query.OP_OR, subqueries)
981            )
982       
983        return query
984   
985    def _query_from_search_node(self, search_node, is_not=False):
986        query_list = []
987       
988        for child in search_node.children:
989            if isinstance(child, SearchNode):
990                query_list.append(
991                    self._query_from_search_node(child, child.negated)
992                )
993            else:
994                expression, term = child
995                field, filter_type = search_node.split_expression(expression)
996               
997                # Handle when we've got a ``ValuesListQuerySet``...
998                if hasattr(term, 'values_list'):
999                    term = list(term)
1000               
1001                if isinstance(term, (list, tuple)):
1002                    term = [_marshal_term(t) for t in term]
1003                else:
1004                    term = _marshal_term(term)
1005               
1006                if field == 'content':
1007                    query_list.append(self._content_field(term, is_not))
1008                else:
1009                    if filter_type == 'exact':
1010                        query_list.append(self._filter_exact(term, field, is_not))
1011                    elif filter_type == 'gt':
1012                        query_list.append(self._filter_gt(term, field, is_not))
1013                    elif filter_type == 'gte':
1014                        query_list.append(self._filter_gte(term, field, is_not))
1015                    elif filter_type == 'lt':
1016                        query_list.append(self._filter_lt(term, field, is_not))
1017                    elif filter_type == 'lte':
1018                        query_list.append(self._filter_lte(term, field, is_not))
1019                    elif filter_type == 'startswith':
1020                        query_list.append(self._filter_startswith(term, field, is_not))
1021                    elif filter_type == 'in':
1022                        query_list.append(self._filter_in(term, field, is_not))
1023       
1024        if search_node.connector == 'OR':
1025            return xapian.Query(xapian.Query.OP_OR, query_list)
1026        else:
1027            return xapian.Query(xapian.Query.OP_AND, query_list)
1028   
1029    def _content_field(self, term, is_not):
1030        """
1031        Private method that returns a xapian.Query that searches for `value`
1032        in all fields.
1033       
1034        Required arguments:
1035            ``term`` -- The term to search for
1036            ``is_not`` -- Invert the search results
1037       
1038        Returns:
1039            A xapian.Query
1040        """
1041        if ' ' in term:
1042            if is_not:
1043                return xapian.Query(
1044                    xapian.Query.OP_AND_NOT, self._all_query(), self._phrase_query(
1045                        term.split(), self.backend.content_field_name
1046                    )
1047                )
1048            else:
1049                return self._phrase_query(term.split(), self.backend.content_field_name)
1050        else:
1051            if is_not:
1052                return xapian.Query(xapian.Query.OP_AND_NOT, self._all_query(), self.backend.parse_query(term))
1053            else:
1054                return self.backend.parse_query(term)
1055   
1056    def _filter_exact(self, term, field, is_not):
1057        """
1058        Private method that returns a xapian.Query that searches for `term`
1059        in a specified `field`.
1060       
1061        Required arguments:
1062            ``term`` -- The term to search for
1063            ``field`` -- The field to search
1064            ``is_not`` -- Invert the search results
1065       
1066        Returns:
1067            A xapian.Query
1068        """
1069        if ' ' in term:
1070            if is_not:
1071                return xapian.Query(
1072                    xapian.Query.OP_AND_NOT, self._all_query(), self._phrase_query(term.split(), field)
1073                )
1074            else:
1075                return self._phrase_query(term.split(), field)
1076        else:
1077            if is_not:
1078                return xapian.Query(xapian.Query.OP_AND_NOT, self._all_query(), self._term_query(term, field))
1079            else:
1080                return self._term_query(term, field)
1081   
1082    def _filter_in(self, term_list, field, is_not):
1083        """
1084        Private method that returns a xapian.Query that searches for any term
1085        of `value_list` in a specified `field`.
1086       
1087        Required arguments:
1088            ``term_list`` -- The terms to search for
1089            ``field`` -- The field to search
1090            ``is_not`` -- Invert the search results
1091       
1092        Returns:
1093            A xapian.Query
1094        """
1095        query_list = []
1096        for term in term_list:
1097            if ' ' in term:
1098                query_list.append(
1099                    self._phrase_query(term.split(), field)
1100                )
1101            else:
1102                query_list.append(
1103                    self._term_query(term, field)
1104                )
1105        if is_not:
1106            return xapian.Query(xapian.Query.OP_AND_NOT, self._all_query(), xapian.Query(xapian.Query.OP_OR, query_list))
1107        else:
1108            return xapian.Query(xapian.Query.OP_OR, query_list)
1109   
1110    def _filter_startswith(self, term, field, is_not):
1111        """
1112        Private method that returns a xapian.Query that searches for any term
1113        that begins with `term` in a specified `field`.
1114       
1115        Required arguments:
1116            ``term`` -- The terms to search for
1117            ``field`` -- The field to search
1118            ``is_not`` -- Invert the search results
1119       
1120        Returns:
1121            A xapian.Query
1122        """
1123        if is_not:
1124            return xapian.Query(
1125                xapian.Query.OP_AND_NOT,
1126                self._all_query(),
1127                self.backend.parse_query('%s:%s*' % (field, term)),
1128            )
1129        return self.backend.parse_query('%s:%s*' % (field, term))
1130   
1131    def _filter_gt(self, term, field, is_not):
1132        return self._filter_lte(term, field, is_not=(is_not != True))
1133   
1134    def _filter_lt(self, term, field, is_not):
1135        return self._filter_gte(term, field, is_not=(is_not != True))
1136
1137    def _filter_gte(self, term, field, is_not):
1138        """
1139        Private method that returns a xapian.Query that searches for any term
1140        that is greater than `term` in a specified `field`.
1141        """
1142        vrp = XHValueRangeProcessor(self.backend)
1143        pos, begin, end = vrp('%s:%s' % (field, _marshal_value(term)), '*')
1144        if is_not:
1145            return xapian.Query(xapian.Query.OP_AND_NOT,
1146                self._all_query(),
1147                xapian.Query(xapian.Query.OP_VALUE_RANGE, pos, begin, end)
1148            )
1149        return xapian.Query(xapian.Query.OP_VALUE_RANGE, pos, begin, end)
1150   
1151    def _filter_lte(self, term, field, is_not):
1152        """
1153        Private method that returns a xapian.Query that searches for any term
1154        that is less than `term` in a specified `field`.
1155        """
1156        vrp = XHValueRangeProcessor(self.backend)
1157        pos, begin, end = vrp('%s:' % field, '%s' % _marshal_value(term))
1158        if is_not:
1159            return xapian.Query(xapian.Query.OP_AND_NOT,
1160                self._all_query(),
1161                xapian.Query(xapian.Query.OP_VALUE_RANGE, pos, begin, end)
1162            )
1163        return xapian.Query(xapian.Query.OP_VALUE_RANGE, pos, begin, end)
1164
1165    def _all_query(self):
1166        """
1167        Private method that returns a xapian.Query that returns all documents,
1168       
1169        Returns:
1170            A xapian.Query
1171        """
1172        return xapian.Query('')
1173   
1174    def _term_query(self, term, field=None):
1175        """
1176        Private method that returns a term based xapian.Query that searches
1177        for `term`.
1178       
1179        Required arguments:
1180            ``term`` -- The term to search for
1181            ``field`` -- The field to search (If `None`, all fields)
1182       
1183        Returns:
1184            A xapian.Query
1185        """
1186        stem = xapian.Stem(self.backend.language)
1187       
1188        if field == 'id':
1189            return xapian.Query('%s%s' % (DOCUMENT_ID_TERM_PREFIX, term))
1190        elif field == 'django_ct':
1191            return xapian.Query('%s%s' % (DOCUMENT_CT_TERM_PREFIX, term))
1192        elif field:
1193            stemmed = 'Z%s%s%s' % (
1194                DOCUMENT_CUSTOM_TERM_PREFIX, field.upper(), stem(term)
1195            )
1196            unstemmed = '%s%s%s' % (
1197                DOCUMENT_CUSTOM_TERM_PREFIX, field.upper(), term
1198            )
1199        else:
1200            stemmed = 'Z%s' % stem(term)
1201            unstemmed = term
1202           
1203        return xapian.Query(
1204            xapian.Query.OP_OR,
1205            xapian.Query(stemmed),
1206            xapian.Query(unstemmed)
1207        )
1208   
1209    def _phrase_query(self, term_list, field=None):
1210        """
1211        Private method that returns a phrase based xapian.Query that searches
1212        for terms in `term_list.
1213       
1214        Required arguments:
1215            ``term_list`` -- The terms to search for
1216            ``field`` -- The field to search (If `None`, all fields)
1217       
1218        Returns:
1219            A xapian.Query
1220        """
1221        if field and field != self.backend.content_field_name: 
1222            return xapian.Query(
1223                xapian.Query.OP_PHRASE, [
1224                    '%s%s%s' % (
1225                        DOCUMENT_CUSTOM_TERM_PREFIX, field.upper(), term
1226                    ) for term in term_list
1227                ]
1228            )
1229        else:
1230            return xapian.Query(xapian.Query.OP_PHRASE, term_list)
1231
1232def _marshal_value(value):
1233    """
1234    Private utility method that converts Python values to a string for Xapian values.
1235    """
1236    if isinstance(value, datetime.datetime):
1237        value = _marshal_datetime(value)
1238    elif isinstance(value, datetime.date):
1239        value = _marshal_date(value)
1240    elif isinstance(value, bool):
1241        if value:
1242            value = u't'
1243        else:
1244            value = u'f'
1245    elif isinstance(value, float):
1246        value = xapian.sortable_serialise(value)
1247    elif isinstance(value, (int, long)):
1248        value = u'%012d' % value
1249    else:
1250        value = force_unicode(value).lower()
1251    return value
1252
1253
1254def _marshal_term(term):
1255    """
1256    Private utility method that converts Python terms to a string for Xapian terms.
1257    """
1258    if isinstance(term, datetime.datetime):
1259        term = _marshal_datetime(term)
1260    elif isinstance(term, datetime.date):
1261        term = _marshal_date(term)
1262    else:
1263        term = force_unicode(term).lower()
1264    return term
1265
1266
1267def _marshal_date(d):
1268    return u'%04d%02d%02d000000' % (d.year, d.month, d.day)
1269
1270
1271def _marshal_datetime(dt):
1272    if dt.microsecond:
1273        return u'%04d%02d%02d%02d%02d%02d%06d' % (
1274            dt.year, dt.month, dt.day, dt.hour,
1275            dt.minute, dt.second, dt.microsecond
1276        )
1277    else:
1278        return u'%04d%02d%02d%02d%02d%02d' % (
1279            dt.year, dt.month, dt.day, dt.hour,
1280            dt.minute, dt.second
1281        )
Note: See TracBrowser for help on using the repository browser.