Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
15 changes: 14 additions & 1 deletion lmfdb/number_fields/number_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
parse_floats, parse_subfield, search_wrap, parse_padicfields, integer_options,
raw_typeset, raw_typeset_poly, flash_info, input_string_to_poly,
raw_typeset_int, compress_poly_Q, compress_polynomial, CodeSnippet, redirect_no_cache)
from lmfdb.utils.search_wrapper import multi_entry_jump_search
from lmfdb.utils.web_display import compress_int
from lmfdb.utils.interesting import interesting_knowls
from lmfdb.utils.search_columns import SearchColumns, SearchCol, CheckCol, MathCol, ProcessedCol, MultiProcessedCol, CheckMaybeCol, PolynomialCol
Expand Down Expand Up @@ -817,6 +818,17 @@ def interesting():


def number_field_jump(info):
# If jump box input is a comma-separated list of labels/names/polynomials, then use multi_entry_jump_search to return a search page
multi_jump = multi_entry_jump_search(
info,
parse_entry=nf_string_to_label,
label_exists=db.nf_fields.label_exists,
index_endpoint=".number_field_render_webpage",
object_name="number fields",
)
if multi_jump is not None:
return multi_jump

query = {'label_orig': info['jump']}
try:
parse_nf_string(info, query, 'jump', name="Label", qfield='label')
Expand Down Expand Up @@ -1165,6 +1177,7 @@ def nf_code(**args):

