188188import static org .elasticsearch .xpack .esql .core .type .DataType .UNSUPPORTED ;
189189import static org .elasticsearch .xpack .esql .core .type .DataType .VERSION ;
190190import static org .elasticsearch .xpack .esql .core .type .DataType .isTemporalAmount ;
191+ import static org .elasticsearch .xpack .esql .parser .ParserUtils .source ;
191192import static org .elasticsearch .xpack .esql .telemetry .FeatureMetric .LIMIT ;
192193import static org .elasticsearch .xpack .esql .telemetry .FeatureMetric .STATS ;
193194import static org .elasticsearch .xpack .esql .type .EsqlDataTypeConverter .maybeParseTemporalAmount ;
@@ -228,6 +229,10 @@ public class Analyzer extends ParameterizedRuleExecutor<LogicalPlan, AnalyzerCon
228229 "esql_lookup_join_full_text_function"
229230 );
230231
232+ public static final TransportVersion ESQL_LOOKUP_JOIN_GENERAL_EXPRESSION = TransportVersion .fromName (
233+ "esql_lookup_join_general_expression"
234+ );
235+
231236 private final Verifier verifier ;
232237
233238 public Analyzer (AnalyzerContext context , Verifier verifier ) {
@@ -730,14 +735,17 @@ private Expression resolveJoinFiltersAndSwapIfNeeded(
730735 /**
731736 * This function resolves and orients a single join on condition.
732737 * We support AND of such conditions, here we handle a single child of the AND
733- * We support the following 2 cases:
738+ * We support the following cases:
734739 * 1) Binary comparisons between a left and a right attribute.
735740 * We resolve all attributes and orient them so that the attribute on the left side of the join
736741 * is on the left side of the binary comparison
737742 * and the attribute from the lookup index is on the right side of the binary comparison
738743 * 2) A Lucene pushable expression containing only attributes from the lookup side of the join
739744 * We resolve all attributes in the expression, verify they are from the right side of the join
740745 * and also verify that the expression is potentially Lucene pushable
746+ * 3) General expressions (when all nodes support ESQL_LOOKUP_JOIN_GENERAL_EXPRESSION) that may reference
747+ * attributes from both sides. We extract all left-side attributes referenced in the expression
748+ * and add them to leftJoinKeysToPopulate to ensure they are sent to the lookup join.
741749 */
742750 private Expression resolveAndOrientJoinCondition (
743751 Expression condition ,
@@ -778,14 +786,35 @@ private Expression resolveAndOrientJoinCondition(
778786 + condition .sourceText ()
779787 );
780788 }
781- return handleRightOnlyPushableFilter (condition , rightChildOutput );
789+ Expression result = handleRightOnlyPushableFilter (condition , rightChildOutput , context );
790+ // If general expressions are enabled and this is not an error, extract all left-side attributes
791+ // This ensures that fields like 'value' in ABS(value) > 15 are included in leftFields
792+ if (context .minimumVersion ().onOrAfter (ESQL_LOOKUP_JOIN_GENERAL_EXPRESSION ) && result instanceof UnresolvedAttribute == false ) {
793+ // Extract all left-side attributes from the expression and add them to leftJoinKeysToPopulate
794+ // This handles general expressions that reference left-side fields (e.g., ABS(value) > 15)
795+ for (Attribute attr : condition .references ()) {
796+ if (leftChildOutput .contains (attr )) {
797+ // Check if we've already added this attribute (by NameId to avoid duplicates)
798+ boolean alreadyAdded = leftJoinKeysToPopulate .stream ().anyMatch (a -> a .id ().equals (attr .id ()));
799+ if (alreadyAdded == false ) {
800+ leftJoinKeysToPopulate .add (attr );
801+ }
802+ }
803+ }
804+ }
805+ return result ;
782806 }
783807
784- private Expression handleRightOnlyPushableFilter (Expression condition , AttributeSet rightChildOutput ) {
808+ private Expression handleRightOnlyPushableFilter (Expression condition , AttributeSet rightChildOutput , AnalyzerContext context ) {
785809 if (isCompletelyRightSideAndTranslatable (condition , rightChildOutput )) {
786810 // The condition is completely on the right side and is translation aware, so it can be (potentially) pushed down
787811 return condition ;
788812 } else {
813+ // Check if general expressions are enabled
814+ if (context .minimumVersion ().onOrAfter (ESQL_LOOKUP_JOIN_GENERAL_EXPRESSION )) {
815+ // General expressions are enabled, allow the condition
816+ return condition ;
817+ }
789818 // The condition cannot be used in the join on clause for now
790819 // It is not a binary comparison between left and right attributes
791820 // It is not using fields from the right side only and translation aware
@@ -801,7 +830,13 @@ private Join resolveLookupJoin(LookupJoin join, AnalyzerContext context) {
801830 JoinConfig config = join .config ();
802831 // for now, support only (LEFT) USING clauses
803832 JoinType type = config .type ();
804-
833+ if (context .minimumVersion ().onOrAfter (ESQL_LOOKUP_JOIN_GENERAL_EXPRESSION ) == false
834+ && (join .config ().leftFields ().isEmpty () || join .config ().rightFields ().isEmpty ())) {
835+ throw new ParsingException (
836+ Source .EMPTY ,
837+ "JOIN ON clause with expressions must contain at least one condition relating the left index and the lookup index"
838+ );
839+ }
805840 // rewrite the join into an equi-join between the field with the same name between left and right
806841 if (type == JoinTypes .LEFT ) {
807842 // the lookup cannot be resolved, bail out
0 commit comments