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
31 changes: 30 additions & 1 deletion src/did/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,34 @@ def struct_partial_match(a, b):
return True


def or_operands(search_struct):
"""The operands of an OR search structure, with nested ORs flattened.

``q1 | q2 | q3 ...`` built in a loop nests one OR per term:
``or(or(or(q1, q2), q3), ...)``. Evaluating that by recursion costs a
stack frame or more per term, and a few hundred terms overrun Python's
recursion limit (issue #100). This walks the chain with a stack instead
and returns the operands in their left-to-right order, so the caller can
evaluate them in a loop.

An operand that is itself an OR (a dict, or a one-element list holding
one) is flattened into the chain; a negated OR ('~or') is not -- it is
one operand, as its negation applies to its whole result. Operands are
returned as given, empty ones included: what an empty operand means is
the caller's, as before.
"""
operands = []
pending = [search_struct.get("param2"), search_struct.get("param1")]
while pending:
item = pending.pop()
inner = item[0] if isinstance(item, list) and len(item) == 1 else item
if isinstance(inner, dict) and str(inner.get("operation", "")).lower() == "or":
pending.extend([inner.get("param2"), inner.get("param1")])
else:
operands.append(item)
return operands


def field_search(a, search_struct):
"""
Searches a dictionary to determine if it matches a search structure.
Expand Down Expand Up @@ -268,7 +296,8 @@ def field_search(a, search_struct):
b = True
break
elif op_lower == "or":
b = field_search(a, param1) or field_search(a, param2)
# A long OR chain is a deep tree; flatten it rather than recurse
b = any(field_search(a, operand) for operand in or_operands(search_struct))
elif op_lower == "depends_on":
# param1 = dependency name, param2 = dependency value
if "depends_on" in a:
Expand Down
7 changes: 6 additions & 1 deletion src/did/did_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ classes:
- name: database
type: class
matlab_path: "+did/database.m"
matlab_last_sync_hash: "83a2bda"
matlab_last_sync_hash: "0de9cf2"
status: ported
python_path: "did/database.py"
python_class: "Database"
Expand All @@ -57,6 +57,11 @@ classes:
In Python the SQL search is SQLiteDB's, not Database's, so the port is in
did/implementations/sqlitedb.py -- see the sqlitedb entry. Nothing
changes in did/database.py.
DID-matlab 0de9cf2 (2026-10-24): search_doc_ids unions over
did.datastructures.orOperands for an OR that is not all base.id
lookups, instead of recursing once per nested OR. Ported to
SQLiteDB._search_doc_ids (DID-python #100 / #103); nothing changes in
did/database.py.
sync_notes: >
Schema validation was ported on 2026-08-28, closing what had been the
largest capability gap between the two languages. Python previously had
Expand Down
21 changes: 19 additions & 2 deletions src/did/did_matlab_python_bridge_util.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -317,11 +317,28 @@ functions:
- name: fieldsearch
type: function
matlab_path: "+did/+datastructures/fieldsearch.m"
matlab_last_sync_hash: "3aa892d"
matlab_last_sync_hash: "0de9cf2"
status: ported
python_path: "did/datastructures.py"
python_name: "field_search"
decision_log: "Exact match. Synchronized 2026-03-15."
decision_log: >
Exact match. Synchronized 2026-03-15. DID-matlab 0de9cf2 (2026-10-24):
case 'or' loops over did.datastructures.orOperands instead of recursing
once per nested OR, and stops at the first operand that matches;
field_search does the same with or_operands (DID-python #100 / #103).

- name: orOperands
type: function
matlab_path: "+did/+datastructures/orOperands.m"
matlab_last_sync_hash: "0de9cf2"
status: ported
python_path: "did/datastructures.py"
python_name: "or_operands"
decision_log: >
Added in both languages together (DID-python #100 / #103, DID-matlab
0de9cf2): flattens a chain of nested ORs into its operands, left to
right, with a stack; a nested '~or' stays one operand. MATLAB returns a
cell array, Python a list.

- name: findclosest
type: function
Expand Down
15 changes: 9 additions & 6 deletions src/did/implementations/sqlitedb.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from datetime import datetime, timezone

from ..database import Database
from ..datastructures import or_operands
from ..file import is_safe_uid


Expand Down Expand Up @@ -1712,12 +1713,14 @@ def _search_doc_ids(self, search_struct, branch_id, candidates=None):
if is_lookup and self._search_indexes_ready:
# base.id lookups ORed together: search all the ids at once
return self._search_base_ids(lookup_ids, branch_id, candidates)
# OR: union results from param1 and param2
p1 = search_struct.get("param1")
p2 = search_struct.get("param2")
ids1 = self._search_doc_ids(p1, branch_id, candidates) if p1 else []
ids2 = self._search_doc_ids(p2, branch_id, candidates) if p2 else []
result = list(set(ids1) | set(ids2))
# OR: union the results of its operands. A chain of ORs is
# flattened first (or_operands), so a long one built in a loop
# does not recurse once per term (issue #100).
matched = set()
for operand in or_operands(search_struct):
if operand:
matched |= set(self._search_doc_ids(operand, branch_id, candidates))
result = list(matched)
if negation:
if universe is None:
universe = self._do_get_doc_ids(branch_id)
Expand Down
89 changes: 74 additions & 15 deletions src/did/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,83 @@ def to_search_structure(self):

@staticmethod
def _resolve_search_structure(ss):
"""Recursively resolve a search structure."""
if isinstance(ss, list):
return [Query._resolve_single(item) for item in ss]
return Query._resolve_single(ss)
"""Resolve a search structure: a list (an AND) or a single item.

