Add joined queries (join= on search/count/lucky) and the $col special key - #89
Merged
Conversation
search, count, lucky and lookup accept a join= option that makes columns of other search tables available to queries, projections and results via qualified "table.column" strings, with INNER and LEFT joins, chained joins, and uncached counts. The unfinished join_search method is replaced. Independently, the $col special key compares two columns of the same table. Both are documented in QueryLanguage.md, which also gains the missing $raw section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 20, 2026
The parser is now join-aware: _parse_dict and _parse_special thread a join context, resolving each name (query keys, $col values) by the same first-period rule and emitting table-qualified identifiers directly, with types looked up in the owning table. This replaces the per-table split-and-qualify architecture, which structurally could not express a clause mixing two tables -- so $or/$and/$not now span tables, $col compares columns of different tables, and $raw stays scoped to its key's table. Sort entries resolve the same way, lifting the primary-table-only restriction, and RIGHT and FULL join types join INNER and LEFT. Review feedback on the v1 scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Names in a $raw expression now resolve like query keys: bare names are the primary table's columns, and in a joined query table.column names a joined table's column, matching the $col scoping decision. filter_sql_injection gains a join_context under which each piece resolves against the validated join specification and its table's own column whitelist -- nothing is admitted that an unjoined clause could not name in its table -- and emits every identifier qualified, which retires the owner threading the previous commit added for $raw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… config.ini, analyze(join=) Joined projection and sort entries now accept the dotted paths the resolution rule promised (a new _column_composable builds the same path composables query keys get), so nf_fields.class_group.0 works as a projection and a sort; projections outside joins still accept only slicers, a pre-existing limitation guarded by a test. config.ini was deleted by mistake in the v2 commit (a scratch file collided with the tracked name during downstream verification); restored from master. analyze gains join=, sharing the new _join_selecter with search and lucky, and the slow-query note for joined searches appends join=..., so logged reproducers are runnable again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the two query-language extensions from the pre-1.0 plan, per the joins design doc, now including the v2 lifts from review: joined sorts, RIGHT/FULL joins, and cross-table
$col/$or/$and/$not.join=onsearch,count,lucky(andlookup)(col1, col2)or(col1, col2, jointype):col2is qualified"table.column"and names the table being joined;col1belongs to the primary table, or to a previously joined table if qualified (enabling chains).jointypeis"inner"(default),"left","right"or"full".$colnames split at the first.— a joined-table prefix means that table's column (further dots are a path within it); anything else is a primary-table name with dots keeping their jsonb/array path meaning. Result dictionaries use projection entries verbatim as keys._parse_dict/_parse_specialthread a join context and resolve each name against its owning table, emitting qualified identifiers directly. So$or/$and/$notclauses span tables, and$coland$rawreach columns of different tables (names in both resolve by the same rule;filter_sql_injectionresolves qualified pieces against the validated join specification and each table's own whitelist). (v1 split the query per table and qualified afterwards, which structurally couldn't express a clause mixing tables.)(col, -1)descending; the default sort is the primary table's.join=Nonedispatches to exactly the previous code before anything else happens.SELECT COUNT(*)every time — noquick_count, no counts-table writes (tested).ValueError:split_ors/one_per/raw/groupbywithjoin, dictionary projections, duplicate/self joins, the old quadruple join format.$col— compare two columns{"col1": {"$col": "col2"}}→"col1" = "col2"; also usable as the operand of any infix operator, with array-slicer support. The name resolves exactly like a query key, so under a join it can name any table's column. Independent of joins for the same-table case.Removes
join_searchThe 2023-era method required tuple keys, had no count/left-join/validation, and generated invalid SQL whenever two tables were constrained. No callers in lmfdb, seminars, psetpartners, or lmfdb-lite.
Docs and tests
QueryLanguage.md: Joined queries and$colsections (single resolution rule, jointypes, NULL-row semantics), and the$rawsection's "Details to be added" is finally written.tests/test_joins.py— projections, constraint placement, jsonb paths on joined tables, LEFT/RIGHT/FULL, chained joins, joined-column sorts, cross-table$col,$or/$notacross tables, info paging, streaming, uncached counts, lucky/lookup, error cases — cross-table$raw, plus 4$coltests intest_search.py, unit tests for the joinedfilter_sql_injection, and 2 devmirror tests cross-checked row-by-row againstnf_fields.nf_fields.class_group.0) via a shared_column_composable;analyzegainsjoin=and slow-query logs for joined searches appendjoin=[...]so their replication commands run; an accidental deletion ofconfig.iniis restored (paths in projections outside joins remain slicer-only — pre-existing, guarded by a test).🤖 Generated with Claude Code