Searching

This document specifies the read API of a psycodict search table (psycodict.searchtable.PostgresSearchTable): the methods used to retrieve rows, count them and compute simple statistics. It is the companion to QueryLanguage.md, which specifies the dictionary-to-WHERE language used by the query/constraint arguments below; this document specifies everything else — parameters, return types, projections, the info contract, sorting, sampling, caching and result-value semantics.

Stability

This describes psycodict 1.0. The methods and behaviors documented here are the stable read interface: their signatures, return-type rules and the info dictionary contract are frozen for the 1.0 series.

Names beginning with an underscore (_parse_dict, _build_query, _search_iterator, _process_sort, _count_cutoff, _include_nones, …) are private implementation details. They are referenced in this document only to explain observable behavior; they are not part of the stable API and may change without notice. Code outside psycodict should call only the non-underscore methods described below (on the table, and on its public table.stats attribute).

A search table is reached as db.<table_name> (equivalently db["<table_name>"]) on a PostgresDatabase. Every example in this document uses db for the database and a table variable such as nf = db.nf_fields.

Contents

lucky, lookup, exists, label_exists

lucky

lucky(query={}, projection=2, offset=0, sort=[], raw=None, raw_values=None, join=None)

lucky returns a single record — the first row matching query — or None if there is no match. It is the single-result counterpart of search, and shares its projection semantics: with projection=0 or a string projection it returns a bare value; otherwise a dictionary.

Two defaults differ from search:

  • projection=2 (all columns), versus search’s 1. Since 2 is an alias for 1, both return all search columns; the difference is only in the default.

  • sort=[]unsorted by default. Because no order is imposed unless you pass one, when more than one row matches, which row you get is arbitrary and not stable across calls or database states. Pass an explicit sort (or sort=None for the table’s default sort) when you need a well-defined “first” row.

offset skips that many matching rows before taking one, so lucky(query, offset=k, sort=[...]) returns the k-th match in the sort order (0-indexed). raw/raw_values behave as in search and are incompatible with join.

nf.lucky({'degree': 2, 'disc_sign': 1, 'disc_abs': 5}, projection=0)  # '2.2.5.1'
nf.lucky({'label': '6.6.409587233.1'}, projection=['regulator'])
    # {'regulator': 455.191694993}
nf.lucky({'label': 'no.such.label'})   # None

lookup

lookup(label, projection=2, label_col=None, join=None)

A convenience wrapper: lookup(label) returns the single record whose label column equals label (or None). It is exactly lucky({label_col: label}, projection=projection, sort=[], join=join), where label_col defaults to the table’s label column. If the table has no label column and none is supplied, it raises ValueError. Because it delegates to lucky with sort=[], it assumes the label is unique (as labels normally are).

exists

exists(query)

Returns a boolean: whether at least one row matches query. Implemented as self.lucky(query, projection="id") is not None, so it fetches a single id rather than counting.

label_exists

label_exists(label, label_col=None)

Returns whether a row with the given label exists: exists({label_col: label}). As with lookup, label_col defaults to the table’s label column and a missing label column raises ValueError.

random and random_sample

random

random(query={}, projection=0, pick_first=None)

Returns one row chosen uniformly at random from those matching query (projection=0 by default, so a random label), or None if nothing matches. The algorithm depends on the arguments:

  • pick_first given — a column name. random first fetches the distinct values of that column (subject to query) via distinct, chooses one uniformly, adds it to the query, and recurses. This samples column values uniformly rather than rows — so with an unevenly distributed column, rows with rare values are over-represented. The distinct-value list is computed and held in memory, so pick_first should name a column with few distinct values.

  • Non-empty query, no pick_firstrandom asks the counts cache (stats.quick_count) how many rows match:

    • If the count is cached, it picks a uniform offset in [0, count) and returns lucky(query, projection=projection, offset=offset, sort=[]). Because the offset is applied to an unsorted query, this is a valid uniform draw only if the (arbitrary) row order is stable between the count and the fetch.

    • If the count is not cached, it must materialize the match set to sample uniformly: it fetches all matching labels (projection=0) or all matching ids (any other projection) unsorted, records the resulting count in the counts table as a side effect, and returns a random.choice (re-fetching the full row by id if a non-label projection was requested). None if the set is empty.

  • Empty query, no pick_first — an id-based retry loop. random reads MIN(id) and MAX(id), and up to 100 times picks a random id uniformly in that range and tries to fetch it, returning the first hit. This is fast and index-only, but if ids are sparse (many rows deleted) it can fail; after 100 misses it raises RuntimeError("Random selection failed!"). On a genuinely empty table (no rows) it returns None.

