Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/pyfwapi/search/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Search Expressions.

Consider using SE (Seach Expression) for an easier, fluent-style API.

Source: <https://learn.fotoware.com/searching-for-assets/fotoweb-fotoware-search-expressions-reference>
"""

import json
Expand All @@ -21,7 +23,9 @@ class SEASTNode:
its arguments are instances of the AST that it structures.
"""

type: t.Literal["AND", "OR", "NOT", "FIELD_EQ", "FIELD", "VAL_RANGE", "VALUE"]
type: t.Literal[
"AND", "OR", "NOT", "FIELD_EQ", "FIELD_EMPTY", "FIELD", "VAL_RANGE", "VALUE"
]
args: tuple[t.Self | str, t.Self | str | None]

def __str__(self) -> str:
Expand All @@ -33,8 +37,17 @@ def __str__(self) -> str:
return f"{str(arg1)}~~{str(arg2)}"
case "FIELD_EQ":
return f"{str(arg1)}:{str(arg2)}"
case "FIELD_EMPTY":
# An empty field value must still match, so it's a quoted empty phrase.
return f'{str(arg1)}:""'
case "NOT":
return f"NOT ( {str(arg1)} )"
# FotoWeb negation syntax is a leading hyphen, e.g. `-801:""`.
# Parentheses are only needed for compound (AND/OR) subexpressions;
# a simple `field:value` term binds tighter than the `-` operator.
term = str(arg1)
if isinstance(arg1, SEASTNode) and arg1.type in {"AND", "OR"}:
term = f"( {term} )"
return f"-{term}"
case "OR" | "AND":
return f"( {str(arg1)} ) {self.type} ( {str(arg2)} )"

Expand Down Expand Up @@ -108,8 +121,8 @@ def VAL_RANGE(start_value: SEASTNode, end_value: SEASTNode):


def FIELD_EMPTY(field: SEASTNode):
"""Create an empty field expression"""
return SEASTNode(type="FIELD_EQ", args=(field, VALUE("")))
"""Create an empty field expression (field has no value)."""
return SEASTNode(type="FIELD_EMPTY", args=(field, None))


def FIELD_EQ(field: SEASTNode, value: SEASTNode):
Expand Down
25 changes: 22 additions & 3 deletions tests/test_search_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ def test_ast_field_eq():
def test_ast_field_empty():
field = FIELD("description")
node = FIELD_EMPTY(field)
assert node.type == "FIELD_EQ"
assert str(node) == "description:"
assert node.type == "FIELD_EMPTY"
# An empty field must be a quoted empty phrase to actually match in FotoWeb.
assert str(node) == 'description:""'


def test_ast_not():
Expand All @@ -100,7 +101,25 @@ def test_ast_not():

node = NOT(eq_node)
assert node.type == "NOT"
assert str(node) == "NOT ( title:hello )"
assert str(node) == "-title:hello"


def test_ast_not_empty_field():
# Regression: 'all assets with any 801 value' must render as -801:"", which
# URL-encodes to ?q=-801%3A%22%22. A leading '"' (i.e. double-quoting of the
# whole expression) would be wrong.
node = NOT(FIELD_EMPTY(FIELD(801)))
rendered = str(node)
assert rendered == '-801:""'
assert not rendered.startswith('"')


def test_ast_not_compound_needs_parentheses():
from pyfwapi.search.ast import AND

compound = AND(FIELD_EQ(FIELD("a"), VALUE("1")), FIELD_EQ(FIELD("b"), VALUE("2")))
node = NOT(compound)
assert str(node) == "-( ( a:1 ) AND ( b:2 ) )"


def test_ast_or():
Expand Down
6 changes: 3 additions & 3 deletions tests/test_search_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def test_se_fts():

def test_se_empty():
se = SE().empty("title")
assert str(se.data) == "title:"
assert str(se.data) == 'title:""'


def test_se_eq():
Expand Down Expand Up @@ -71,7 +71,7 @@ def test_se_dunder_methods():
assert str(se_or.data) == "( tag:cat ) OR ( tag:dog )"

se_not = -se1
assert str(se_not.data) == "NOT ( tag:cat )"
assert str(se_not.data) == "-tag:cat"


def test_se_dunder_str():
Expand All @@ -86,7 +86,7 @@ def test_se_complex_chaining():
se = se | SE().eq("override", 1)
se = se & ~SE().empty("required_field")

expected = "( ( ( status:active ) AND ( date:2020-01-01~~2020-12-31 ) ) OR ( override:1 ) ) AND ( NOT ( required_field: ) )"
expected = '( ( ( status:active ) AND ( date:2020-01-01~~2020-12-31 ) ) OR ( override:1 ) ) AND ( -required_field:"" )'
assert str(se) == expected


Expand Down
Loading