class NFSearchArray(SearchArray):
noun = "field"
label_knowl = "nf.label"
sorts = [("", "degree", ['degree', 'disc_abs', 'disc_sign', 'iso_number']),
("signature", "signature", ['degree', 'r2', 'disc_abs', 'disc_sign', 'iso_number']),
("rd", "root discriminant", ['rd', 'degree', 'disc_abs', 'disc_sign', 'iso_number']),
Expand All @@ -1177,7 +1190,7 @@ class NFSearchArray(SearchArray):
jump_example = "x^7 - x^6 - 3 x^5 + x^4 + 4 x^3 - x^2 - x + 1"
jump_egspan = r"e.g. 2.2.5.1, Qsqrt5, x^2-5, or x^2-x-1 for \(\Q(\sqrt{5})\)"
jump_knowl = "nf.search_input"
jump_prompt = "Label, name, or polynomial"
jump_prompt = "Label, name, polynomial, or comma-separated list"
has_diagram = False

def __init__(self):
Expand Down
8 changes: 8 additions & 0 deletions lmfdb/number_fields/test_numberfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ def test_search_zeta(self):
def test_search_sqrt(self):
self.check_args('/NumberField/?jump=Qsqrt-163&search=Go', '41') # minpoly

def test_search_multiple_fields(self):
# Test comma-separated list of field labels
self.check_args('/NumberField/?jump=2.2.5.1%2c+3.3.49.1&search=Go', '2.2.5.1')
self.check_args('/NumberField/?jump=2.2.5.1%2c+3.3.49.1&search=Go', '3.3.49.1')
# Test comma-separated list with different input formats
self.check_args('/NumberField/?jump=Qsqrt5%2c+x%5E2-3&search=Go', '2.2.5.1')
self.check_args('/NumberField/?jump=Qsqrt5%2c+x%5E2-3&search=Go', '2.2.12.1')

def test_search_disc(self):
self.check_args('/NumberField/?discriminant=1988-2014', '401') # factor of one of the discriminants

Expand Down
4 changes: 3 additions & 1 deletion lmfdb/utils/search_boxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,7 @@ class SearchArray(UniqueRepresentation):
"""
_ex_col_width = 170 # only used for box layout
sort_knowl = None
label_knowl = None # Link to knowl for label (used for "labels" SneakyBox)
sorts = None # Provides an easy way to implement sort_order: a list of triples (name, display, sort -- as a list of columns or pairs (col, +-1)), or a dictionary indexed on the value of self._st()
null_column_explanations = {} # Can override the null warnings for a column by including False as a value, or customize the error message by giving a formatting string (see search_wrapper.py)
noun = "result"
Expand Down Expand Up @@ -711,7 +712,8 @@ def main_array(self, info):
if info is None:
return self.browse_array
else:
return self.refine_array
# Append a "Labels" search box allowing multiple labels to be passed via URL
return self.refine_array + [[SneakyTextBox(name="labels", label="Labels", knowl=self.label_knowl)]]

def _print_table(self, grid, info, layout_type):
if not grid:
Expand Down
132 changes: 131 additions & 1 deletion lmfdb/utils/search_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import time
from random import randrange
from flask import render_template, jsonify, redirect, request
from flask import render_template, jsonify, redirect, request, url_for
from urllib.parse import urlparse
from psycopg2.extensions import QueryCanceledError
from psycopg2.errors import NumericValueOutOfRange
Expand Down Expand Up @@ -38,6 +39,134 @@ def use_split_ors(info, query, split_ors, offset, table):
)


def split_top_level_commas(text):
"""
A function which takes an input string and returns a list of strings, splitting on commas that are not inside parentheses/brackets/braces.
Used as the default separator function when parsing jump box input for multiple entries.
"""

entries = []
chunk = []
depth = 0
for ch in text:
if ch in "([{":
depth += 1
chunk.append(ch)
elif ch in ")]}":
depth = max(depth - 1, 0)
chunk.append(ch)
elif ch == "," and depth == 0:
entry = "".join(chunk).strip()
if entry:
entries.append(entry)
chunk = []
else:
chunk.append(ch)

entry = "".join(chunk).strip()
if entry:
entries.append(entry)
return entries


def multi_entry_jump_search(info, parse_entry, label_exists, index_endpoint, input_key="jump", labels_key="labels",
sep=split_top_level_commas, object_name="records", time_limit=30):
"""
Generic handler for jump boxes that supports comma-separated input of various entries (labels/names/polynomials/equations etc.).

Returns ``None`` if there is at most one entry, allowing the caller's single-entry jump logic to run.
Otherwise returns a redirect to a search page of the given labels.

INPUT:

- ``info`` -- the info dictionary passed in from front end
- ``parse_entry`` -- a custom function which converts a string (e.g. polynomial, equation, nickname, etc.) to be parsed into a label
- ``label_exists`` -- a custom function which determines whether a given label exists in the database
- ``index_endpoint`` -- the input to "url_for" which returns the index homepage for this section
- ``input_key`` -- the dictionary key for the jump search box (default: "jump")
- ``labels_key`` -- the dictionary key for the labels search query (default: "labels")
- ``sep`` -- A function used to separate out jump box input into separate entries (default: split_top_level_commas)
- ``object_name`` -- The name of the objects in the database (e.g. "fields", "elliptic curves"). Used when flashing info or error messages.
- ``time_limit`` -- a time limit (in seconds) for the maximum amount of time this query should take (default: 30)
"""

jump_input = info.get(input_key, "")
entries = [s.strip() for s in sep(jump_input) if s.strip()]
if len(entries) <= 1:
return None

# For each entry given in the comma-separated jump box input, we attempt to parse the entry using parse_entry (while skipping duplicates)
# If the user inputs a large number of entries, this may take a long time (e.g. for number fields, this might require calling Pari's polredabs on every entry)
# We start a timer and stop parsing entries if we've hit the specified time_limit (default: 30 seconds)

labels, seen = [], set()
not_parsed, not_found = 0, 0
start_timer = time.monotonic()
for i in range(len(entries)):
# Check if exceeded time limit
if time.monotonic() - start_timer > time_limit:
flash_error("Query timed out after parsing the first %s entries in the input.", i)
return redirect(url_for(index_endpoint))

# Attempt to parse entry
try:
label = parse_entry(entries[i])
except (SearchParsingError, ValueError):
not_parsed += 1
continue
if not label_exists(label):
not_found += 1
continue
if label not in seen:
labels.append(label)
seen.add(label)

# Flash error if no entries successfully parsed
if not labels:
flash_error("None of the %s entries matched %s in the database.", len(entries), object_name)
return redirect(url_for(index_endpoint))

# Otherwise flash info message with number of entries we are able to parse
ignored = not_parsed + not_found
duplicates = len(entries) - ignored - len(labels)
if ignored:
flash_info("Matched %s of %s entries; ignored %s unrecognized or missing entries.", len(labels), len(entries), ignored)
if duplicates > 0:
flash_info("Removed %s duplicate label(s).", duplicates)

return redirect(url_for(index_endpoint, **{labels_key: ",".join(labels)}))


def parse_labels(info, query, table, labels_key="labels"):
"""
Parse a list of labels from the URL "?labels=" query into a database query.
"""
labels_input = info.get(labels_key)
if not labels_input or not hasattr(table, "_label_col"):
return

# Separate out labels from input, stripping whitespace and removing duplicates while preserving order
labels = list(set(label.strip() for label in labels_input.split(",")))
seen = set(labels)
if not labels:
return

label_col = table._label_col
existing = query.get(label_col)
if existing is None:
query[label_col] = {"$in": labels}
elif isinstance(existing, dict):
if "$in" in existing:
existing["$in"] = [label for label in existing["$in"] if label in seen]
else:
# Keep existing constraints and add an $in constraint as well.
existing["$in"] = labels
else:
# Existing exact match constraint: keep it only if it appears in labels.
if existing not in seen:
query[label_col] = {"$in": []}


class Wrapper:
def __init__(
self,
Expand Down Expand Up @@ -85,6 +214,7 @@ def make_query(self, info, random=False):
template_kwds = {key: info.get(key, val()) for key, val in self.kwds.items()}
try:
errpage = self.f(info, query)
parse_labels(info, query, self.table)
except Exception as err:
# Errors raised in parsing; these should mostly be SearchParsingErrors
if is_debug_mode():
Expand Down
Loading