Side-effect note: on a counts-cache miss the uncached non-empty-query branch computes the exact count (it materializes the full match set anyway) and records it in the counts table — but only when count-saving is enabled, the same rule count follows. See Counts and statistics.

random_sample

random_sample(ratio, query={}, projection=1, mode=None, repeatable=None, silent=False)

Returns an approximately-ratio fraction of the rows matching query. ratio must be in (0, 1]; anything else raises ValueError. The fraction is approximate (except in choice mode) and the amount of randomness varies by mode:

  • mode='system' — PostgreSQL TABLESAMPLE SYSTEM: the fastest option, but it samples random pages rather than random rows, which clusters the sample. The WHERE clause is applied after sampling.

  • mode='bernoulli'TABLESAMPLE BERNOULLI: each row is included independently with probability ratio, then the WHERE clause is applied. Slower than system but not clustered.

  • mode='choice' — fetch all rows matching query, then take a random.sample of int(len(results) * ratio) of them. Slow when many rows match, but accurate on the ratio and efficient when few match.

  • mode=None (default) — choose automatically: bernoulli if count(query) > _count_cutoff, else choice.

repeatable is an integer random seed for reproducible samples (it seeds Python’s RNG in choice mode, and becomes the SQL REPEATABLE (seed) clause in system/bernoulli mode). silent suppresses slow-query warnings.

Return type varies by mode (unlike search): choice returns a list (from random.sample); system and bernoulli return an iterator (they run an unlimited search-style query on a server-side cursor); and ratio == 1 short-circuits to search(query, projection, sort=[]), which is also an iterator. Wrap in list(...) if you need a uniform container.

Counts and statistics

These methods live on the search table and (except distinct) delegate to the public statistics table table.stats. Two auxiliary tables back the cache: <table>_counts (row counts for queries) and <table>_stats (distinct-value counts and min/max/sum statistics); the table’s total row count is additionally cached in memory and in meta_tables.total.

count

count(query={}, groupby=None, record=True, join=None)

Returns the number of rows matching query.

  • Empty query ({}) is answered from the cached total (stats.total, mirrored in meta_tables) — no scan.

  • Non-empty query is looked up in the <table>_counts cache (quick_count); on a miss it runs SELECT COUNT(*) (_slow_count) and, if recording is enabled, stores the result.

  • groupby — a list of columns. Instead of a single integer, returns a dictionary mapping each distinct tuple of values of those columns (a 1-tuple for a single column) to its count, via GROUP BY. Grouped counts are never cached, and groupby is not supported together with join.

  • record — see The record parameter below.

  • join — see Joins; joined counts are computed directly and never cached, and record/groupby do not apply.

nf.count({'degree': 6, 'galt': 7})          # 244006
nf.count({}, groupby=['degree'])            # {(1,): ..., (2,): ..., ...}

count_distinct

count_distinct(col, query={}, record=True)

Returns the number of distinct values taken by col (a column name or a list of column names) among rows matching query. It checks the <table>_stats cache (quick_count_distinct) and, on a miss, runs COUNT(DISTINCT …) and optionally records it. Equivalent to len(distinct(col, query)) for a single column, but faster and cacheable.

distinct

distinct(col, query={})

Returns the sorted list of distinct values of col among rows matching query, via SELECT DISTINCT ORDER BY. Unlike count_distinct, distinct is never cached — every call scans.

max, min, sum

max(col, constraint={})
min(col, constraint={})
sum(col, constraint={})

Return the maximum, minimum and sum of col over rows matching constraint. Each checks the <table>_stats cache first (_quick_statistic) and, on a miss, computes the value (_slow_statistic) and may record it.

  • max/min raise ValueError if there are no non-null values of col in the constrained set.

  • max('id') is special-cased to return count() (the table’s row count).

  • Note the argument name is constraint here, not query as elsewhere; the meaning is the same (a query dictionary).

The record parameter