A chain of ORs built in a loop (``q = q | ...``) nests one level per
term, so this walks the structure with an explicit stack rather than
by recursion, which ran out of stack after about 1,000 terms (issue
#100). Each node is rebuilt once all its children are: a list from
its resolved items, an OR from its resolved operands, anything else
by :meth:`_resolve_leaf`. The result is what the recursive
definition gives -- see :meth:`_resolve_single` for the per-node rule.
"""
# A node is (object, kind). "struct" is anything an OR operand or the
# top level may be (a list is an AND of items); "single" is one item.
done = {} # (id(object), kind) -> resolved
keep_alive = [] # objects whose id() is a key in done
stack = [(ss, "struct")]
while stack:
node, kind = stack[-1]
key = (id(node), kind)
if key in done:
stack.pop()
continue
children = Query._resolve_children(node, kind)
pending = [c for c in children if (id(c[0]), c[1]) not in done]
if pending:
stack.extend(reversed(pending))
continue
stack.pop()
keep_alive.append(node)
done[key] = Query._resolve_node(node, kind, done)
return done[(id(ss), "struct")]

@staticmethod
def _is_or(item):
return (
isinstance(item, dict)
and str(item.get("operation", "")).removeprefix("~").lower() == "or"
)

@staticmethod
def _resolve_children(node, kind):
"""The nodes that must be resolved before *node* can be."""
if kind == "struct":
if isinstance(node, list):
return [(item, "single") for item in node]
return [(node, "single")]
if Query._is_or(node):
return [
(node.get(p), "struct") for p in ("param1", "param2") if node.get(p)
]
return []

@staticmethod
def _resolve_node(node, kind, done):
"""Resolve *node*, whose children are already resolved in *done*."""
if kind == "struct":
if isinstance(node, list):
return [done[(id(item), "single")] for item in node]
return done[(id(node), "single")]
if Query._is_or(node):
p1, p2 = node.get("param1"), node.get("param2")
return {
"field": node.get("field", ""),
"operation": node.get("operation", ""),
"param1": done[(id(p1), "struct")] if p1 else p1,
"param2": done[(id(p2), "struct")] if p2 else p2,
}
return Query._resolve_leaf(node)

@staticmethod
def _resolve_single(item):
"""Resolve one item: an OR's operands in turn, or a leaf operation."""
if Query._is_or(item):
return Query._resolve_search_structure(item)
return Query._resolve_leaf(item)

@staticmethod
def _resolve_leaf(item):
if not isinstance(item, dict):
return item

Expand Down Expand Up @@ -138,15 +208,4 @@ def _resolve_single(item):
}
return resolved

elif op_lower == "or":
# Recursively resolve OR sub-structures
p1 = item.get("param1")
p2 = item.get("param2")
return {
"field": item.get("field", ""),
"operation": operation,
"param1": Query._resolve_search_structure(p1) if p1 else p1,
"param2": Query._resolve_search_structure(p2) if p2 else p2,
}

return item
Loading
Loading