Repository: questdb/questdb
Pull request #5620: perf(sql): support for Oracle-style SQL hints
Author: login: jerrinot
Labels: name: SQL
description: Issues or changes relating to SQL execution
name: Performance
description: Performance improvements
Created: 2025-04-23T15:15:41Z | Merged: 2025-04-25T17:00:26Z

## Motivation
The database query optimizer doesn't always have enough information to choose the optimal execution plan for certain queries. This PR introduces SQL hints, allowing expert users to explicitly guide the execution path when needed.

Hints are implemented as SQL block comments `/*+ HINT */`, making them entirely optional. The database can safely ignore them or fall back to default behavior for unknown hints.

Example:
```sql
select /*+ USE_ASOF_BINARY_SEARCH(orders md) */orders.ts, bid, md.market_data_symbol, orders.order_symbol, md.md_ts as order_ts, price
from orders
asof join (
  select ts as md_ts, market_Data_symbol, bid from market_data
  where market_data_symbol = 'sym_109'
) md
where orders.ts > '2025-01-23T00:00:00.000000000Z'
and bid > price;
```

## Implementation details
### Robustness
- **Error handling:** Syntax errors inside a hint block won't fail the entire SQL query
- **Partial application:** Hints parsed before an error are applied, while everything after is ignored

### ASOF Join Optimization
This PR also changes the default strategy for ASOF joins:
- Reverts to the behavior before PR #5553
- Adds the `USE_ASOF_BINARY_SEARCH(<table alias> <table alias>)` hint to explicitly request the fused strategy when beneficial

Example:
```sql
select * From (
  select t.ts
    , btc_usdt.ts as btc_usdt_ts
  from (
    select *
    from orders
    where ts >= '2025-01-23T10:00:00.000000000Z'
    order by ts
  )  as t
  asof join (
    select ts
    from market_data
    where market_data_symbol = 'sym_10'
  ) btc_usdt
) limit -10;
```
This will NOT use the strategy from #5553:
![Screenshot From 2025-04-24 14-20-43](https://github.com/user-attachments/assets/61085bd9-1e52-482a-9d84-b23bd0901c87)

The `USE_ASOF_BINARY_SEARCH` hint will enforce it:
```sql
select /*+ USE_ASOF_BINARY_SEARCH(t btc_usdt)*/ * From (
  select t.ts
    , btc_usdt.ts as btc_usdt_ts
  from (
    select *
    from orders
    where ts >= '2025-01-23T10:00:00.000000000Z'
    order by ts
  )  as t
  asof join (
    select ts
    from market_data
    where market_data_symbol = 'sym_10'
  ) btc_usdt
) limit -10;
```

![Screenshot From 2025-04-24 14-22-04](https://github.com/user-attachments/assets/2d8cfe95-16fb-4707-872f-2c24c50ad4c7)

## Supported & Unsupported Features
- **Supported**: Block comment hints `(/*+ HINT */)`
- **Not supported**: Line comment hints `(--+ HINT)` to maintain simplicity and clarity in documentation



TODO:
- [x] more tests
- [x] consider whether to ignore syntactical errors (unbalanced brackets, etc), it's a comment after all. 
- [x] review hints propagations in SQL optimiser
- [x] nested comments
- [x] review/refactor changes in `SqlUtil` - there is quite some code duplication
- [x] bikeshed on the exact syntax :) cc @nwoolmer ;-)


