summaryrefslogtreecommitdiff
path: root/logical_search.py
diff options
context:
space:
mode:
Diffstat (limited to 'logical_search.py')
-rw-r--r--logical_search.py367
1 files changed, 367 insertions, 0 deletions
diff --git a/logical_search.py b/logical_search.py
new file mode 100644
index 0000000..86c6352
--- /dev/null
+++ b/logical_search.py
@@ -0,0 +1,367 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+from collections import defaultdict
+import enum
+import sys
+from typing import (
+ Any,
+ Dict,
+ List,
+ NamedTuple,
+ Optional,
+ Set,
+ Sequence,
+ Tuple,
+ Union,
+)
+
+
+class ParseError(Exception):
+ """An error encountered while parsing a logical search expression."""
+
+ def __init__(self, message: str):
+ self.message = message
+
+
+class Document(NamedTuple):
+ """A tuple representing a searchable document."""
+
+ docid: str # a unique idenfier for the document
+ tags: Set[str] # an optional set of tags
+ properties: List[
+ Tuple[str, str]
+ ] # an optional set of key->value properties
+ reference: Any # an optional reference to something else
+
+
+class Operation(enum.Enum):
+ """A logical search query operation."""
+
+ QUERY = 1
+ CONJUNCTION = 2
+ DISJUNCTION = 3
+ INVERSION = 4
+
+ @staticmethod
+ def from_token(token: str):
+ table = {
+ "not": Operation.INVERSION,
+ "and": Operation.CONJUNCTION,
+ "or": Operation.DISJUNCTION,
+ }
+ return table.get(token, None)
+
+ def num_operands(self) -> Optional[int]:
+ table = {
+ Operation.INVERSION: 1,
+ Operation.CONJUNCTION: 2,
+ Operation.DISJUNCTION: 2,
+ }
+ return table.get(self, None)
+
+
+class Corpus(object):
+ """A collection of searchable documents."""
+
+ def __init__(self) -> None:
+ self.docids_by_tag: Dict[str, Set[str]] = defaultdict(set)
+ self.docids_by_property: Dict[Tuple[str, str], Set[str]] = defaultdict(
+ set
+ )
+ self.docids_with_property: Dict[str, Set[str]] = defaultdict(set)
+ self.documents_by_docid: Dict[str, Document] = {}
+
+ def add_doc(self, doc: Document) -> None:
+ """Add a new Document to the Corpus. Each Document must have a
+ distinct docid that will serve as its primary identifier. If
+ the same Document is added multiple times, only the most
+ recent addition is indexed. If two distinct documents with
+ the same docid are added, the latter klobbers the former in the
+ indexes.
+
+ Each Document may have an optional set of tags which can be
+ used later in expressions to the query method.
+
+ Each Document may have an optional list of key->value tuples
+ which can be used later in expressions to the query method.
+
+ Document includes a user-defined "reference" field which is
+ never interpreted by this module. This is meant to allow easy
+ mapping between Documents in this corpus and external objects
+ they may represent.
+ """
+
+ if doc.docid in self.documents_by_docid:
+ # Handle collisions; assume that we are re-indexing the
+ # same document so remove it from the indexes before
+ # adding it back again.
+ colliding_doc = self.documents_by_docid[doc.docid]
+ assert colliding_doc.docid == doc.docid
+ del self.documents_by_docid[doc.docid]
+ for tag in colliding_doc.tags:
+ self.docids_by_tag[tag].remove(doc.docid)
+ for key, value in colliding_doc.properties:
+ self.docids_by_property[(key, value)].remove(doc.docid)
+ self.docids_with_property[key].remove(doc.docid)
+
+ # Index the new Document
+ assert doc.docid not in self.documents_by_docid
+ self.documents_by_docid[doc.docid] = doc
+ for tag in doc.tags:
+ self.docids_by_tag[tag].add(doc.docid)
+ for key, value in doc.properties:
+ self.docids_by_property[(key, value)].add(doc.docid)
+ self.docids_with_property[key].add(doc.docid)
+
+ def get_docids_by_exact_tag(self, tag: str) -> Set[str]:
+ """Return the set of docids that have a particular tag."""
+
+ return self.docids_by_tag[tag]
+
+ def get_docids_by_searching_tags(self, tag: str) -> Set[str]:
+ """Return the set of docids with a tag that contains a str"""
+
+ ret = set()
+ for search_tag in self.docids_by_tag:
+ if tag in search_tag:
+ for docid in self.docids_by_tag[search_tag]:
+ ret.add(docid)
+ return ret
+
+ def get_docids_with_property(self, key: str) -> Set[str]:
+ """Return the set of docids that have a particular property no matter
+ what that property's value.
+ """
+
+ return self.docids_with_property[key]
+
+ def get_docids_by_property(self, key: str, value: str) -> Set[str]:
+ """Return the set of docids that have a particular property with a
+ particular value..
+ """
+
+ return self.docids_by_property[(key, value)]
+
+ def invert_docid_set(self, original: Set[str]) -> Set[str]:
+ """Invert a set of docids."""
+
+ return set(
+ [
+ docid
+ for docid in self.documents_by_docid.keys()
+ if docid not in original
+ ]
+ )
+
+ def get_doc(self, docid: str) -> Optional[Document]:
+ """Given a docid, retrieve the previously added Document."""
+
+ return self.documents_by_docid.get(docid, None)
+
+ def query(self, query: str) -> Optional[Set[str]]:
+ """Query the corpus for documents that match a logical expression.
+ Returns a (potentially empty) set of docids for the matching
+ (previously added) documents or None on error.
+
+ e.g.
+
+ tag1 and tag2 and not tag3
+
+ (tag1 or tag2) and (tag3 or tag4)
+
+ (tag1 and key2:value2) or (tag2 and key1:value1)
+
+ key:*
+
+ tag1 and key:*
+ """
+
+ try:
+ root = self._parse_query(query)
+ except ParseError as e:
+ print(e.message, file=sys.stderr)
+ return None
+ return root.eval()
+
+ def _parse_query(self, query: str):
+ """Internal parse helper; prefer to use query instead."""
+
+ parens = set(["(", ")"])
+ and_or = set(["and", "or"])
+
+ def operator_precedence(token: str) -> Optional[int]:
+ table = {
+ "(": 4, # higher
+ ")": 4,
+ "not": 3,
+ "and": 2,
+ "or": 1, # lower
+ }
+ return table.get(token, None)
+
+ def is_operator(token: str) -> bool:
+ return operator_precedence(token) is not None
+
+ def lex(query: str):
+ query = query.lower()
+ tokens = query.split()
+ for token in tokens:
+ # Handle ( and ) operators stuck to the ends of tokens
+ # that split() doesn't understand.
+ if len(token) > 1:
+ first = token[0]
+ if first in parens:
+ tail = token[1:]
+ yield first
+ token = tail
+ last = token[-1]
+ if last in parens:
+ head = token[0:-1]
+ yield head
+ token = last
+ yield token
+
+ def evaluate(corpus: Corpus, stack: List[str]):
+ node_stack: List[Node] = []
+ for token in stack:
+ node = None
+ if not is_operator(token):
+ node = Node(corpus, Operation.QUERY, [token])
+ else:
+ args = []
+ operation = Operation.from_token(token)
+ operand_count = operation.num_operands()
+ if len(node_stack) < operand_count:
+ raise ParseError(
+ f"Incorrect number of operations for {operation}"
+ )
+ for _ in range(operation.num_operands()):
+ args.append(node_stack.pop())
+ node = Node(corpus, operation, args)
+ node_stack.append(node)
+ return node_stack[0]
+
+ output_stack = []
+ operator_stack = []
+ for token in lex(query):
+ if not is_operator(token):
+ output_stack.append(token)
+ continue
+
+ # token is an operator...
+ if token == "(":
+ operator_stack.append(token)
+ elif token == ")":
+ ok = False
+ while len(operator_stack) > 0:
+ pop_operator = operator_stack.pop()
+ if pop_operator != "(":
+ output_stack.append(pop_operator)
+ else:
+ ok = True
+ break
+ if not ok:
+ raise ParseError(
+ "Unbalanced parenthesis in query expression"
+ )
+
+ # and, or, not
+ else:
+ my_precedence = operator_precedence(token)
+ if my_precedence is None:
+ raise ParseError(f"Unknown operator: {token}")
+ while len(operator_stack) > 0:
+ peek_operator = operator_stack[-1]
+ if not is_operator(peek_operator) or peek_operator == "(":
+ break
+ peek_precedence = operator_precedence(peek_operator)
+ if peek_precedence is None:
+ raise ParseError("Internal error")
+ if (
+ (peek_precedence < my_precedence)
+ or (peek_precedence == my_precedence)
+ and (peek_operator not in and_or)
+ ):
+ break
+ output_stack.append(operator_stack.pop())
+ operator_stack.append(token)
+ while len(operator_stack) > 0:
+ token = operator_stack.pop()
+ if token in parens:
+ raise ParseError("Unbalanced parenthesis in query expression")
+ output_stack.append(token)
+ return evaluate(self, output_stack)
+
+
+class Node(object):
+ """A query AST node."""
+
+ def __init__(
+ self,
+ corpus: Corpus,
+ op: Operation,
+ operands: Sequence[Union[Node, str]],
+ ):
+ self.corpus = corpus
+ self.op = op
+ self.operands = operands
+
+ def eval(self) -> Set[str]:
+ """Evaluate this node."""
+
+ evaled_operands: List[Union[Set[str], str]] = []
+ for operand in self.operands:
+ if isinstance(operand, Node):
+ evaled_operands.append(operand.eval())
+ elif isinstance(operand, str):
+ evaled_operands.append(operand)
+ else:
+ raise ParseError(f"Unexpected operand: {operand}")
+
+ retval = set()
+ if self.op is Operation.QUERY:
+ for tag in evaled_operands:
+ if isinstance(tag, str):
+ if ":" in tag:
+ try:
+ key, value = tag.split(":")
+ except ValueError as v:
+ raise ParseError(
+ f'Invalid key:value syntax at "{tag}"'
+ ) from v
+ if value == "*":
+ r = self.corpus.get_docids_with_property(key)
+ else:
+ r = self.corpus.get_docids_by_property(key, value)
+ else:
+ r = self.corpus.get_docids_by_exact_tag(tag)
+ retval.update(r)
+ else:
+ raise ParseError(f"Unexpected query {tag}")
+ elif self.op is Operation.DISJUNCTION:
+ if len(evaled_operands) != 2:
+ raise ParseError(
+ "Operation.DISJUNCTION (or) expects two operands."
+ )
+ retval.update(evaled_operands[0])
+ retval.update(evaled_operands[1])
+ elif self.op is Operation.CONJUNCTION:
+ if len(evaled_operands) != 2:
+ raise ParseError(
+ "Operation.CONJUNCTION (and) expects two operands."
+ )
+ retval.update(evaled_operands[0])
+ retval = retval.intersection(evaled_operands[1])
+ elif self.op is Operation.INVERSION:
+ if len(evaled_operands) != 1:
+ raise ParseError(
+ "Operation.INVERSION (not) expects one operand."
+ )
+ _ = evaled_operands[0]
+ if isinstance(_, set):
+ retval.update(self.corpus.invert_docid_set(_))
+ else:
+ raise ParseError(f"Unexpected negation operand {_} ({type(_)})")
+ return retval