feat: add exists() support via FT.AGGREGATE APPLY/FILTER#14
Conversation
There was a problem hiding this comment.
Pull request overview
Adds support for SQL exists(field) by translating it into RediSearch aggregation expressions (exists(@field)) and enabling use in both SELECT projections (via APPLY) and HAVING predicates (via FILTER) within FT.AGGREGATE.
Changes:
- Extend the SQL parser to recognize
exists(column)in SELECT and HAVING, and to rejectexists()in WHERE. - Extend the analyzer to include fields referenced by HAVING filters in schema validation.
- Update the translator to force
FT.AGGREGATE, generateAPPLY/FILTERforexists(), and ensure referenced fields are included inLOAD.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
sql_redis/parser.py |
Adds HAVING parsing into filters and SELECT handling for exists(column) computed fields. |
sql_redis/analyzer.py |
Attempts to include fields referenced in HAVING filters in schema validation. |
sql_redis/translator.py |
Forces aggregate path when HAVING filters exist; emits FILTER for exists() and loads fields referenced by exists(). |
tests/test_sql_parser.py |
Adds parser unit tests for exists() in SELECT/HAVING and WHERE rejection. |
tests/test_translator.py |
Adds translator unit tests for exists() APPLY/FILTER generation and LOAD behavior. |
tests/test_exists.py |
Adds end-to-end integration tests covering projection, HAVING filtering, and error cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Translate SQL IS NULL and IS NOT NULL to RediSearch ismissing(@field) and -ismissing(@field) respectively. Requires Redis 7.4+ with the INDEXMISSING attribute declared on the target field. Changes: - parser: handle sqlglot exp.Is nodes, emit IS_NULL / IS_NOT_NULL operators - query_builder: add build_missing_condition() for ismissing() syntax - translator: short-circuit IS_NULL/IS_NOT_NULL before field-type dispatch - translator: make DIALECT 2 the default for all FT.SEARCH and FT.AGGREGATE - executor: catch and re-raise ResponseError with clear version guidance when ismissing() fails (both sync and async paths) - translator: emit UserWarning on IS NULL/IS NOT NULL noting Redis 7.4+ and INDEXMISSING requirements Tests: - 18 integration tests against Redis 8 (TAG, TEXT, NUMERIC field types, combined conditions, edge cases, raw command verification) - Unit tests for parser, query_builder, and translator - Warning and error message verification tests
Translate SQL exists(field) to RediSearch exists(@field) in FT.AGGREGATE APPLY (projection) and FILTER (HAVING) clauses. - Parser: handle exp.Exists in SELECT → computed_fields and HAVING → filters, distinguishing from SQL EXISTS (SELECT ...) - Analyzer: extract field references from exists() for schema validation - Translator: force FT.AGGREGATE when exists() is used, generate APPLY/FILTER clauses, include exists() fields in LOAD - Reject exists() in WHERE with clear error (aggregate-only function) - No INDEXMISSING attribute required — works on any indexed field Tests: 8 integration, 5 parser, 7 translator (350 total pass)
…pressions like exists(a) + exists(b), ensuring the referenced fields are included in LOAD args and don't cause a Property not loaded runtime error.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…UPBY/REDUCE - parser: unwrap exp.Paren in _process_having_clause so queries like HAVING (exists(email)) don't raise 'Unsupported HAVING expression' - translator: move exists() FILTER emission to after GROUPBY/REDUCE block so it acts as post-aggregation filtering (correct HAVING semantics) instead of pre-group document filtering
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Move import re, import asyncio, import warnings, import time, and several from-imports out of function/method bodies to module-level imports. Local imports inside TYPE_CHECKING guards and try/except compatibility blocks are intentionally kept.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
When FT.AGGREGATE is forced (e.g., by HAVING exists()), SELECT * was skipping the wildcard in the LOAD field collection loop, resulting in no fields being loaded and empty/partial rows. Now detects SELECT * in aggregate mode and emits LOAD * so RediSearch returns all document attributes. Adds test for SELECT * with HAVING exists() verifying LOAD * is emitted.
When FT.AGGREGATE is forced (e.g., by HAVING exists()), SELECT * was skipping the wildcard in the LOAD field collection loop, resulting in no fields being loaded and empty/partial rows. Now detects SELECT * in aggregate mode and emits LOAD * so RediSearch returns all document attributes.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Summary
Translates SQL
exists(field)to RediSearchexists(@field)inFT.AGGREGATEAPPLY(projection) andFILTER(HAVING) clauses. Unlikeismissing(),exists()does not requireINDEXMISSINGon the field — it works on any indexed field. ForcesFT.AGGREGATEwhen used.DIALECT 2is appended automatically.Changes
sql_redis/parser.py— Handleexp.Existsnodes in_process_select_expression_innerto routeexists(column_ref)tocomputed_fields. Distinguish from SQLEXISTS (SELECT ...)subqueries. Add_process_having_clauseto parseHAVING exists(field)into afilterslist. Rejectexists()inWHEREwith a clear error.sql_redis/analyzer.py— Extract field references fromparsed.filtersso fields insideexists()pass schema validation.sql_redis/translator.py— ForceFT.AGGREGATEwhenexists()is used. GenerateAPPLY "exists(@field)" AS aliasfor SELECT projections andFILTER "exists(@field)"for HAVING clauses. Includeexists()fields inLOADarguments.Usage
SQL queries
Translated commands
SELECT exists(email) AS has_email FROM idxFT.AGGREGATE idx "*" LOAD 1 @email APPLY "exists(@email)" AS has_email DIALECT 2SELECT name, exists(email) AS e, exists(phone) AS p FROM idxFT.AGGREGATE idx "*" LOAD 3 @email @name @phone APPLY "exists(@email)" AS e APPLY "exists(@phone)" AS p DIALECT 2SELECT name FROM idx HAVING exists(email)FT.AGGREGATE idx "*" LOAD 2 @email @name FILTER "exists(@email)" DIALECT 2exists() vs ismissing()
exists()ismissing()SELECT exists(field) AS .../HAVING exists(field)WHERE field IS NULLexists(@field)→ 1 or 0ismissing(@field)→ filter"1"(present) /"0"(absent)Error handling
ValueErrorwith message: "exists() is a RediSearch aggregate function and cannot be used in WHERE clauses. Use HAVING exists(field) instead for post-aggregate filtering."ValueErrorif the field is not in the index schema.Tests
tests/test_exists.py) — SELECT projection (TAG, NUMERIC, multiple), HAVING filter, command structure verification, error handling