Diff:
diff --git a/core/src/main/java/io/questdb/griffin/SqlCodeGenerator.java b/core/src/main/java/io/questdb/griffin/SqlCodeGenerator.java
index 66f7548b07ac..10e26808c065 100644
--- a/core/src/main/java/io/questdb/griffin/SqlCodeGenerator.java
+++ b/core/src/main/java/io/questdb/griffin/SqlCodeGenerator.java
@@ -2453,8 +2453,10 @@ private RecordCursorFactory generateJoins(QueryModel model, SqlExecutionContext
                                             );
                                         }
                                     } else {
+                                        boolean binarySearchHinted = SqlHints.hasAsOfJoinBinarySearchHint(model, masterAlias, slaveModel.getName());
                                         boolean created = false;
-                                        if (fastAsOfJoins) {
+                                        if (fastAsOfJoins || binarySearchHinted) {
+                                            // when slave directly supports time frame cursor then it's strictly better to use it, even without any hint
                                             if (slave.supportsTimeFrameCursor()) {
                                                 master = new AsOfJoinNoKeyFastRecordCursorFactory(
                                                         configuration,
@@ -2466,7 +2468,11 @@ private RecordCursorFactory generateJoins(QueryModel model, SqlExecutionContext
                                                 created = true;
                                             }
 
-                                            if (!created && slave.supportsFilterStealing() && slave.getBaseFactory().supportsTimeFrameCursor()) {
+                                            // if we have a hint, we can try to steal the filter from the slave.
+                                            // this downgrades to single-threaded Java-level filtering so it's only worth it if
+                                            // the filter selectivity is low. we don't have statistics to tell selectivity, so
+                                            // we rely on the user to provide an explicit hint.
+                                            if (binarySearchHinted && !created && slave.supportsFilterStealing() && slave.getBaseFactory().supportsTimeFrameCursor()) {
                                                 RecordCursorFactory slaveBase = slave.getBaseFactory();
                                                 int slaveTimestampIndex = slaveMetadata.getTimestampIndex();
 
@@ -2499,7 +2505,7 @@ private RecordCursorFactory generateJoins(QueryModel model, SqlExecutionContext
                                                 created = true;
                                             }
 
-                                            if (!created && slave.isProjection()) {
+                                            if (binarySearchHinted && !created && slave.isProjection()) {
                                                 RecordCursorFactory projectionBase = slave.getBaseFactory();
                                                 // We know projectionBase does not support supportsTimeFrameCursor, because
                                                 // Projections forward this call to its base factory and if we are in this branch
diff --git a/core/src/main/java/io/questdb/griffin/SqlCompilerImpl.java b/core/src/main/java/io/questdb/griffin/SqlCompilerImpl.java
index 84d1e428e19d..f732d836f9f9 100644
--- a/core/src/main/java/io/questdb/griffin/SqlCompilerImpl.java
+++ b/core/src/main/java/io/questdb/griffin/SqlCompilerImpl.java
@@ -4319,6 +4319,7 @@ private void sqlTableBackup(SqlExecutionContext executionContext) throws SqlExce
         sqlControlSymbols.add(")");
         sqlControlSymbols.add(",");
         sqlControlSymbols.add("/*");
+        sqlControlSymbols.add("/*+");
         sqlControlSymbols.add("*/");
         sqlControlSymbols.add("--");
         sqlControlSymbols.add("[");
diff --git a/core/src/main/java/io/questdb/griffin/SqlHints.java b/core/src/main/java/io/questdb/griffin/SqlHints.java
new file mode 100644
index 000000000000..7e95ac358b6f
--- /dev/null
+++ b/core/src/main/java/io/questdb/griffin/SqlHints.java
@@ -0,0 +1,42 @@
+/*******************************************************************************
+ *     ___                  _   ____  ____
+ *    / _ \ _   _  ___  ___| |_|  _ \| __ )
+ *   | | | | | | |/ _ \/ __| __| | | |  _ \
+ *   | |_| | |_| |  __/\__ \ |_| |_| | |_) |
+ *    \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ *  Copyright (c) 2014-2019 Appsicle
+ *  Copyright (c) 2019-2024 QuestDB
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.griffin;
+
+import io.questdb.griffin.model.QueryModel;
+import io.questdb.std.Chars;
+import io.questdb.std.LowerCaseCharSequenceObjHashMap;
+import org.jetbrains.annotations.NotNull;
+
+public final class SqlHints {
+    public static final String ASOF_JOIN_BINARY_SEARCH_HINT = "use_asof_binary_search";
+    public static final char HINTS_PARAMS_DELIMITER = ' ';
+
+    public static boolean hasAsOfJoinBinarySearchHint(@NotNull QueryModel queryModel, CharSequence tableNameA, CharSequence tableNameB) {
+        LowerCaseCharSequenceObjHashMap<CharSequence> hints = queryModel.getHints();
+        CharSequence params = hints.get(SqlHints.ASOF_JOIN_BINARY_SEARCH_HINT);
+        return Chars.containsWordIgnoreCase(params, tableNameA, HINTS_PARAMS_DELIMITER) &&
+                Chars.containsWordIgnoreCase(params, tableNameB, HINTS_PARAMS_DELIMITER);
+    }
+}
diff --git a/core/src/main/java/io/questdb/griffin/SqlOptimiser.java b/core/src/main/java/io/questdb/griffin/SqlOptimiser.java
index f6a2ae5fabbb..5cbbb13f6d8c 100644
--- a/core/src/main/java/io/questdb/griffin/SqlOptimiser.java
+++ b/core/src/main/java/io/questdb/griffin/SqlOptimiser.java
@@ -108,7 +108,7 @@ public class SqlOptimiser implements Mutable {
     private static final int SAMPLE_BY_REWRITE_WRAP_CONVERT_TIME_ZONE = 4;
     private static final int SAMPLE_BY_REWRITE_WRAP_REMOVE_TIMESTAMP = 1;
     private static final IntHashSet flexColumnModelTypes = new IntHashSet();
-    // list of join types that don't support all optimisations (e.g. pushing table-specific predicates to both left and right table)
+    // list of join types that don't support all optimisations (e.g., pushing table-specific predicates to both left and right table)
     private static final IntHashSet joinBarriers;
     private static final CharSequenceIntHashMap joinOps = new CharSequenceIntHashMap();
     private static final boolean[] joinsRequiringTimestamp = {false, false, false, false, true, true, true};
@@ -351,7 +351,7 @@ private void addColumnToSelectModel(QueryModel model, IntList insertColumnIndexe
         tempColumns.addAll(model.getBottomUpColumns());
         model.clearColumnMapStructs();
 
-        // These are merged columns, the assumption is that the insetColumnIndexes are ordered.
+        // These are merged columns; the assumption is that the insetColumnIndexes are ordered.
         // This loop will fail miserably in indexes are unordered.
         int src1ColumnCount = tempColumns.size();
         int src2ColumnCount = insertColumnIndexes.size();
@@ -386,8 +386,8 @@ private void addColumnToTranslatingModel(
             final CharSequence refColumn = column.getAst().token;
             final int dot = Chars.indexOfUnquoted(refColumn, '.');
             validateColumnAndGetModelIndex(validatingModel, refColumn, dot, column.getAst().position);
-            // when we have only one model, e.g. this is not a join
-            // and there is table alias to lookup column
+            // when we have only one model, e.g. this is not a join,
+            // and there is a table alias to lookup column;
             // we will remove this alias as unneeded
             if (dot != -1 && validatingModel.getJoinModels().size() == 1) {
                 ExpressionNode base = column.getAst();
@@ -417,7 +417,7 @@ private QueryColumn addCursorFunctionAsCrossJoin(
     ) throws SqlException {
         QueryColumn qc = cursorModel.findBottomUpColumnByAst(node);
         if (qc == null) {
-            // we are about to add new column as a join model to the base model
+            // we are about to add a new column as a join model to the base model
             // the name of this column must not clash with any name of the base mode
             CharSequence baseAlias;
             if (alias != null) {
@@ -452,7 +452,7 @@ private QueryColumn addCursorFunctionAsCrossJoin(
             CharSequence translatingAlias;
             translatingAlias = createColumnAlias(baseAlias, translatingModel);
 
-            // add trackable expression to cursor model
+            // add trackable expression to a cursor model
             cursorModel.addBottomUpColumn(queryColumnPool.next().of(baseAlias, node));
 
             qc = queryColumnPool.next().of(translatingAlias, nextLiteral(baseAlias));
@@ -481,7 +481,7 @@ private QueryColumn addCursorFunctionAsCrossJoin(
                 throw SqlException.invalidColumn(node.position, "duplicate alias");
             }
 
-            // check if column is in inner virtual model as requested
+            // check if column is in an inner virtual model as requested
             qc = translatingModel.getAliasToColumnMap().get(al);
         }
         if (innerVirtualModel != null) {
@@ -534,21 +534,21 @@ private void addFunction(
         // Adds what intended to be a function (rather than a literal) to the
         // inner virtual model. It is possible that the function will have
         // the same alias as the existing table columns. We will "temporarily"
-        // rename the function alias, so it does not clash. And after that
+        // rename the function alias, so it does not clash. And after that,
         // the "innerColumn" will restore the alias specified by the user. This
         // alias comes on the `qc.getAlias()`.
 
         QueryColumn virtualColumn = ensureAliasUniqueness(innerVirtualModel, qc);
         innerVirtualModel.addBottomUpColumn(virtualColumn);
 
-        // we also create column that references this inner layer from outer layer,
+        // we also create a column that references this inner layer from outer layer,
         // for example when we have:
         // select a, b+c ...
         // it should translate to:
         // select a, x from (select a, b+c x from (select a,b,c ...))
         final QueryColumn innerColumn = nextColumn(qc.getAlias(), virtualColumn.getAlias());
 
-        // pull literals only into translating model
+        // pull literals only into a translating model
         emitLiterals(qc.getAst(), translatingModel, null, validatingModel, false);
         groupByModel.addBottomUpColumn(innerColumn);
         windowModel.addBottomUpColumn(innerColumn);
@@ -645,14 +645,14 @@ private void addTopDownColumn(@Transient ExpressionNode node, QueryModel model)
             final CharSequence columnName = node.token;
             final int dotIndex = Chars.indexOfUnquoted(columnName, '.');
             if (dotIndex == -1) {
-                // When there is no dot in column name it is still possible that column comes from
+                // When there is no dot in column name, it is still possible that column comes from
                 // one of the join models. What we need to do here is to assign column to that model
                 // which already have this column in alias map
                 addTopDownColumn(columnName, model);
             } else {
                 int modelIndex = model.getModelAliasIndex(node.token, 0, dotIndex);
                 if (modelIndex < 0) {
-                    // alias cannot be resolved, we will trust that the calling side will handle this
+                    // alias cannot be resolved; we will trust that the calling side will handle this
                     // in this context we do not have model that is able to resolve table alias
                     return;
                 }
@@ -702,7 +702,7 @@ private void addTopDownColumn0(@Transient ExpressionNode node, QueryModel model,
     }
 
     /**
-     * Adds filters derived from transitivity of equals operation, for example
+     * Adds filters derived from transitivity of equals operation, for example,
      * if there is filter:
      * <p>
      * a.x = b.x and b.x = 10
@@ -776,7 +776,7 @@ private void alignJoinClauses(QueryModel parent) {
      *
      * @param model         model to check
      * @param orderByAdvice advice
-     * @return all order by advice appears in model columns list
+     * @return all orders by advice appear in the model columns list
      */
     private boolean allAdviceIsForThisTable(QueryModel model, ObjList<ExpressionNode> orderByAdvice) {
         CharSequence alias;
@@ -804,7 +804,7 @@ private void analyseEquals(QueryModel parent, ExpressionNode node, boolean inner
         boolean canMovePredicate = joinBarriers.excludes(joinModel.getJoinType());
         int joinIndex = parent.getJoinModels().indexOfRef(joinModel);
 
-        //switch code below assumes expression are simple column references
+        //the switch code below assumes expression are simple column references
         if (literalCollector.functionCount > 0) {
             node.innerPredicate = innerPredicate;
             if (canMovePredicate) {
@@ -823,7 +823,7 @@ private void analyseEquals(QueryModel parent, ExpressionNode node, boolean inner
                 }
                 if (bSize == 1
                         && literalCollector.nullCount == 0
-                        // table must not be OUTER or ASOF joined
+                        // the table must not be OUTER or ASOF joined
                         && joinBarriers.excludes(parent.getJoinModels().get(literalCollectorBIndexes.get(0)).getJoinType())
                 ) {
                     // single table reference + constant
@@ -850,7 +850,7 @@ private void analyseEquals(QueryModel parent, ExpressionNode node, boolean inner
                         // single table reference
                         jc.slaveIndex = lhi;
                         if (canMovePredicate) {
-                            // we can't push anything into other left join
+                            // we can't push anything into another left join
                             if (jc.slaveIndex != joinIndex &&
                                     joinBarriers.contains(parent.getJoinModels().get(jc.slaveIndex).getJoinType())) {
                                 addPostJoinWhereClause(parent.getJoinModels().getQuick(jc.slaveIndex), node);
@@ -883,7 +883,7 @@ private void analyseEquals(QueryModel parent, ExpressionNode node, boolean inner
                     }
 
                     if (canMovePredicate || jc.slaveIndex == joinIndex) {
-                        //we can't push anything into other left join
+                        //we can't push anything into another left join
                         if (jc.slaveIndex != joinIndex && joinBarriers.contains(parent.getJoinModels().get(jc.slaveIndex).getJoinType())) {
                             addPostJoinWhereClause(parent.getJoinModels().getQuick(jc.slaveIndex), node);
                         } else {
@@ -963,7 +963,7 @@ private void assignFilters(QueryModel parent) throws SqlException {
         }
 
         IntList ordered = parent.getOrderedJoinModels();
-        // match table references to set of table in join order
+        // match table references to a set of table in join order
         for (int i = 0, n = ordered.size(); i < n; i++) {
             int index = ordered.getQuick(i);
             tablesSoFar.add(index);
@@ -1071,7 +1071,7 @@ private QueryModel bubbleUpOrderByAndLimitFromUnion(QueryModel model) throws Sql
     }
 
     // pushing predicates to sample by model is only allowed for sample by fill none align to calendar and expressions on non-timestamp columns
-    // pushing for other fill options or sample by first observation could alter result
+    // pushing for other fill options or sample by first observation could alter a result
     private boolean canPushToSampleBy(final QueryModel model, ObjList<CharSequence> expressionColumns) {
         ObjList<ExpressionNode> fill = model.getSampleByFill();
         int fillCount = fill.size();
@@ -1125,7 +1125,7 @@ private boolean checkForChildAggregates(ExpressionNode node) {
      * Checks whether the given advice is for one table only i.e. consistent table prefix.
      *
      * @param orderByAdvice the given advice
-     * @return whether prefix is consistent or not
+     * @return whether the prefix is consistent or not
      */
     private boolean checkForConsistentPrefix(ObjList<ExpressionNode> orderByAdvice) {
         CharSequence prefix = "";
@@ -1166,7 +1166,7 @@ private boolean checkIfTranslatingModelIsRedundant(
             boolean checkTranslatingModel,
             QueryModel translatingModel
     ) {
-        // check if translating model is redundant, e.g.
+        // check if the translating model is redundant, e.g.
         // that it neither chooses between tables nor renames columns
         boolean translationIsRedundant = (useInnerModel || useGroupByModel || useWindowModel) && !forceTranslatingModel;
         if (translationIsRedundant && checkTranslatingModel) {
@@ -1204,8 +1204,8 @@ private QueryColumn checkSimpleIntegerColumn(ExpressionNode column, QueryModel m
      * Choose models are required in 3 specific cases:
      * - Column duplicating.
      * - Column reordering.
-     * - Column hiding. Nested model might select more columns for purpose of using them for
-     * ordering. But ordered column is not visible to the user and hidden by the select model.
+     * - Column hiding. Nested model might select more columns for the purpose of using them for
+     * ordering. But the ordered column is not visible to the user and hidden by the select model.
      * - Multiplexing joined tables.
      * <p>
      * It is conceivable that the optimiser creates degenerate cases of "chose" models:
@@ -1254,7 +1254,7 @@ private void collectModelAlias(QueryModel parent, int modelIndex, QueryModel mod
         if (parent.addModelAliasIndex(alias, modelIndex)) {
             return;
         }
-        // If both models are the same, and we already added the alias to it via a subquery, it's not a duplicate
+        // If both models are the same, and we already added the alias to it via a subquery, it's not a duplicate,
         // it's only a duplicate if its being applied to a different model.
         if (parent != model) {
             throw SqlException.position(alias.position).put("Duplicate table or alias: ").put(alias.token);
@@ -1406,7 +1406,7 @@ private void createOrderHash(QueryModel model) {
         if (n > 0) {
             final IntList orderByDirection = model.getOrderByDirection();
             for (int i = 0; i < n; i++) {
-                // if a column appears multiple times in the ORDER BY clause then we use only the first occurrence.
+                // if a column appears multiple times in the ORDER BY clause, then we use only the first occurrence.
                 // why? consider this clause: ORDER BY A DESC, A ASC. In this case we want to order to be "A DESC" only.
                 // why? "A ASC" is a lower in priority, and it's applicable if and only if the items within first clause
                 // are equal. but if the items are already equal then there is no point in ordering by the same column again.
@@ -1431,7 +1431,7 @@ private void createOrderHash(QueryModel model) {
         }
     }
 
-    // add existing group by column to outer & distinct models
+    // add the existing group by column to outer and distinct models
     private boolean createSelectColumn(
             CharSequence alias,
             CharSequence columnName,
@@ -1448,7 +1448,7 @@ private boolean createSelectColumn(
         if (distinctModel != null) {
             if (sameAlias) {
                 distinctModel.addBottomUpColumn(outerColumn);
-            } else { // we've to use alias from outer model
+            } else { // we've to use alias from the outer model
                 QueryColumn distinctColumn = nextColumn(outerColumn.getAlias());
                 distinctColumn = ensureAliasUniqueness(distinctModel, distinctColumn);
                 distinctModel.addBottomUpColumn(distinctColumn);
@@ -1550,7 +1550,7 @@ private void createSelectColumn0(
             final QueryColumn translatedColumn = nextColumn(innerAlias, translatedColumnName);
             innerModel.addBottomUpColumn(translatedColumn);
 
-            // window model is used together with inner model
+            // window model is used together with the inner model
             final CharSequence windowAlias = createColumnAlias(innerAlias, windowModel);
             final QueryColumn windowColumn = nextColumn(windowAlias, innerAlias);
             windowModel.addBottomUpColumn(windowColumn);
@@ -1592,7 +1592,7 @@ private void createSelectColumnsForWildcard(
                 throw SqlException.$(qc.getAst().position, "invalid table alias");
             }
 
-            // we are targeting single table
+            // we are targeting a single table
             createSelectColumnsForWildcard0(
                     baseModel.getJoinModels().getQuick(index),
                     hasJoins,
@@ -1636,7 +1636,7 @@ private void createSelectColumnsForWildcard0(
         final ObjList<CharSequence> columnNames = srcModel.getWildcardColumnNames();
         for (int j = 0, z = columnNames.size(); j < z; j++) {
             CharSequence name = columnNames.getQuick(j);
-            // this is a check to see if column has to be added to wildcard list
+            // this is a check to see if a column has to be added to a wildcard list
             QueryColumn qc = srcModel.getAliasToColumnMap().get(name);
             if (qc.isIncludeIntoWildcard()) {
                 CharSequence token;
@@ -1668,7 +1668,7 @@ private void createSelectColumnsForWildcard0(
     @NotNull
     private QueryModel createWrapperModel(QueryModel model) {
         // these are early stages of model processing
-        // to create outer query, we will need a pair of models
+        // to create the outer query, we will need a pair of models
         QueryModel _model = queryModelPool.next();
         QueryModel _nested = queryModelPool.next();
 
@@ -1724,7 +1724,7 @@ private int doReorderTables(QueryModel parent, IntList ordered) {
 
             IntHashSet dependencies = m.getDependencies();
 
-            //for each node m with an edge e from n to m do
+            //for each node m with an-edge e from n to m do
             for (int i = 0, k = dependencies.size(); i < k; i++) {
                 int depIndex = dependencies.get(i);
                 JoinContext jc = joinModels.getQuick(depIndex).getContext();
@@ -1742,7 +1742,7 @@ private int doReorderTables(QueryModel parent, IntList ordered) {
             }
         }
 
-        // add pure crosses at end of ordered table list
+        // add pure crosses at the end of ordered table list
         for (int i = 0, n = tempCrossIndexes.size(); i < n; i++) {
             ordered.add(tempCrossIndexes.getQuick(i));
         }
@@ -1766,7 +1766,7 @@ private ExpressionNode doReplaceLiteral(
                 // column is not referenced by inner model
                 CharSequence alias = createColumnAlias(n.token, innerVirtualModel);
                 innerVirtualModel.addBottomUpColumn(queryColumnPool.next().of(alias, n));
-                // when alias is not the same as token, e.g. column aliases as "token" is already on the list
+                // when alias is not the same as token, e.g., column aliases as "token" is already on the list,
                 // we have to create a new expression node that uses this alias
                 if (alias != n.token) {
                     return nextLiteral(alias);
@@ -1791,7 +1791,7 @@ private ExpressionNode doReplaceLiteral0(
         int index = map.keyIndex(node.token);
         final CharSequence alias;
         if (index > -1) {
-            // there is a possibility that column references join table, but in a different way
+            // there is a possibility that column references join the table, but in a different way
             // for example. main column could be tab1.y and the "missing" one just "y"
             // which is the same thing.
             // To disambiguate this situation we need to go over all join tables and see if the
@@ -1827,7 +1827,7 @@ private ExpressionNode doReplaceLiteral0(
                 }
             }
 
-            // this is the first time we see this column and must create alias
+            // this is the first time we've seen this column and must create alias
             alias = createColumnAlias(node, translatingModel);
             QueryColumn column = queryColumnPool.next().of(alias, node);
             // add column to both models
@@ -1939,7 +1939,7 @@ private void emitColumnLiteralsTopDown(ObjList<QueryColumn> columns, QueryModel
         }
     }
 
-    // This method will create CROSS join models in the "baseModel" for all unique cursor
+    // This method will create CROSS-join models in the "baseModel" for all unique cursor
     // function it finds on the node. The "translatingModel" is used to ensure uniqueness
     private void emitCursors(
             @Transient ExpressionNode node,
@@ -1995,7 +1995,7 @@ private void emitCursors(
         }
     }
 
-    // warning: this method replaces literal with aliases (changes node)
+    // warning: this method replaces literal with aliases (changes the node)
     private void emitLiterals(
             @Transient ExpressionNode node,
             QueryModel translatingModel,
@@ -2117,7 +2117,7 @@ private void enumerateTableColumns(QueryModel model, SqlExecutionContext executi
         final ObjList<QueryModel> jm = model.getJoinModels();
 
         // we have plain tables and possibly joins
-        // deal with _this_ model first, it will always be the first element in join model list
+        // a deal with _this_ model first, it will always be the first element in the join model list
         final ExpressionNode tableNameExpr = model.getTableNameExpr();
         if (tableNameExpr != null || model.getSelectModelType() == QueryModel.SELECT_MODEL_SHOW) {
             if (model.getSelectModelType() == QueryModel.SELECT_MODEL_SHOW || (tableNameExpr != null && tableNameExpr.type == FUNCTION)) {
@@ -2152,7 +2152,7 @@ private void eraseColumnPrefixInWhereClauses(QueryModel model) throws SqlExcepti
             // join models can have "where" clause
             // although in context of SQL where is executed after joins, this model
             // always localises "where" to a single table and therefore "where" is
-            // applied before join. Please see post-join-where for filters that
+            // applied before join. Please see post-join-where for filters that are
             // executed in line with standard SQL behaviour.
 
             if (where != null) {
@@ -2309,7 +2309,7 @@ private ObjList<ExpressionNode> getOrderByAdvice(QueryModel model, int orderByMn
 
         int len = orderBy.size();
         if (len == 0) {
-            // propagate advice in case nested model can implement it efficiently (e.g. with backward scan)
+            // propagate advice in case nested model can implement it efficiently (e.g., with backward scan)
             if (orderByMnemonic == OrderByMnemonic.ORDER_BY_INVARIANT && model.getOrderByAdvice().size() > 0) {
                 orderBy = model.getOrderByAdvice();
                 len = orderBy.size();
@@ -2540,7 +2540,7 @@ private JoinContext mergeContexts(QueryModel parent, JoinContext a, JoinContext
         deletedContexts.clear();
         JoinContext r = contextPool.next();
         // check if we are merging a.x = b.x to a.y = b.y
-        // or a.x = b.x to a.x = b.y, e.g. one of columns in the same table
+        // or a.x = b.x to a.x = b.y, e.g., one of the columns in the same table
         for (int i = 0, n = b.aNames.size(); i < n; i++) {
             CharSequence ban = b.aNames.getQuick(i);
             int bai = b.aIndexes.getQuick(i);
@@ -2640,12 +2640,12 @@ private JoinContext moveClauses(QueryModel parent, JoinContext from, JoinContext
         result.slaveIndex = from.slaveIndex;
 
         for (int i = 0, n = from.aIndexes.size(); i < n; i++) {
-            // logically those clauses we move away from "from" context
+            // logically, those clauses we move away from "from" context
             // should no longer exist in "from", but instead of implementing
-            // "delete" function, which would be manipulating underlying array
-            // on every invocation, we copy retained clauses to new context,
+            // "delete" function, which would be manipulating an underlying array
+            // on every invocation; we copy retained clauses to new context,
             // which is "result".
-            // hence, whenever exists in "positions" we copy clause to "to"
+            // hence, whenever it exists in "positions" we copy clause to "to"
             // otherwise copy to "result"
             JoinContext t = p < m && i == positions.getQuick(p) ? to : result;
             int ai = from.aIndexes.getQuick(i);
@@ -2786,7 +2786,7 @@ private void moveWhereInsideSubQueries(QueryModel model) throws SqlException {
 
                     int distinctIndexes = tempList.size();
 
-                    // at this point we must not have constant conditions in where clause
+                    // at this point, we must not have constant conditions in where clause
                     // this could be either referencing constant of a sub-query
                     if (literalCollectorAIndexes.size() == 0) {
                         // keep condition with this model
@@ -2799,7 +2799,7 @@ private void moveWhereInsideSubQueries(QueryModel model) throws SqlException {
                         continue;
                     }
 
-                    // by now all where clause must reference single table only and all column references have to be valid
+                    // by now all where clause must reference single table only and all column references have to be valid,
                     // they would have been rewritten and validated as join analysis stage
                     final int tableIndex = literalCollectorAIndexes.get(0);
                     final QueryModel parent = model.getJoinModels().getQuick(tableIndex);
@@ -3427,6 +3427,24 @@ private void processJoinConditions(
         }
     }
 
+    private void propagateHintsTo(QueryModel targetModel, LowerCaseCharSequenceObjHashMap<CharSequence> hints) {
+        if (targetModel == null) {
+            return;
+        }
+
+        targetModel.copyHints(hints);
+        var h = targetModel.getHints();
+        propagateHintsTo(targetModel.getNestedModel(), h);
+
+        // propagate hints to join models
+        var joinModels = targetModel.getJoinModels();
+        for (int i = 1, n = joinModels.size(); i < n; i++) {
+            propagateHintsTo(joinModels.getQuick(i), h);
+        }
+        // propagate to union model
+        propagateHintsTo(targetModel.getUnionModel(), h);
+    }
+
     private void propagateTopDownColumns(QueryModel model, boolean allowColumnChange) {
         propagateTopDownColumns0(model, true, null, allowColumnChange);
     }
@@ -5365,7 +5383,7 @@ private void rewriteSampleByFromTo(QueryModel model) {
             // If WHERE present and already contains a timestamp clause,
             // add it anyway, as it will be ANDed with the existing clause narrowing down existing filtering.
 
-            ExpressionNode intervalClause = null;
+            ExpressionNode intervalClause;
             ExpressionNode timestamp = fromToModel.getTimestamp();
             QueryModel toAddWhereClause = fromToModel;
 
@@ -5382,7 +5400,7 @@ private void rewriteSampleByFromTo(QueryModel model) {
                 // prefix the timestamp column name only if the table is not dotted
                 // this is to handle cases where we use system tables, which are prefixed
                 // downstream code cannot handle `"sys.telemetry.wal".created`
-                // it will break in `where` optimisation, and later metadata lookups
+                // it will break in `where` optimisation and later metadata lookups
                 CharacterStoreEntry e = characterStore.newEntry();
                 if (Chars.indexOf(toAddWhereClause.getTableName(), '.') != -1) {
                     // Table name has . in the name, quote it
@@ -5477,7 +5495,7 @@ private QueryModel rewriteSelectClause(
                 QueryModel rewritten = rewriteSelectClause(nestedModel, flatModel, sqlExecutionContext, sqlParserCallback);
                 if (rewritten != nestedModel) {
                     m.setNestedModel(rewritten);
-                    // since we have rewritten nested model we also have to update column hash
+                    // since we have rewritten nested model, we also have to update column hash
                     m.copyColumnsFrom(rewritten, queryColumnPool, expressionNodePool);
                 }
             }
@@ -5491,7 +5509,7 @@ private QueryModel rewriteSelectClause(
             }
         }
 
-        // "model" is always first in its own list of join models
+        // "model" is always the first in its own list of join models
         return models.getQuick(0);
     }
 
@@ -5518,7 +5536,7 @@ private QueryModel rewriteSelectClause0(
         windowModel.setSelectModelType(QueryModel.SELECT_MODEL_WINDOW);
         final QueryModel translatingModel = queryModelPool.next();
         translatingModel.setSelectModelType(QueryModel.SELECT_MODEL_CHOOSE);
-        // this is dangling model, which isn't chained with any other
+        // this is a dangling model, which isn't chained with any other
         // we use it to ensure expression and alias uniqueness
         final QueryModel cursorModel = queryModelPool.next();
 
@@ -6106,12 +6124,14 @@ else if (!(isEffectivelyConstantExpression(node) && n > 1)) {
             translatingModel.moveLimitFrom(model);
             translatingModel.moveJoinAliasFrom(model);
             translatingModel.setSelectTranslation(true);
+            translatingModel.copyHints(model.getHints());
         }
 
         if (useInnerModel) {
             innerVirtualModel.setNestedModel(root);
             innerVirtualModel.moveLimitFrom(limitSource);
             innerVirtualModel.moveJoinAliasFrom(limitSource);
+            innerVirtualModel.copyHints(model.getHints());
 
             // Set limit hint if applicable.
             pushDownLimitAdvice(innerVirtualModel, root, useDistinctModel);
@@ -6124,12 +6144,14 @@ else if (!(isEffectivelyConstantExpression(node) && n > 1)) {
             windowModel.setNestedModel(root);
             windowModel.moveLimitFrom(limitSource);
             windowModel.moveJoinAliasFrom(limitSource);
+            windowModel.copyHints(model.getHints());
             root = windowModel;
             limitSource = windowModel;
         } else if (useGroupByModel) {
             groupByModel.setNestedModel(root);
             groupByModel.moveLimitFrom(limitSource);
             groupByModel.moveJoinAliasFrom(limitSource);
+            groupByModel.copyHints(model.getHints());
             root = groupByModel;
             limitSource = groupByModel;
         }
@@ -6138,18 +6160,21 @@ else if (!(isEffectivelyConstantExpression(node) && n > 1)) {
             outerVirtualModel.setNestedModel(root);
             outerVirtualModel.moveLimitFrom(limitSource);
             outerVirtualModel.moveJoinAliasFrom(limitSource);
+            outerVirtualModel.copyHints(model.getHints());
             root = outerVirtualModel;
         } else if (root != outerVirtualModel && root.getBottomUpColumns().size() < outerVirtualModel.getBottomUpColumns().size()) {
             outerVirtualModel.setNestedModel(root);
             outerVirtualModel.moveLimitFrom(limitSource);
             outerVirtualModel.moveJoinAliasFrom(limitSource);
             outerVirtualModel.setSelectModelType(outerVirtualIsSelectChoose ? QueryModel.SELECT_MODEL_CHOOSE : QueryModel.SELECT_MODEL_VIRTUAL);
+            outerVirtualModel.copyHints(model.getHints());
             root = outerVirtualModel;
         }
 
         if (useDistinctModel) {
             distinctModel.setNestedModel(root);
             distinctModel.moveLimitFrom(root);
+            distinctModel.copyHints(model.getHints());
             root = distinctModel;
         }
 
@@ -6649,6 +6674,7 @@ QueryModel optimise(
             enumerateTableColumns(rewrittenModel, sqlExecutionContext, sqlParserCallback);
             rewriteTopLevelLiteralsToFunctions(rewrittenModel);
             rewriteSampleByFromTo(rewrittenModel);
+            propagateHintsTo(rewrittenModel, rewrittenModel.getHints());
             rewrittenModel = rewriteDistinct(rewrittenModel);
             rewrittenModel = rewriteSampleBy(rewrittenModel, sqlExecutionContext);
             rewrittenModel = moveOrderByFunctionsIntoOuterSelect(rewrittenModel);
@@ -6676,7 +6702,7 @@ QueryModel optimise(
             authorizeColumnAccess(sqlExecutionContext, rewrittenModel);
             return rewrittenModel;
         } catch (Throwable th) {
-            // at this point models may have functions than need to be freed
+            // at this point, models may have functions than need to be freed
             Misc.freeObjListAndClear(tableFactoriesInFlight);
             throw th;
         }
diff --git a/core/src/main/java/io/questdb/griffin/SqlParser.java b/core/src/main/java/io/questdb/griffin/SqlParser.java
index f607118ce814..6ad3fc840f4e 100644
--- a/core/src/main/java/io/questdb/griffin/SqlParser.java
+++ b/core/src/main/java/io/questdb/griffin/SqlParser.java
@@ -2248,6 +2248,80 @@ private void parseFromTable(GenericLexer lexer, QueryModel model) throws SqlExce
         parseTableName(lexer, model);
     }
 
+    private void parseHints(GenericLexer lexer, QueryModel model) {
+        CharSequence hintToken = null;
+        boolean parsingParams = false;
+        CharSequence hintKey = null;
+        CharacterStoreEntry hintValuesEntry = null;
+        boolean error = false;
+        while ((hintToken = SqlUtil.fetchNextHintToken(lexer)) != null) {
+            if (error) {
+                // if in error state, just consume the rest of hints, but ignore them
+                // since in error state we cannot reliably parse them
+                continue;
+            }
+
+            if (Chars.equals(hintToken, '(')) {
+                if (parsingParams) {
+                    // hints cannot be nested
+                    error = true;
+                    continue;
+                }
+                if (hintKey == null) {
+                    // missing key
+                    error = true;
+                    continue;
+                }
+                parsingParams = true;
+                continue;
+            }
+
+            if (Chars.equals(hintToken, ')')) {
+                if (!parsingParams) {
+                    // unexpected closing parenthesis
+                    error = true;
+                    continue;
+                }
+                if (hintValuesEntry == null) {
+                    // store last parameter-less hint, e.g. KEY()
+                    model.addHint(hintKey, null);
+                } else {
+                    // ok, there are some parameters
+                    model.addHint(hintKey, hintValuesEntry.toImmutable());
+                    hintValuesEntry = null;
+                }
+                hintKey = null;
+                parsingParams = false;
+                continue;
+            }
+
+            if (parsingParams) {
+                if (hintValuesEntry == null) {
+                    // store first parameter
+                    hintValuesEntry = characterStore.newEntry();
+                } else {
+                    hintValuesEntry.put(SqlHints.HINTS_PARAMS_DELIMITER);
+                }
+                hintValuesEntry.put(GenericLexer.unquote(hintToken));
+                continue;
+            }
+
+            if (hintKey != null) {
+                // store previous parameter-less hint
+                model.addHint(hintKey, null);
+            }
+            CharacterStoreEntry entry = characterStore.newEntry();
+            entry.put(hintToken);
+            hintKey = entry.toImmutable();
+        }
+        if (!error && !parsingParams && hintKey != null) {
+            // store the last parameter-less hint
+            // why only when not parsingParams? dangling parsingParams indicates a syntax error and in this case
+            // we don't want to store the hint
+            model.addHint(hintKey, null);
+        }
+    }
+
     private void parseInVolume(GenericLexer lexer, CreateTableOperationBuilderImpl tableOpBuilder) throws SqlException {
         int volumeKwPos = lexer.getPosition();
         expectTok(lexer, "volume");
@@ -2572,7 +2646,16 @@ private ExecutionModel parseSelect(
     }
 
     private void parseSelectClause(GenericLexer lexer, QueryModel model, SqlParserCallback sqlParserCallback) throws SqlException {
-        CharSequence tok = tok(lexer, "[distinct] column");
+        int pos = lexer.getPosition();
+        CharSequence tok = SqlUtil.fetchNext(lexer, true);
+        if (tok == null || (subQueryMode && Chars.equals(tok, ')') && !overClauseMode)) {
+            throw SqlException.position(pos).put("[distinct] column expected");
+        }
+
+        if (Chars.equals(tok, "/*+")) {
+            parseHints(lexer, model);
+            tok = tok(lexer, "[distinct] column");
+        }
 
         ExpressionNode expr;
         if (isDistinctKeyword(tok)) {
@@ -2763,7 +2846,7 @@ private void parseSelectClause(GenericLexer lexer, QueryModel model, SqlParserCa
                                 } else if (isPrecedingKeyword(tok)) {
                                     throw SqlException.$(lexer.lastTokenPosition(), "integer expression expected");
                                 } else {
-                                    int pos = lexer.lastTokenPosition();
+                                    pos = lexer.lastTokenPosition();
                                     lexer.unparseLast();
                                     winCol.setRowsLoExpr(expectExpr(lexer, sqlParserCallback, model.getDecls()), pos);
                                     if (framingMode == WindowColumn.FRAMING_RANGE) {
@@ -2807,7 +2890,7 @@ private void parseSelectClause(GenericLexer lexer, QueryModel model, SqlParserCa
                                     } else if (isPrecedingKeyword(tok) || isFollowingKeyword(tok)) {
                                         throw SqlException.$(lexer.lastTokenPosition(), "integer expression expected");
                                     } else {
-                                        int pos = lexer.lastTokenPosition();
+                                        pos = lexer.lastTokenPosition();
                                         lexer.unparseLast();
                                         winCol.setRowsHiExpr(expectExpr(lexer, sqlParserCallback, model.getDecls()), pos);
                                         if (framingMode == WindowColumn.FRAMING_RANGE) {
@@ -2837,7 +2920,7 @@ private void parseSelectClause(GenericLexer lexer, QueryModel model, SqlParserCa
                             } else {
                                 // If you omit BETWEEN and specify only one end point, then QuestDB considers it the
                                 // start point, and the end point defaults to the current row.
-                                int pos = lexer.lastTokenPosition();
+                                pos = lexer.lastTokenPosition();
                                 if (isUnboundedPreceding(lexer, tok)) {
                                     winCol.setRowsLoKind(WindowColumn.PRECEDING, lexer.lastTokenPosition());
                                 } else if (isCurrentRow(lexer, tok)) {
diff --git a/core/src/main/java/io/questdb/griffin/SqlUtil.java b/core/src/main/java/io/questdb/griffin/SqlUtil.java
index a553ac3b5c12..23bc51af6522 100644
--- a/core/src/main/java/io/questdb/griffin/SqlUtil.java
+++ b/core/src/main/java/io/questdb/griffin/SqlUtil.java
@@ -203,6 +203,20 @@ public static long expectSeconds(CharSequence tok, int position) throws SqlExcep
      * @return with next valid token or null if end of input is reached .
      */
     public static CharSequence fetchNext(GenericLexer lexer) throws SqlException {
+        return fetchNext(lexer, false);
+    }
+
+    /**
+     * Fetches next non-whitespace token that's not part of single or multiline comment.
+     *
+     * @param lexer        The input lexer containing the token stream to process
+     * @param includeHints If true, hint block markers (/*+) are treated as valid tokens and returned;
+     *                     if false, hint blocks are treated as comments and skipped
+     * @return The next meaningful token as a CharSequence, or null if the end of input is reached
+     * @throws SqlException If a parsing error occurs while processing the token stream
+     * @see #fetchNextHintToken(GenericLexer) For handling tokens within hint blocks
+     */
+    public static CharSequence fetchNext(GenericLexer lexer, boolean includeHints) throws SqlException {
         int blockCount = 0;
         boolean lineComment = false;
         while (lexer.hasNext()) {
@@ -225,6 +239,11 @@ public static CharSequence fetchNext(GenericLexer lexer) throws SqlException {
                 continue;
             }
 
+            if (Chars.equals("/*+", cs) && (!includeHints || blockCount > 0)) {
+                blockCount++;
+                continue;
+            }
+
             if (Chars.equals("*/", cs) && blockCount > 0) {
                 blockCount--;
                 continue;
@@ -241,6 +260,79 @@ public static CharSequence fetchNext(GenericLexer lexer) throws SqlException {
         return null;
     }
 
+    /**
+     * Fetches the next non-whitespace, non-comment hint token from the lexer.
+     * <p>
+     * This method should only be called after entering a hint block. Specifically,
+     * a previous call to {@link #fetchNext(GenericLexer, boolean)} must have returned
+     * a hint start token (<code>/*+</code>) before this method can be used.
+     * <p>
+     * The method processes the input stream, skipping over any nested comments and whitespace,
+     * and returns the next meaningful hint token. This allows for clean parsing of hint
+     * content without manual handling of comments and formatting characters.
+     * <p>
+     * When the end of the hint block is reached, the method returns null, indicating
+     * no more hint tokens are available for processing.
+     * <p>
+     * If a hint contains unbalanced quotes, the method will NOT throw an exception, instead
+     * it will consume all tokens until the end of the hint block is reached and then return null indicating
+     * the end of the hint block.
+     *
+     * @param lexer The input lexer containing the token stream to process
+     * @return The next meaningful hint token, or null if the end of the hint block is reached
+     * @see #fetchNext(GenericLexer, boolean) For entering the hint block initially
+     */
+    public static CharSequence fetchNextHintToken(GenericLexer lexer) {
+        int blockCount = 0;
+        boolean lineComment = false;
+        boolean inError = false;
+        while (lexer.hasNext()) {
+            CharSequence cs = lexer.next();
+
+            if (lineComment) {
+                if (Chars.equals(cs, '\n') || Chars.equals(cs, '\r')) {
+                    lineComment = false;
+                }
+                continue;
+            }
+
+            if (Chars.equals("--", cs)) {
+                lineComment = true;
+                continue;
+            }
+
+            if (Chars.equals("/*", cs)) {
+                blockCount++;
+                continue;
+            }
+
+            if (Chars.equals("/*+", cs)) {
+                // nested hints are treated as regular comments
+                blockCount++;
+                continue;
+            }
+
+            // end of hints or a nested comment
+            if (Chars.equals("*/", cs)) {
+                if (blockCount > 0) {
+                    blockCount--;
+                    continue;
+                }
+                return null;
+            }
+
+            if (!inError && blockCount == 0 && GenericLexer.WHITESPACE.excludes(cs)) {
+                // unclosed quote check
+                if (cs.length() == 1 && cs.charAt(0) == '"') {
+                    inError = true;
+                } else {
+                    return cs;
+                }
+            }
+        }
+        return null;
+    }
+
     public static byte implicitCastAsByte(long value, int fromType) {
         if (value >= Byte.MIN_VALUE && value <= Byte.MAX_VALUE) {
             return (byte) value;
diff --git a/core/src/main/java/io/questdb/griffin/model/QueryModel.java b/core/src/main/java/io/questdb/griffin/model/QueryModel.java
index 6fbce73d7b94..1dcf1a807f99 100644
--- a/core/src/main/java/io/questdb/griffin/model/QueryModel.java
+++ b/core/src/main/java/io/questdb/griffin/model/QueryModel.java
@@ -116,6 +116,7 @@ public class QueryModel implements Mutable, ExecutionModel, AliasTranslator, Sin
     private final IntHashSet dependencies = new IntHashSet();
     private final ObjList<ExpressionNode> expressionModels = new ObjList<>();
     private final ObjList<ExpressionNode> groupBy = new ObjList<>();
+    private final LowerCaseCharSequenceObjHashMap<CharSequence> hintsMap = new LowerCaseCharSequenceObjHashMap<>();
     private final ObjList<ExpressionNode> joinColumns = new ObjList<>(4);
     private final ObjList<QueryModel> joinModels = new ObjList<>();
     private final ObjList<ExpressionNode> latestBy = new ObjList<>();
@@ -323,6 +324,10 @@ public void addGroupBy(ExpressionNode node) {
         groupBy.add(node);
     }
 
+    public void addHint(CharSequence key, CharSequence value) {
+        hintsMap.put(key, value);
+    }
+
     public void addJoinColumn(ExpressionNode node) {
         joinColumns.add(node);
     }
@@ -476,6 +481,7 @@ public void clear() {
         decls.clear();
         orderDescendingByDesignatedTimestampOnly = false;
         forceBackwardScan = false;
+        hintsMap.clear();
     }
 
     public void clearColumnMapStructs() {
@@ -565,6 +571,13 @@ public void copyDeclsFrom(LowerCaseCharSequenceObjHashMap<ExpressionNode> decls)
         }
     }
 
+    public void copyHints(LowerCaseCharSequenceObjHashMap<CharSequence> hints) {
+        // do not copy hints to self
+        if (hintsMap != hints) {
+            this.hintsMap.putAll(hints);
+        }
+    }
+
     public void copyOrderByAdvice(ObjList<ExpressionNode> orderByAdvice) {
         this.orderByAdvice.clear();
         this.orderByAdvice.addAll(orderByAdvice);
@@ -773,6 +786,11 @@ public ObjList<ExpressionNode> getGroupBy() {
         return groupBy;
     }
 
+    @NotNull
+    public LowerCaseCharSequenceObjHashMap<CharSequence> getHints() {
+        return hintsMap;
+    }
+
     public ObjList<ExpressionNode> getJoinColumns() {
         return joinColumns;
     }
@@ -1979,6 +1997,29 @@ private void toSink0(CharSink<?> sink, boolean joinSlave, boolean showOrderBy) {
             }
             unionModel.toSink0(sink, false, showOrderBy);
         }
+
+        if (hintsMap.size() > 0) {
+            sink.putAscii(" hints[");
+            boolean first = true;
+            for (int i = 0, n = hintsMap.getKeyCount(); i < n; i++) {
+                CharSequence hint = hintsMap.getKey(i);
+                if (hint == null) {
+                    continue;
+                }
+                if (!first) {
+                    sink.putAscii(", ");
+                }
+                sink.put(hint);
+                CharSequence params = hintsMap.valueAt(-i - 1);
+                if (params != null) {
+                    sink.putAscii("(");
+                    sink.put(params);
+                    sink.putAscii(")");
+                }
+                first = false;
+            }
+            sink.putAscii(']');
+        }
     }
 
     private void updateToSink(CharSink<?> sink) {
diff --git a/core/src/main/java/io/questdb/std/Chars.java b/core/src/main/java/io/questdb/std/Chars.java
index 0cab722f6075..e41af44c31b7 100644
--- a/core/src/main/java/io/questdb/std/Chars.java
+++ b/core/src/main/java/io/questdb/std/Chars.java
@@ -174,6 +174,34 @@ public static boolean containsLowerCase(@NotNull CharSequence sequence, @NotNull
         return indexOfLowerCase(sequence, 0, sequence.length(), termLC) != -1;
     }
 
+    public static boolean containsWordIgnoreCase(CharSequence seq, CharSequence term, char separator) {
+        if (Chars.isBlank(seq)) {
+            return false;
+        }
+        if (Chars.isBlank(term)) {
+            return false;
+        }
+
+        int seqLen = seq.length();
+        int i = Chars.indexOfIgnoreCase(seq, 0, seqLen, term);
+
+        if (i < 0) {
+            return false;
+        }
+
+        if (i > 0) {
+            if (seq.charAt(i - 1) != separator) {
+                return false;
+            }
+        }
+
+        int termLen = term.length();
+        if (i + termLen < seqLen) {
+            return seq.charAt(i + termLen) == separator;
+        }
+        return true;
+    }
+
     public static void copyStrChars(CharSequence value, int pos, int len, long address) {
         for (int i = 0; i < len; i++) {
             char c = value.charAt(i + pos);
@@ -679,6 +707,37 @@ public static int indexOfLowerCase(@NotNull CharSequence seq, int seqLo, int seq
         return -1;
     }
 
+    public static int indexOfIgnoreCase(@NotNull CharSequence seq, int seqLo, int seqHi, @NotNull CharSequence term) {
+        int termLen = term.length();
+        if (termLen == 0) {
+            return 0;
+        }
+
+        char first = Character.toLowerCase(term.charAt(0));
+        int max = seqHi - termLen;
+
+        for (int i = seqLo; i <= max; ++i) {
+            if (Character.toLowerCase(seq.charAt(i)) != first) {
+                do {
+                    ++i;
+                } while (i <= max && Character.toLowerCase(seq.charAt(i)) != first);
+            }
+
+            if (i <= max) {
+                int j = i + 1;
+                int end = j + termLen - 1;
+                for (int k = 1; j < end && Character.toLowerCase(seq.charAt(j)) == Character.toLowerCase(term.charAt(k)); ++k) {
+                    ++j;
+                }
+                if (j == end) {
+                    return i;
+                }
+            }
+        }
+
+        return -1;
+    }
+
     public static int indexOfUnquoted(@NotNull CharSequence seq, char ch) {
         return indexOfUnquoted(seq, ch, 0, seq.length(), 1);
     }
diff --git a/core/src/test/java/io/questdb/test/cairo/mv/MatViewTest.java b/core/src/test/java/io/questdb/test/cairo/mv/MatViewTest.java
index b1ee31477744..a3f8228ff19f 100644
--- a/core/src/test/java/io/questdb/test/cairo/mv/MatViewTest.java
+++ b/core/src/test/java/io/questdb/test/cairo/mv/MatViewTest.java
@@ -248,6 +248,83 @@ public void testAlterSymbolCapacityInvalidStatement() throws Exception {
         });
     }
 
+    @Test
+    public void testAsOfJoinBinarySearchHintInMatView() throws Exception {
+        assertMemoryLeak(() -> {
+            execute("create table trades as (\n" +
+                    "  select \n" +
+                    "    rnd_double() price,\n" +
+                    "    rnd_double() volume,\n" +
+                    "    ('2025'::timestamp + x * 200_000_000L + rnd_int(0, 10_000, 0))::timestamp as ts,\n" +
+                    "  from long_sequence(5_000)\n" +
+                    ") timestamp(ts) partition by day wal;");
+
+            execute("create table prices as (\n" +
+                    "  select \n" +
+                    "    rnd_double() bid,\n" +
+                    "    rnd_double() ask,\n" +
+                    "    rnd_boolean() valid,\n" +
+                    "    ('2025'::timestamp + x * 1_000_000L + rnd_int(0, 10_000, 0))::timestamp as ts,\n" +
+                    "  from long_sequence(1_000_000)\n" +
+                    ") timestamp(ts) partition by day wal;\n");
+
+            String mvWithoutHint = "create materialized view daily_summary \n" +
+                    "WITH BASE trades\n" +
+                    "as (\n" +
+                    "select trades.ts, count(*), sum(volume), min(price), max(price), avg(price)\n" +
+                    "FROM trades\n" +
+                    "asof join (select * from prices where valid) prices\n" +
+                    "sample by 1d\n" +
+                    ");";
+
+            String mvWithHint = "create materialized view daily_summary \n" +
+                    "WITH BASE trades\n" +
+                    "as (\n" +
+                    "select /*+ USE_ASOF_BINARY_SEARCH(trades prices) */ trades.ts, count(*), sum(volume), min(price), max(price), avg(price)\n" +
+                    "FROM trades\n" +
+                    "asof join (select * from prices where valid) prices\n" +
+                    "sample by 1d\n" +
+                    ");";
+
+            // sanity test: without the hint it do not use binary search
+            sink.clear();
+            printSql("EXPLAIN " + mvWithoutHint);
+            TestUtils.assertContains(sink, "AsOf Join");
+            TestUtils.assertNotContains(sink, "Fast Scan");
+
+            // but it does with the hint
+            sink.clear();
+            printSql("EXPLAIN " + mvWithHint);
+            TestUtils.assertContains(sink, "Filtered AsOf Join Fast Scan");
+
+            // ok, now the real data: first try the view without the hint
+            execute(mvWithoutHint);
+            drainQueues();
+            String expectedView = "ts\tcount\tsum\tmin\tmax\tavg\n" +
+                    "2025-01-01T00:00:00.000000Z\t431\t215.12906540853268\t0.0031075670450616544\t0.9975907992178104\t0.4923297830071461\n" +
+                    "2025-01-02T00:00:00.000000Z\t432\t214.8933638390628\t0.0027013057617086833\t0.9997998069306392\t0.5363814932706943\n" +
+                    "2025-01-03T00:00:00.000000Z\t432\t211.63403995544482\t0.0014510055926236776\t0.9979936641680203\t0.4900138748185357\n" +
+                    "2025-01-04T00:00:00.000000Z\t432\t225.1870697913935\t0.0026339327135822543\t0.9996217482017493\t0.49406088823120226\n" +
+                    "2025-01-05T00:00:00.000000Z\t432\t213.8124549264717\t0.00985149958244913\t0.9981734770138071\t0.4728684440748092\n" +
+                    "2025-01-06T00:00:00.000000Z\t432\t214.8994847762188\t0.0010433040681515626\t0.9998120012952196\t0.48758818235506823\n" +
+                    "2025-01-07T00:00:00.000000Z\t432\t220.0881500794553\t0.0014542249844708977\t0.9973956570924076\t0.5131734923704387\n" +
+                    "2025-01-08T00:00:00.000000Z\t432\t218.41372811829154\t8.166095924849737E-4\t0.9976953158075262\t0.5276052830143888\n" +
+                    "2025-01-09T00:00:00.000000Z\t432\t220.10482943202246\t0.0011023415061862663\t0.9974983068581821\t0.493060539742248\n" +
+                    "2025-01-10T00:00:00.000000Z\t432\t208.43848337612906\t0.0028067126112681917\t0.9976283386812487\t0.5136095078793146\n" +
+                    "2025-01-11T00:00:00.000000Z\t432\t213.02005186038846\t8.598501058093566E-4\t0.999708216046598\t0.5040670959429089\n" +
+                    "2025-01-12T00:00:00.000000Z\t249\t119.80938485754517\t0.007906045439897036\t0.9962991313334122\t0.4923923393746041\n";
+            assertQuery(expectedView, "SELECT * FROM daily_summary", "ts", true, true);
+
+            // now, recreate the view with hint
+            execute("drop materialized view daily_summary");
+            execute(mvWithHint);
+            drainQueues();
+
+            // it must result in the same data
+            assertQuery(expectedView, "SELECT * FROM daily_summary", "ts", true, true);
+        });
+    }
+
     @Test
     public void testBaseTableInvalidateOnAttachPartition() throws Exception {
         final String partition = "2024-01-01";
diff --git a/core/src/test/java/io/questdb/test/griffin/AsOfJoinFuzzTest.java b/core/src/test/java/io/questdb/test/griffin/AsOfJoinFuzzTest.java
index 37e3f920de80..cc29758b31ea 100644
--- a/core/src/test/java/io/questdb/test/griffin/AsOfJoinFuzzTest.java
+++ b/core/src/test/java/io/questdb/test/griffin/AsOfJoinFuzzTest.java
@@ -88,7 +88,9 @@ private void assertResultSetsMatch0(Rnd rnd) throws Exception {
                         ", exerciseIntervals=" + exerciseIntervals +
                         ", limitType=" + limitType +
                         ", exerciseFilters=" + exerciseFilters +
-                        ", projectionType=" + projectionType, e);
+                        ", projectionType=" + projectionType +
+                        ", applyOuterProjection = " + applyOuterProjection,
+                        e);
             }
         }
     }
@@ -176,7 +178,9 @@ private void assertResultSetsMatch0(JoinType joinType, boolean exerciseIntervals
             char mainProjectionSuffix = projectionType == ProjectionType.RENAME_COLUMN ? '2' : ' ';
             outerProjection = "t1.ts, t2.i" + mainProjectionSuffix;
         }
-        String query = "select " + outerProjection + " from " + "t1" + join + " JOIN " + "(select " + projection + " from t2 " + filter + ") t2" + onSuffix;
+
+        // we can always hint to use BINARY_SEARCH, it's ignored in cases where it doesn't apply
+        String query = "select /*+ USE_ASOF_BINARY_SEARCH(t1 t2) */ " + outerProjection + " from " + "t1" + join + " JOIN " + "(select " + projection + " from t2 " + filter + ") t2" + onSuffix;
         int limit;
         switch (limitType) {
             case POSITIVE_LIMIT:
diff --git a/core/src/test/java/io/questdb/test/griffin/AsOfJoinTest.java b/core/src/test/java/io/questdb/test/griffin/AsOfJoinTest.java
index 298b3ea9dabf..54744f56fba0 100644
--- a/core/src/test/java/io/questdb/test/griffin/AsOfJoinTest.java
+++ b/core/src/test/java/io/questdb/test/griffin/AsOfJoinTest.java
@@ -30,6 +30,7 @@
 import io.questdb.cairo.sql.RecordCursorFactory;
 import io.questdb.cairo.sql.RecordMetadata;
 import io.questdb.griffin.SqlCompiler;
+import io.questdb.jit.JitUtil;
 import io.questdb.std.str.StringSink;
 import io.questdb.test.AbstractCairoTest;
 import io.questdb.test.tools.TestUtils;
@@ -81,6 +82,91 @@ public void testAsOfJoinAliasDuplication() throws Exception {
         });
     }
 
+    @Test
+    public void testAsOfJoinBinarySearchHint() throws Exception {
+        assertMemoryLeak(() -> {
+            execute("create table orders as (\n" +
+                    "  select \n" +
+                    "    concat('sym_', rnd_int(0, 10, 0))::symbol as order_symbol,\n" +
+                    "    rnd_double() price,\n" +
+                    "    rnd_double() volume,\n" +
+                    "    ('2025'::timestamp + x * 200_000_000L + rnd_int(0, 10_000, 0))::timestamp as ts,\n" +
+                    "  from long_sequence(5)\n" +
+                    ") timestamp(ts) partition by day;\n");
+
+            execute("create table market_data as (\n" +
+                    "  select \n" +
+                    "    concat('sym_', rnd_int(0, 10, 0))::symbol as market_data_symbol,\n" +
+                    "    rnd_double() bid,\n" +
+                    "    rnd_double() ask,\n" +
+                    "    ('2025'::timestamp + x * 100_000L + rnd_int(0, 10_000, 0))::timestamp as ts,\n" +
+                    "  from long_sequence(10_000)\n" +
+                    ") timestamp(ts) partition by day;");
+
+            String queryWithoutHint = "select * from (\n" +
+                    "  select orders.ts, bid, md.market_data_symbol, orders.order_symbol, md.md_ts as order_ts, price from oRdERS\n" +
+                    "  asof join (\n" +
+                    "    select ts as md_ts, market_Data_symbol, bid from market_data\n" +
+                    "    where market_data_symbol = 'sym_1' \n" +
+                    "  ) MD  \n" +
+                    "  where orders.ts > '2025-01-01T00:00:00.000000000Z' \n" +
+                    "  and bid > price\n" +
+                    ");";
+
+            // the same query with hint
+            String queryWithHint = "select /*+ use_asof_binary_search(orders md) */ * from (\n" +
+                    "  select orders.ts, bid, md.market_data_symbol, orders.order_symbol, md.md_ts as order_ts, price from oRdERS\n" +
+                    "  asof join (\n" +
+                    "    select ts as md_ts, market_Data_symbol, bid from market_data\n" +
+                    "    where market_data_symbol = 'sym_1' \n" +
+                    "  ) MD  \n" +
+                    "  where orders.ts > '2025-01-01T00:00:00.000000000Z' \n" +
+                    "  and bid > price\n" +
+                    ");";
+
+            // plan without the hint should not use the FAST ASOF
+            assertQuery("QUERY PLAN\n" +
+                            "SelectedRecord\n" +
+                            "    Filter filter: oRdERS.price<MD.bid\n" +
+                            "        AsOf Join\n" +
+                            "            PageFrame\n" +
+                            "                Row forward scan\n" +
+                            "                Interval forward scan on: orders\n" +
+                            "                  intervals: [(\"2025-01-01T00:00:00.000001Z\",\"MAX\")]\n" +
+                            "            SelectedRecord\n" +
+                            "                Async " + (JitUtil.isJitSupported() ? "JIT " : "") + "Filter workers: 1\n" +
+                            "                  filter: market_Data_symbol='sym_1'\n" +
+                            "                    PageFrame\n" +
+                            "                        Row forward scan\n" +
+                            "                        Frame forward scan on: market_data\n",
+                    "EXPLAIN " + queryWithoutHint, null, false, true);
+
+            // with hint it generates a plan with the fast asof join
+            assertQuery("QUERY PLAN\n" +
+                            "SelectedRecord\n" +
+                            "    Filter filter: oRdERS.price<MD.bid\n" +
+                            "        Filtered AsOf Join Fast Scan\n" +
+                            "          filter: oRdERS.order_symbol='sym_1'\n" +
+                            "            PageFrame\n" +
+                            "                Row forward scan\n" +
+                            "                Interval forward scan on: orders\n" +
+                            "                  intervals: [(\"2025-01-01T00:00:00.000001Z\",\"MAX\")]\n" +
+                            "            PageFrame\n" +
+                            "                Row forward scan\n" +
+                            "                Frame forward scan on: market_data\n",
+                    "EXPLAIN " + queryWithHint, null, false, true);
+
+            // both queries must return the same result
+            String expectedResult = "ts\tbid\tmarket_data_symbol\torder_symbol\torder_ts\tprice\n" +
+                    "2025-01-01T00:03:20.003570Z\t0.18646912884414946\tsym_1\tsym_4\t2025-01-01T00:03:19.407091Z\t0.08486964232560668\n" +
+                    "2025-01-01T00:06:40.006304Z\t0.9130994629783138\tsym_1\tsym_2\t2025-01-01T00:06:37.303610Z\t0.8423410920883345\n" +
+                    "2025-01-01T00:13:20.002056Z\t0.24872951622414008\tsym_1\tsym_4\t2025-01-01T00:13:19.909382Z\t0.0367581207471136\n" +
+                    "2025-01-01T00:16:40.009947Z\t0.5071618579762882\tsym_1\tsym_6\t2025-01-01T00:16:39.800653Z\t0.3100545983862456\n";
+            assertQuery(expectedResult, queryWithHint, "ts", false, false);
+            assertQuery(expectedResult, queryWithoutHint, "ts", false, false);
+        });
+    }
+
     @Test
     public void testAsOfJoinCombinedWithInnerJoin() throws Exception {
         assertMemoryLeak(() -> {
diff --git a/core/src/test/java/io/questdb/test/griffin/SqlHintsTest.java b/core/src/test/java/io/questdb/test/griffin/SqlHintsTest.java
new file mode 100644
index 000000000000..616e3a579d17
--- /dev/null
+++ b/core/src/test/java/io/questdb/test/griffin/SqlHintsTest.java
@@ -0,0 +1,57 @@
+/*******************************************************************************
+ *     ___                  _   ____  ____
+ *    / _ \ _   _  ___  ___| |_|  _ \| __ )
+ *   | | | | | | |/ _ \/ __| __| | | |  _ \
+ *   | |_| | |_| |  __/\__ \ |_| |_| | |_) |
+ *    \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ *  Copyright (c) 2014-2019 Appsicle
+ *  Copyright (c) 2019-2024 QuestDB
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.test.griffin;
+
+import io.questdb.griffin.SqlHints;
+import io.questdb.griffin.model.QueryModel;
+import io.questdb.test.AbstractTest;
+import io.questdb.test.tools.TestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class SqlHintsTest extends AbstractTest {
+
+    @Test
+    public void testAsOfJoinBinarySearchHint() throws Exception {
+        TestUtils.assertMemoryLeak(() -> {
+            QueryModel model = new QueryModel.QueryModelFactory().newInstance();
+            Assert.assertFalse(SqlHints.hasAsOfJoinBinarySearchHint(model, "tableA", "tableB"));
+
+            model.addHint(SqlHints.ASOF_JOIN_BINARY_SEARCH_HINT, "tableA tableB");
+            Assert.assertTrue(SqlHints.hasAsOfJoinBinarySearchHint(model, "tableA", "tableB"));
+
+            // case insensitive
+            Assert.assertTrue(SqlHints.hasAsOfJoinBinarySearchHint(model, "tablea", "tableb"));
+            Assert.assertTrue(SqlHints.hasAsOfJoinBinarySearchHint(model, "TABLEA", "TABLEB"));
+
+            // different order
+            Assert.assertTrue(SqlHints.hasAsOfJoinBinarySearchHint(model, "tableB", "tableA"));
+            Assert.assertTrue(SqlHints.hasAsOfJoinBinarySearchHint(model, "TABLEB", "TABLEA"));
+
+            model.clear();
+            Assert.assertFalse(SqlHints.hasAsOfJoinBinarySearchHint(model, "tableA", "tableB"));
+        });
+    }
+}
diff --git a/core/src/test/java/io/questdb/test/griffin/SqlParserTest.java b/core/src/test/java/io/questdb/test/griffin/SqlParserTest.java
index dcb5a316bf5f..039cce1f53b6 100644
--- a/core/src/test/java/io/questdb/test/griffin/SqlParserTest.java
+++ b/core/src/test/java/io/questdb/test/griffin/SqlParserTest.java
@@ -4533,6 +4533,236 @@ public void testGroupByWithSubQueryLimit() throws SqlException {
         );
     }
 
+    @Test
+    public void testHints() throws Exception {
+        // simple smoke test
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NEW_HINT(a b), NO_INDEX(a), NO_PARAM_HINT]) hints[NEW_HINT(a b), NO_INDEX(a), NO_PARAM_HINT]",
+                "select /*+ NO_INDEX(a) NEW_HINT(a b) NO_PARAM_HINT*/ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // empty hints
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1)",
+                "select /*+ */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // hint nested inside outer comment is ignored
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1)",
+                "select /* /*+ NO_INDEX(a) */ */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // hint nested in a line comment is ignored
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1)",
+                "select -- /*+ NO_INDEX(a) */ \n a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // hints nested inside another hint are ignored
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a)]) hints[NO_INDEX(a)]",
+                "select /*+ NO_INDEX(a) /*+ IGNORED */ */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // comment nested inside hint is ignored
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a), AFTER]) hints[NO_INDEX(a), AFTER]",
+                "select /*+ NO_INDEX(a) /* THIS IS JUST A REGULAR COMMENT */ AFTER */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // line comment inside hints are honoured
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a)]) hints[NO_INDEX(a)]",
+                "select /*+ NO_INDEX(a) -- IGNORED \n */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // bad hint syntax - missing closing bracket
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a)]) hints[NO_INDEX(a)]",
+                "select /*+ NO_INDEX(a) SKIPPED(  */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // bad hint syntax - missing opening brackets
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a)]) hints[NO_INDEX(a)]",
+                "select /*+ NO_INDEX(a) SKIPPED)  */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // bad hint syntax - missing hint key
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a)]) hints[NO_INDEX(a)]",
+                "select /*+ NO_INDEX(a) (param)  */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // nested params
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a)]) hints[NO_INDEX(a)]",
+                "select /*+ NO_INDEX(a) SKIPPED(a (b))  */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // empty params
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX]) hints[NO_INDEX]",
+                "select /*+ NO_INDEX()  */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // consecutive parameter-free hints
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX, MAKE_IT_FAST]) hints[NO_INDEX, MAKE_IT_FAST]",
+                "select /*+ NO_INDEX MAKE_IT_FAST  */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // quoted hint name is NOT unquoted!
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[\"NO_INDEX\"]) hints[\"NO_INDEX\"]",
+                "select /*+ \"NO_INDEX\"  */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // quoted param - params are unquoted!
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a)]) hints[NO_INDEX(a)]",
+                "select /*+ NO_INDEX(\"a\")  */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // dangling quotes in parameter less hint name - we still parse the hint which is right prior to the dangling quote
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NOT_SKIPPED, NO_INDEX(a)]) hints[NOT_SKIPPED, NO_INDEX(a)]",
+                "select /*+ NO_INDEX(a) NOT_SKIPPED\" */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // dangling quote in parameter 1 - right after the opening parenthesis
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a)]) hints[NO_INDEX(a)]",
+                "select /*+ NO_INDEX(a) SKIPPED(\"foo) */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // dangling quote in parameter
+        assertQuery(
+                "select-choose a, b, c from (select [a, b, c] from xyz where a = 1 hints[NO_INDEX(a)]) hints[NO_INDEX(a)]",
+                "select /*+ NO_INDEX(a) SKIPPED(foo\") */ a,b,c from xyz where a = 1",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // model with joins
+        assertQuery(
+                "select-choose t.timestamp timestamp, t.tag tag, q.timestamp timestamp1 from (select [timestamp, tag] from trades t timestamp (timestamp) asof join select [timestamp] from quotes q timestamp (timestamp) hints[HINT] where tag = null hints[HINT]) t hints[HINT]",
+                "select /*+ HINT*/ * from trades t ASOF JOIN quotes q WHERE tag = null",
+                modelOf("trades").timestamp().col("tag", ColumnType.SYMBOL),
+                modelOf("quotes").timestamp()
+        );
+
+        // model with sample by - simple
+        assertQuery(
+                "select-group-by timestamp_floor('1d',timestamp,null,'00:00',null) timestamp, count() count from (select [timestamp] from trades timestamp (timestamp) stride 1d hints[HINT]) order by timestamp hints[HINT]",
+                "select /*+ HINT*/ timestamp, count(*) from trades sample by 1d",
+                modelOf("trades").timestamp().col("tag", ColumnType.SYMBOL)
+        );
+
+        // model with sample by - with projection
+        // todo: hints currently do not appear in the outer SELECT-CHOOSE, consider adding them?
+        assertQuery(
+                "select-choose tag, count from (select-group-by [tag, count() count, timestamp_floor('1d',timestamp,null,'00:00',null) timestamp] tag, count() count, timestamp_floor('1d',timestamp,null,'00:00',null) timestamp from (select [tag, timestamp] from trades timestamp (timestamp) stride 1d hints[HINT]) order by timestamp hints[HINT])",
+                "select /*+ HINT*/ tag, count(*) from trades sample by 1d",
+                modelOf("trades").timestamp().col("tag", ColumnType.SYMBOL)
+        );
+
+        // model with union
+        assertQuery(
+                "select-choose [a, b, c] a, b, c from (select [a, b, c] from xyz where a = 1 hints[HINT_A]) union select-choose [a, b, c] a, b, c from (select [a, b, c] from xyz where a = 2 hints[HINT_A, HINT_B]) hints[HINT_A, HINT_B] hints[HINT_A]",
+                "select /*+ HINT_A*/ a,b,c from xyz where a = 1 union select /*+ HINT_B*/ a,b,c from xyz where a = 2",
+                modelOf("xyz")
+                        .col("a", ColumnType.SYMBOL)
+                        .col("b", ColumnType.INT)
+                        .col("c", ColumnType.INT)
+        );
+
+        // ending with unclosed hint - it reports missing columns instead of missing closing comment
+        // this behaviour is consistent with a regular block comments unclosed, except for position
+        assertSyntaxError(
+                "select /*+ HINT_A",
+                17,
+                "[distinct] column expected"
+        );
+        // for a comparison: here the same error with regular (non-hint) comments
+        assertSyntaxError(
+                "select /* HINT_A",
+                7,
+                "[distinct] column expected"
+        );
+    }
+
     @Test
     public void testInnerJoin() throws Exception {
         assertQuery(
diff --git a/core/src/test/java/io/questdb/test/std/str/CharsTest.java b/core/src/test/java/io/questdb/test/std/str/CharsTest.java
index e229ef0751c5..bb992b577157 100644
--- a/core/src/test/java/io/questdb/test/std/str/CharsTest.java
+++ b/core/src/test/java/io/questdb/test/std/str/CharsTest.java
@@ -280,6 +280,77 @@ public void testBase64UrlEncode() {
         Assert.assertArrayEquals(bytes, decoded);
     }
 
+    @Test
+    public void testContainsWordIgnoreCase() {
+        // --- Positive Cases ---
+        // Middle
+        Assert.assertTrue(Chars.containsWordIgnoreCase("a b c", "b", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("alpha beta gamma", "beta", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("a,b,c", "b", ','));
+        // Start
+        Assert.assertTrue(Chars.containsWordIgnoreCase("b c d", "b", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("beta gamma", "beta", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("b,c,d", "b", ','));
+        // End
+        Assert.assertTrue(Chars.containsWordIgnoreCase("a b c", "c", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("alpha beta", "beta", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("a,b,c", "c", ','));
+        // Single word sequence
+        Assert.assertTrue(Chars.containsWordIgnoreCase("word", "word", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("word", "word", ','));
+        // Multiple occurrences
+        Assert.assertTrue(Chars.containsWordIgnoreCase("a b a", "a", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("beta alpha beta", "beta", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("a b,c d", "b,c", ' '));
+
+        // --- Negative Cases ---
+        // Term not present
+        Assert.assertFalse(Chars.containsWordIgnoreCase("a b c", "d", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase("alpha beta", "gamma", ' '));
+        // Term is substring (start)
+        Assert.assertFalse(Chars.containsWordIgnoreCase("abc d", "ab", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase("alphabet soup", "alpha", ' '));
+        // Term is substring (middle) - not preceded by separator
+        Assert.assertFalse(Chars.containsWordIgnoreCase("xabc d", "abc", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase("alphabet soup", "lphabe", ' '));
+        // Term is substring (middle) - not followed by separator
+        Assert.assertFalse(Chars.containsWordIgnoreCase("a bcd", "bc", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase("alpha beta", "bet", ' '));
+        // Term is substring (end)
+        Assert.assertFalse(Chars.containsWordIgnoreCase("the alphabet", "bet", ' '));
+        // Incorrect separator used in check
+        Assert.assertFalse(Chars.containsWordIgnoreCase("a,b,c", "b", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase("a b c", "b", ','));
+        // Term matches but wrong separator before
+        Assert.assertFalse(Chars.containsWordIgnoreCase("a,b c", "b", ' '));
+        // Term matches but wrong separator after
+        Assert.assertFalse(Chars.containsWordIgnoreCase("a b,c", "b", ' '));
+        // Term contains separator char, but boundaries don't match separator
+        Assert.assertFalse(Chars.containsWordIgnoreCase("a b,c d", "b,c", ',')); // Space before/after != ','
+
+        // --- Edge Cases ---
+        // Null inputs
+        Assert.assertFalse(Chars.containsWordIgnoreCase(null, "a", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase("a b c", null, ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase(null, null, ' '));
+        // Empty inputs (Assuming empty term is not a word)
+        Assert.assertFalse(Chars.containsWordIgnoreCase("", "a", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase("a b c", "", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase("", "", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase(" ", "", ' '));
+        // Sequence equals term
+        Assert.assertTrue(Chars.containsWordIgnoreCase("abc", "abc", ' '));
+        Assert.assertTrue(Chars.containsWordIgnoreCase("abc", "abc", ','));
+        // Sequence contains only separators
+        Assert.assertFalse(Chars.containsWordIgnoreCase("   ", "a", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase(",,,", "a", ','));
+        // Using StringBuilder (different CharSequence type)
+        Assert.assertTrue(Chars.containsWordIgnoreCase(new StringBuilder("a b c"), "b", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase(new StringBuilder("abc d"), "ab", ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase(new StringBuilder("a b c"), null, ' '));
+        Assert.assertFalse(Chars.containsWordIgnoreCase(new StringBuilder("a b c"), "", ' '));
+    }
+
     @Test
     public void testEmptyString() {
         TestUtils.assertEquals("", extractor.of(Utf8String.EMPTY));
@@ -329,6 +400,32 @@ public void testIndexOfLowerCase() {
         Assert.assertEquals(-1, Chars.indexOfLowerCase("", 0, 0, "y"));
     }
 
+    @Test
+    public void testIndexOfIgnoreCase() {
+        Assert.assertEquals(4, Chars.indexOfIgnoreCase("foo bar baz", 0, 11, "bar"));
+        Assert.assertEquals(4, Chars.indexOfIgnoreCase("foo bar baz", 0, 11, "BAR"));
+
+        Assert.assertEquals(4, Chars.indexOfIgnoreCase("FOO BAR BAZ", 0, 11, "bar"));
+        Assert.assertEquals(4, Chars.indexOfIgnoreCase("FOO BAR BAZ", 0, 11, "BAR"));
+
+        Assert.assertEquals(4, Chars.indexOfIgnoreCase("foo bar baz", 0, 11, "ba"));
+        Assert.assertEquals(4, Chars.indexOfIgnoreCase("foo bar baz", 0, 11, "BA"));
+
+        Assert.assertEquals(8, Chars.indexOfIgnoreCase("foo BAr BAz", 6, 11, "ba"));
+        Assert.assertEquals(8, Chars.indexOfIgnoreCase("foo BAr BAz", 6, 11, "BA"));
+
+        Assert.assertEquals(1, Chars.indexOfIgnoreCase("foo bar baz", 0, 7, "oo"));
+        Assert.assertEquals(1, Chars.indexOfIgnoreCase("foo bar baz", 0, 7, "OO"));
+
+        Assert.assertEquals(0, Chars.indexOfIgnoreCase("foo bar baz", 2, 4, ""));
+        Assert.assertEquals(-1, Chars.indexOfIgnoreCase("foo bar baz", 2, 4, "y"));
+        Assert.assertEquals(-1, Chars.indexOfIgnoreCase("foo bar baz", 2, 4, "Y"));
+        Assert.assertEquals(-1, Chars.indexOfIgnoreCase("", 0, 0, "oo"));
+        Assert.assertEquals(-1, Chars.indexOfIgnoreCase("", 0, 0, "OO"));
+        Assert.assertEquals(-1, Chars.indexOfIgnoreCase("", 0, 0, "y"));
+        Assert.assertEquals(-1, Chars.indexOfIgnoreCase("", 0, 0, "y"));
+    }
+
     @Test
     public void testIsAscii() {
         Assert.assertTrue(Chars.isAscii(""));
diff --git a/core/src/test/java/io/questdb/test/tools/TestUtils.java b/core/src/test/java/io/questdb/test/tools/TestUtils.java
index 1d01d8dcf388..bf31b8d7603e 100644
--- a/core/src/test/java/io/questdb/test/tools/TestUtils.java
+++ b/core/src/test/java/io/questdb/test/tools/TestUtils.java
@@ -699,6 +699,49 @@ public static void assertMemoryLeak(LeakProneCode runnable) throws Exception {
         }
     }
 
+    /**
+     * Asserts that a {@code CharSequence} does NOT contain another {@code CharSequence}.
+     *
+     * @param sequence the {@code CharSequence} to check.
+     * @param term     the {@code CharSequence} to search for (and assert its absence).
+     * @see #assertNotContains(String, CharSequence, CharSequence)
+     */
+    public static void assertNotContains(CharSequence sequence, CharSequence term) {
+        assertNotContains(null, sequence, term);
+    }
+
+    /**
+     * Asserts that a {@code CharSequence} does NOT contain another {@code CharSequence}.
+     * <p>
+     * Fails if the {@code term} is empty (""), because the convention established by
+     * {@link #assertContains(String, CharSequence, CharSequence)} considers an empty
+     * term to be contained within any sequence.
+     * </p>
+     *
+     * @param message  the identifying message for the {@link AssertionError} (<code>null</code> okay)
+     * @param sequence the {@code CharSequence} to check.
+     * @param term     the {@code CharSequence} to search for (and assert its absence).
+     */
+    public static void assertNotContains(String message, CharSequence sequence, CharSequence term) {
+        if (term.length() == 0) {
+            String formatted = "";
+            if (message != null) {
+                formatted = message + " ";
+            }
+            Assert.fail(formatted + "Cannot assert that sequence does not contain an empty term; an empty term is always considered contained by definition.");
+        }
+
+        if (!Chars.contains(sequence, term)) {
+            return;
+        }
+
+        String formatted = "";
+        if (message != null) {
+            formatted = message + " ";
+        }
+        Assert.fail(formatted + "Expected sequence <" + sequence + "> to NOT contain term <" + term + "> but it did.");
+    }
+
     public static void assertReader(CharSequence expected, TableReader reader, MutableUtf16Sink sink) {
         try (TestTableReaderRecordCursor cursor = new TestTableReaderRecordCursor().of(reader)) {
             assertCursor(expected, cursor, reader.getMetadata(), true, sink);


Reviews:
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T13:40:48Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T13:42:59Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T13:46:49Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T13:48:53Z
user: login: jerrinot
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:54:32Z
user: login: jerrinot
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:55:45Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T14:30:57Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T14:31:26Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T14:33:44Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T14:34:23Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T14:36:03Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T14:37:50Z
user: login: jerrinot
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:53:13Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T15:04:16Z
user: login: bluestreak01
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T15:29:15Z
user: login: bluestreak01
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T16:54:01Z

Review comments:
diff_hunk: @@ -306,6 +306,7 @@
 public class SqlCodeGenerator implements Mutable, Closeable {
     public static final int GKK_HOUR_INT = 1;
     public static final int GKK_VANILLA_INT = 0;
+    private static final String ASOF_JOIN_BINARY_SEARCH_HINT = "USE_ASOF_BINARY_SEARCH";
path: core/src/main/java/io/questdb/griffin/SqlCodeGenerator.java
user: login: bluestreak01
body: could we move hints into owne class to not overcrowd the generator? Also for a lowercase keyed map, should constants be low-case?
created_at: 2025-04-24T13:40:48Z
author_association: MEMBER
diff_hunk: @@ -174,6 +174,28 @@ public static boolean containsLowerCase(@NotNull CharSequence sequence, @NotNull
         return indexOfLowerCase(sequence, 0, sequence.length(), termLC) != -1;
     }
 
+    public static boolean containsWord(CharSequence seq, CharSequence term, char separator) {
+        if (Chars.isBlank(seq)) {
+            return false;
+        }
+        if (Chars.isBlank(term)) {
+            return false;
+        }
+        int i = Chars.indexOf(seq, 0, seq.length(), term);
path: core/src/main/java/io/questdb/std/Chars.java
user: login: bluestreak01
body: nit: both sequences are immutable (for the duration of this method), length() doesn't change
created_at: 2025-04-24T13:42:59Z
author_association: MEMBER
diff_hunk: @@ -3427,6 +3427,33 @@ private void processJoinConditions(
         }
     }
 
+    private void propagateHints(QueryModel model) {
+        if (model == null) {
path: core/src/main/java/io/questdb/griffin/SqlOptimiser.java
user: login: bluestreak01
body: nit: this is a classic traversal method, if sig was ``` propagateHintsTo(tagretModel. hints)``` we'd do a lot less null checking and make this method more self-explanatory
created_at: 2025-04-24T13:46:49Z
author_association: MEMBER
diff_hunk: @@ -565,6 +571,10 @@ public void copyDeclsFrom(LowerCaseCharSequenceObjHashMap<ExpressionNode> decls)
         }
     }
 
+    public void copyHints(LowerCaseCharSequenceObjHashMap<CharSequence> hints) {
+        this.hintsMap.putAll(hints);
path: core/src/main/java/io/questdb/griffin/model/QueryModel.java
user: login: bluestreak01
body: i would stick asssert here that hintsMap is empty to begin with, so this method is harder to abuse
created_at: 2025-04-24T13:48:53Z
author_association: MEMBER
diff_hunk: @@ -565,6 +571,10 @@ public void copyDeclsFrom(LowerCaseCharSequenceObjHashMap<ExpressionNode> decls)
         }
     }
 
+    public void copyHints(LowerCaseCharSequenceObjHashMap<CharSequence> hints) {
+        this.hintsMap.putAll(hints);
path: core/src/main/java/io/questdb/griffin/model/QueryModel.java
user: login: jerrinot
body: the target model does not have to be empty. consider unions, one of the queries in tests: 
```
select /*+ HINT_A*/ a,b,c 
from xyz 
where a = 1 
union 
select /*+ HINT_B*/ a,b,c 
from xyz
where a = 2
``` 

we push `HINT_A` to the union model even the union model has its own hint. So the union model will have both `HINT_A` and `HINT_B`. (while the main model has just `HINT_A`)
created_at: 2025-04-24T13:54:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -306,6 +306,7 @@
 public class SqlCodeGenerator implements Mutable, Closeable {
     public static final int GKK_HOUR_INT = 1;
     public static final int GKK_VANILLA_INT = 0;
+    private static final String ASOF_JOIN_BINARY_SEARCH_HINT = "USE_ASOF_BINARY_SEARCH";
path: core/src/main/java/io/questdb/griffin/SqlCodeGenerator.java
user: login: jerrinot
body: good one with lower case! 
created_at: 2025-04-24T13:55:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3604,6 +3700,16 @@ private int toColumnType(GenericLexer lexer, CharSequence tok) throws SqlExcepti
         return tok;
     }
 
+    private @NotNull CharSequence tokIncludingHints(GenericLexer lexer, String expectedList) throws SqlException {
path: core/src/main/java/io/questdb/griffin/SqlParser.java
user: login: bluestreak01
body: we can probably inline this method
created_at: 2025-04-24T14:30:57Z
author_association: MEMBER
diff_hunk: @@ -2248,6 +2257,89 @@ private void parseFromTable(GenericLexer lexer, QueryModel model) throws SqlExce
         parseTableName(lexer, model);
     }
 
+    private void parseHints(GenericLexer lexer, QueryModel model) {
+        CharSequence hintToken = null;
+        boolean parsingParams = false;
+        CharSequence hintKey = null;
+        CharacterStoreEntry hintValuesEntry = null;
+        boolean error = false;
+        while (true) {
+            try {
+                if ((hintToken = SqlUtil.fetchNextHintToken(lexer)) == null) {
+                    break;
+                }
+            } catch (SqlException e) {
+                error = true;
+            }
+
+            if (error) {
+                // if in error state, just consume the rest of hints, but ignore them
+                // since in error state we cannot reliably parse them
+                continue;
+            }
+            if (Chars.equals(hintToken, '(')) {
+                if (parsingParams) {
+                    // hints cannot be nested
+                    error = true;
+                    continue;
+                }
+                if (hintKey == null) {
+                    // missing key
+                    error = true;
+                    continue;
+                }
+                parsingParams = true;
+                continue;
+            }
+
+            if (Chars.equals(hintToken, ')')) {
+                if (!parsingParams) {
+                    // unexpected closing parenthesis
+                    error = true;
+                    continue;
+                }
+                if (hintValuesEntry == null) {
+                    // store last parameter-less hint, e.g. KEY()
+                    model.addHint(hintKey, null);
+                } else {
+                    // ok, there are some parameters
+                    model.addHint(hintKey, hintValuesEntry.toImmutable());
+                    hintValuesEntry = null;
+                }
+                hintKey = null;
+                parsingParams = false;
+                continue;
+            }
+
+            if (parsingParams) {
+                if (hintValuesEntry == null) {
+                    // store first parameter
+                    hintValuesEntry = characterStore.newEntry();
+                } else {
+                    hintValuesEntry.put(HINTS_PARAMS_DELIMITER);
+                }
+                hintValuesEntry.put(GenericLexer.unquote(hintToken));
+                continue;
+            }
+
+            if (hintKey != null) {
+                // store previous parameter-less hint
+                model.addHint(hintKey, null);
+            }
+            CharacterStoreEntry entry = characterStore.newEntry();
+            entry.put(GenericLexer.unquote(hintToken));
+            hintKey = entry.toImmutable();
+        }
+        if (!error) {
+            if (parsingParams) {
path: core/src/main/java/io/questdb/griffin/SqlParser.java
user: login: bluestreak01
body: can be rewritten differently, asking for a friend
created_at: 2025-04-24T14:31:26Z
author_association: MEMBER
diff_hunk: @@ -2248,6 +2257,89 @@ private void parseFromTable(GenericLexer lexer, QueryModel model) throws SqlExce
         parseTableName(lexer, model);
     }
 
+    private void parseHints(GenericLexer lexer, QueryModel model) {
+        CharSequence hintToken = null;
+        boolean parsingParams = false;
+        CharSequence hintKey = null;
+        CharacterStoreEntry hintValuesEntry = null;
+        boolean error = false;
+        while (true) {
+            try {
+                if ((hintToken = SqlUtil.fetchNextHintToken(lexer)) == null) {
+                    break;
+                }
+            } catch (SqlException e) {
path: core/src/main/java/io/questdb/griffin/SqlParser.java
user: login: bluestreak01
body: SqlException error message is being ignored
created_at: 2025-04-24T14:33:43Z
author_association: MEMBER
diff_hunk: @@ -241,6 +260,74 @@ public static CharSequence fetchNext(GenericLexer lexer) throws SqlException {
         return null;
     }
 
+    /**
+     * Fetches the next non-whitespace, non-comment hint token from the lexer.
+     * <p>
+     * This method should only be called after entering a hint block. Specifically,
+     * a previous call to {@link #fetchNext(GenericLexer, boolean)} must have returned
+     * a hint start token (<code>/*+</code>) before this method can be used.
+     * <p>
+     * The method processes the input stream, skipping over any nested comments and whitespace,
+     * and returns the next meaningful hint token. This allows for clean parsing of hint
+     * content without manual handling of comments and formatting characters.
+     * <p>
+     * When the end of the hint block is reached, the method returns null, indicating
+     * no more hint tokens are available for processing.
+     *
+     * @param lexer The input lexer containing the token stream to process
+     * @return The next meaningful hint token, or null if the end of the hint block is reached
+     * @throws SqlException If a parsing error occurs while processing the hint tokens
+     * @see #fetchNext(GenericLexer, boolean) For entering the hint block initially
+     */
+    public static CharSequence fetchNextHintToken(GenericLexer lexer) throws SqlException {
path: core/src/main/java/io/questdb/griffin/SqlUtil.java
user: login: bluestreak01
body: if we are being tolerant to syntax errors here, we should not be throwing exception out
created_at: 2025-04-24T14:34:23Z
author_association: MEMBER
diff_hunk: @@ -2248,6 +2257,89 @@ private void parseFromTable(GenericLexer lexer, QueryModel model) throws SqlExce
         parseTableName(lexer, model);
     }
 
+    private void parseHints(GenericLexer lexer, QueryModel model) {
+        CharSequence hintToken = null;
+        boolean parsingParams = false;
+        CharSequence hintKey = null;
+        CharacterStoreEntry hintValuesEntry = null;
+        boolean error = false;
+        while (true) {
+            try {
+                if ((hintToken = SqlUtil.fetchNextHintToken(lexer)) == null) {
+                    break;
+                }
+            } catch (SqlException e) {
+                error = true;
+            }
+
+            if (error) {
+                // if in error state, just consume the rest of hints, but ignore them
+                // since in error state we cannot reliably parse them
+                continue;
+            }
+            if (Chars.equals(hintToken, '(')) {
+                if (parsingParams) {
+                    // hints cannot be nested
+                    error = true;
+                    continue;
+                }
+                if (hintKey == null) {
+                    // missing key
+                    error = true;
+                    continue;
+                }
+                parsingParams = true;
+                continue;
+            }
+
+            if (Chars.equals(hintToken, ')')) {
+                if (!parsingParams) {
+                    // unexpected closing parenthesis
+                    error = true;
+                    continue;
+                }
+                if (hintValuesEntry == null) {
+                    // store last parameter-less hint, e.g. KEY()
+                    model.addHint(hintKey, null);
+                } else {
+                    // ok, there are some parameters
+                    model.addHint(hintKey, hintValuesEntry.toImmutable());
+                    hintValuesEntry = null;
+                }
+                hintKey = null;
+                parsingParams = false;
+                continue;
+            }
+
+            if (parsingParams) {
+                if (hintValuesEntry == null) {
+                    // store first parameter
+                    hintValuesEntry = characterStore.newEntry();
+                } else {
+                    hintValuesEntry.put(HINTS_PARAMS_DELIMITER);
+                }
+                hintValuesEntry.put(GenericLexer.unquote(hintToken));
path: core/src/main/java/io/questdb/griffin/SqlParser.java
user: login: bluestreak01
body: why would the hint be quoted?
created_at: 2025-04-24T14:36:02Z
author_association: MEMBER
diff_hunk: @@ -728,6 +729,14 @@ private CharSequence optTok(GenericLexer lexer) throws SqlException {
         return tok;
     }
 
+    private CharSequence optTokIncludingHint(GenericLexer lexer) throws SqlException {
+        CharSequence tok = SqlUtil.fetchNext(lexer, true);
path: core/src/main/java/io/questdb/griffin/SqlParser.java
user: login: bluestreak01
body: we probably should inline this too
created_at: 2025-04-24T14:37:50Z
author_association: MEMBER
diff_hunk: @@ -2248,6 +2257,89 @@ private void parseFromTable(GenericLexer lexer, QueryModel model) throws SqlExce
         parseTableName(lexer, model);
     }
 
+    private void parseHints(GenericLexer lexer, QueryModel model) {
+        CharSequence hintToken = null;
+        boolean parsingParams = false;
+        CharSequence hintKey = null;
+        CharacterStoreEntry hintValuesEntry = null;
+        boolean error = false;
+        while (true) {
+            try {
+                if ((hintToken = SqlUtil.fetchNextHintToken(lexer)) == null) {
+                    break;
+                }
+            } catch (SqlException e) {
+                error = true;
+            }
+
+            if (error) {
+                // if in error state, just consume the rest of hints, but ignore them
+                // since in error state we cannot reliably parse them
+                continue;
+            }
+            if (Chars.equals(hintToken, '(')) {
+                if (parsingParams) {
+                    // hints cannot be nested
+                    error = true;
+                    continue;
+                }
+                if (hintKey == null) {
+                    // missing key
+                    error = true;
+                    continue;
+                }
+                parsingParams = true;
+                continue;
+            }
+
+            if (Chars.equals(hintToken, ')')) {
+                if (!parsingParams) {
+                    // unexpected closing parenthesis
+                    error = true;
+                    continue;
+                }
+                if (hintValuesEntry == null) {
+                    // store last parameter-less hint, e.g. KEY()
+                    model.addHint(hintKey, null);
+                } else {
+                    // ok, there are some parameters
+                    model.addHint(hintKey, hintValuesEntry.toImmutable());
+                    hintValuesEntry = null;
+                }
+                hintKey = null;
+                parsingParams = false;
+                continue;
+            }
+
+            if (parsingParams) {
+                if (hintValuesEntry == null) {
+                    // store first parameter
+                    hintValuesEntry = characterStore.newEntry();
+                } else {
+                    hintValuesEntry.put(HINTS_PARAMS_DELIMITER);
+                }
+                hintValuesEntry.put(GenericLexer.unquote(hintToken));
path: core/src/main/java/io/questdb/griffin/SqlParser.java
user: login: jerrinot
body: dunno, SQL probably allows that - `foo` is the same as `"foo"` 
on the other hand - I guess we can be more strict. since I cannot imagine a hint name which would require this. let me remove it. (I will still keep it for parameters tho)
created_at: 2025-04-24T14:53:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3427,6 +3427,33 @@ private void processJoinConditions(
         }
     }
 
+    private void propagateHints(QueryModel model) {
+        if (model == null) {
path: core/src/main/java/io/questdb/griffin/SqlOptimiser.java
user: login: bluestreak01
body: fixed in https://github.com/questdb/questdb/pull/5620/commits/a7217a60b5f20b0a70d4be77614b2c35c4582814
created_at: 2025-04-24T15:04:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,42 @@
+/*******************************************************************************
+ *     ___                  _   ____  ____
+ *    / _ \ _   _  ___  ___| |_|  _ \| __ )
+ *   | | | | | | |/ _ \/ __| __| | | |  _ \
+ *   | |_| | |_| |  __/\__ \ |_| |_| | |_) |
+ *    \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ *  Copyright (c) 2014-2019 Appsicle
+ *  Copyright (c) 2019-2024 QuestDB
+ *
+ *  Licensed under the Apache License, Version 2.0 (the "License");
+ *  you may not use this file except in compliance with the License.
+ *  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.griffin;
+
+import io.questdb.griffin.model.QueryModel;
+import io.questdb.std.Chars;
+import io.questdb.std.LowerCaseCharSequenceObjHashMap;
+import org.jetbrains.annotations.NotNull;
+
+public final class SqlHints {
+    public static final String ASOF_JOIN_BINARY_SEARCH_HINT = "use_asof_binary_search";
+    public static final char HINTS_PARAMS_DELIMITER = ' ';
+
+    public static boolean hasAsOfJoinBinarySearchHint(@NotNull QueryModel queryModel, CharSequence tableNameA, CharSequence tableNameB) {
+        LowerCaseCharSequenceObjHashMap<CharSequence> hints = queryModel.getHints();
+        CharSequence params = hints.get(SqlHints.ASOF_JOIN_BINARY_SEARCH_HINT);
+        return Chars.containsWord(params, tableNameA, HINTS_PARAMS_DELIMITER) &&
path: core/src/main/java/io/questdb/griffin/SqlHints.java
user: login: bluestreak01
body: table name should be case-insentitive + tests
created_at: 2025-04-24T15:29:15Z
author_association: MEMBER

PR comments:
user: login: glasstiger
created_at: 2025-04-25T16:26:20Z
body: #### [PR Coverage check]

:heart_eyes: **pass** : 169 / 170 (99.41%)




#### file detail

|   |path|covered line|new line|coverage|
|----|----|----|----|----|
|:large_blue_circle:|io/questdb/griffin/SqlHints.java|4|5|80.00%|
|:large_blue_circle:|io/questdb/std/Chars.java|32|32|100.00%|
|:large_blue_circle:|io/questdb/griffin/SqlParser.java|53|53|100.00%|
|:large_blue_circle:|io/questdb/griffin/model/QueryModel.java|25|25|100.00%|
|:large_blue_circle:|io/questdb/griffin/SqlCompilerImpl.java|1|1|100.00%|
|:large_blue_circle:|io/questdb/griffin/SqlCodeGenerator.java|4|4|100.00%|
|:large_blue_circle:|io/questdb/griffin/SqlOptimiser.java|18|18|100.00%|
|:large_blue_circle:|io/questdb/griffin/SqlUtil.java|32|32|100.00%|



author_association: CONTRIBUTOR