count and count_distinct take record=True, and max/min/sum record by default; the intent is to persist a freshly computed value into the cache tables so later calls are fast. On this branch, whether recording actually happens depends on the PostgresStatsTable.saving flag, which is False by default in psycodict (it is meant to be enabled by a subclass in a data-management deployment). With the default saving = False:

  • count(..., record=True), count_distinct(..., record=True), max, min and sum record nothing — the record argument is effectively inert, and the computed value is returned without being cached.

  • random behaves the same way: on a counts-cache miss it computes the exact count as a side effect (it has the full match set in hand), but stores it only when saving = True.

When a subclass sets saving = True, all of the above record their results and this is the intended mode for the tools that populate a database. (Writes still fail silently if the connection lacks write permission, e.g. a read-only web user, so recording is best-effort even under saving.)

Joins

search, lucky, lookup and count accept a join argument that makes columns of other search tables available to the query, projection, sort and results. The join language itself — the shape of the join list, name resolution, which special keys apply, $col/$raw across tables — is specified in the Joined queries section of QueryLanguage.md and is not repeated here. This section states only how join changes the read-method contracts documented above.

  • Result keys. Result dictionaries use the projection entries verbatim as keys, so a joined column projected as "other_table.col" comes back under that exact qualified string. Integer projections (0/1/2/3) refer to the primary table’s columns only.

  • Projections. Only string, list and integer projections are allowed; dictionary projections raise ValueError with join. The "col[i]" slicer form is still accepted.

  • Return types and the info contract are unchanged — iterator vs. list by limit, the same five info keys, the same past-the-last-page adjustment — with one difference in how counts are obtained (next point).

  • Counts are never cached. The count used for info["number"] (and the result of count(..., join=…)) is computed directly with SELECT COUNT(*) over the joined FROM clause every time; the counts cache and the record parameter play no role. Empty-query joined counts are not shortcut through the total.

  • Unsupported options. split_ors, one_per, raw/raw_values and groupby do not combine with join and raise ValueError.

db.ec_nfcurves.search(
    {"rank": 1, "nf_fields.r2": 1},
    ["label", "nf_fields.degree"],
    join=[("field_label", "nf_fields.label")],
    limit=3)
# [{'label': '2.0.1003.1-9.1-c1', 'nf_fields.degree': 2}, ...]

Result-value semantics

None values and include_nones

When a projection produces a dictionary, whether columns whose value is SQL NULL appear in that dictionary is governed by the table’s include_nones setting (a per-table meta setting, _include_nones, default True):

  • include_nones = True (default) — every projected column is present in the result dictionary, with None for nulls. This applies uniformly to search (iterator and list forms), lucky, lookup and joined queries.

  • include_nones = False — key/value pairs whose value is None are omitted from the result dictionary, so a returned dict contains only the non-null columns among those projected and callers must treat a missing key as “null” (e.g. with dict.get). Pass include_nones=False at create_table to select this per table.

Because the setting is per-table, two tables in the same database can differ, and the same projection can yield dicts with different key sets depending on the table it came from. (Bare-value projections — 0 or a string — are unaffected: they can return None as the value.)

This interacts with left/right/full joins: an unmatched side contributes NULL columns. Under the default include_nones = True they appear as None; under include_nones = False they simply do not appear in the result dict — so on a table set that way, to detect unmatched rows project a column you expect to be non-null and test for its absence, or query {"table.col": None}. See the Joined queries section of QueryLanguage.md.

Type mapping in results

Values come back as Python objects decoded from PostgreSQL by psycopg plus psycodict’s registered adapters (psycodict/encoding.py, psycodict/database.py):

  • jsonb/json columns decode to the corresponding Python objects — dicts, lists, strings, numbers, booleans, None — natively. Array and composite json values thus become nested Python structures.

  • Array columns (integer[], numeric[], …) decode to Python lists.

  • numeric/decimal columns go through a custom converter (numeric_converter): a value with no decimal point becomes an integer and a value with a decimal point becomes a real number. The exact Python type depends on whether Sage is importable: with Sage, integers become Sage Integers and decimals become Sage real literals (LmfdbRealLiteral, whose precision tracks the number of digits and which re-prints as it was stored) — except an all-zero decimal, which becomes an exact integer zero (LmfdbDecimalZero) so it does not drag a partner down to a few bits of precision in later arithmetic; without Sage, they fall back to Python int and float respectively. Other numeric SQL types (integer, bigint, double precision, boolean, text) map to the obvious Python types.

See psycodict/encoding.py for the full adapter set and the Sage-aware encoding/decoding of additional types.