|
5 | 5 | import com.google.common.annotations.VisibleForTesting; |
6 | 6 | import io.ebean.EbeanServer; |
7 | 7 | import io.ebean.SqlRow; |
| 8 | +import java.util.HashMap; |
8 | 9 | import java.util.HashSet; |
9 | 10 | import java.util.List; |
| 11 | +import java.util.Map; |
10 | 12 | import java.util.Set; |
11 | 13 | import java.util.concurrent.TimeUnit; |
12 | 14 | import javax.annotation.Nonnull; |
| 15 | +import javax.annotation.Nullable; |
13 | 16 | import lombok.extern.slf4j.Slf4j; |
14 | 17 |
|
15 | 18 |
|
@@ -39,10 +42,27 @@ public class SchemaValidatorUtil { |
39 | 42 | .maximumSize(1000) |
40 | 43 | .build(); |
41 | 44 |
|
| 45 | + // Cache: tableName → Set of index names -> expression that defines the index, used as a replacement for creating an index on virtual columns |
| 46 | + // Configuration: |
| 47 | + // - expireAfterWrite(10 minutes): Ensures that newly added indexes (e.g., via Pretzel) are picked up automatically |
| 48 | + // without requiring a service restart. After 10 minutes, the next request will trigger a DB refresh. |
| 49 | + // - maximumSize(1000): Limits cache memory footprint by retaining entries for up to 1000 distinct tables. |
| 50 | + // Least recently used entries are evicted when the size limit is reached. |
| 51 | + // ** THIS IS NEEDED ** because of local testing limitations by MariaDB: expression-based indexes are not supported, |
| 52 | + // so no existing logic should depend on anything introduced by the support of this. Otherwise, we'd need to mock |
| 53 | + // all indexing code in the test DB, which I want to avoid if possible. |
| 54 | + // TODO: This can become the only cache needed for indexes once we are 100% migrated over to this logic. |
| 55 | + private final Cache<String, Map<String, String>> indexExpressionCache = Caffeine.newBuilder() |
| 56 | + .expireAfterWrite(10, TimeUnit.MINUTES) |
| 57 | + .maximumSize(1000) |
| 58 | + .build(); |
| 59 | + |
42 | 60 | private static final String SQL_GET_ALL_COLUMNS = |
43 | 61 | "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = database() AND TABLE_NAME = '%s'"; |
44 | 62 | private static final String SQL_GET_ALL_INDEXES = |
45 | 63 | "SELECT DISTINCT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = database() AND TABLE_NAME = '%s'"; |
| 64 | + private static final String SQL_GET_ALL_INDEXES_WITH_EXPRESSIONS = |
| 65 | + "SELECT DISTINCT INDEX_NAME, EXPRESSION FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = database() AND TABLE_NAME = '%s'"; |
46 | 66 |
|
47 | 67 | public SchemaValidatorUtil(EbeanServer server) { |
48 | 68 | this.server = server; |
@@ -97,6 +117,60 @@ public boolean indexExists(@Nonnull String tableName, @Nonnull String indexName) |
97 | 117 | return indexes.contains(lowerIndex); |
98 | 118 | } |
99 | 119 |
|
| 120 | + |
| 121 | + /** |
| 122 | + * Cleans SQL expression by removing MySQL-specific encoding artifacts. |
| 123 | + * Removes _utf8mb4 charset prefix, unescapes quotes, and removes newlines. |
| 124 | + * MySQL team is the POC for questions about this since there is preprocessing needed to transform the as-is |
| 125 | + * index expression from the index table to a (string) expression that is usable directly in an indexed query. |
| 126 | + * |
| 127 | + * @param expression Raw SQL expression from database |
| 128 | + * @return Cleaned expression string, with enclosing parentheses |
| 129 | + */ |
| 130 | + @VisibleForTesting |
| 131 | + protected String cleanIndexExpression(@Nullable String expression) { |
| 132 | + if (expression == null) { |
| 133 | + return null; |
| 134 | + } |
| 135 | + |
| 136 | + return "(" + expression |
| 137 | + .replace("_utf8mb4\\'", "'") |
| 138 | + .replace("\\'", "'") |
| 139 | + .replace("\\\"", "\"") |
| 140 | + .replace("\n", "") + ")"; |
| 141 | + } |
| 142 | + |
| 143 | + |
| 144 | + /** |
| 145 | + * Retrieves the expression associated with the given index. |
| 146 | + * |
| 147 | + * <p>NULL doesn't necessarily mean that an index doesn't exist, use {@link #indexExists(String, String)} to check for index existence. |
| 148 | + * |
| 149 | + * @param tableName Table name |
| 150 | + * @param indexName Index name |
| 151 | + * @return Expression string, or null if index does not exist OR is not created on an expression; will be enclosed in |
| 152 | + * parentheses '()' |
| 153 | + */ |
| 154 | + @Nullable |
| 155 | + public String getIndexExpression(@Nonnull String tableName, @Nonnull String indexName) { |
| 156 | + String lowerTable = tableName.toLowerCase(); |
| 157 | + String lowerIndex = indexName.toLowerCase(); |
| 158 | + |
| 159 | + try { |
| 160 | + Map<String, String> indexes = indexExpressionCache.get(lowerTable, tbl -> { |
| 161 | + log.info("Refreshing index cache for table '{}' from expression retrieval call", tbl); |
| 162 | + return loadIndexesAndExpressions(tbl); |
| 163 | + }); |
| 164 | + |
| 165 | + // This will also return null if the Expression column is null itself |
| 166 | + return cleanIndexExpression(indexes.getOrDefault(lowerIndex, null)); |
| 167 | + } catch (Exception e) { |
| 168 | + // MariaDB for local testing doesn't support EXPRESSION column - gracefully degrade |
| 169 | + log.debug("Unable to load index expressions for table '{}': {}", lowerTable, e.getMessage()); |
| 170 | + return null; // same logic as "no expression exists", which is good (handled gracefully) |
| 171 | + } |
| 172 | + } |
| 173 | + |
100 | 174 | /** |
101 | 175 | * Loads all columns for the given table from information_schema. |
102 | 176 | * |
@@ -127,4 +201,21 @@ private Set<String> loadIndexes(String tableName) { |
127 | 201 | return indexes; |
128 | 202 | } |
129 | 203 |
|
| 204 | + /** |
| 205 | + * Loads all index names and expressions for the given table from information_schema. |
| 206 | + * See the comment for indexExpressionCache for more details. |
| 207 | + * |
| 208 | + * @param tableName Table to query |
| 209 | + * @return Map of lowercase index names -> expressions |
| 210 | + */ |
| 211 | + private Map<String, String> loadIndexesAndExpressions(String tableName) { |
| 212 | + List<SqlRow> rows = server.createSqlQuery(String.format(SQL_GET_ALL_INDEXES_WITH_EXPRESSIONS, tableName)).findList(); |
| 213 | + Map<String, String> indexes = new HashMap<>(); |
| 214 | + for (SqlRow row : rows) { |
| 215 | + // The Expression value will be null if the index is not created on an expression |
| 216 | + indexes.put(row.getString("INDEX_NAME").toLowerCase(), row.getString("EXPRESSION")); |
| 217 | + } |
| 218 | + return indexes; |
| 219 | + } |
| 220 | + |
130 | 221 | } |
0 commit comments