Skip to content

Commit 10494ad

Browse files
committed
feat: add dynamic index resolution with multi-tenant support
- Add RedisIndexContext for thread-local tenant and environment context - Add IndexResolver interface for custom index name/prefix resolution strategies - Add DefaultIndexResolver with full SpEL evaluation support for context variables - Extend RediSearchIndexer with context-aware index operations - Support dynamic index naming and key prefixes based on runtime context - Enable multi-tenant Redis deployments with isolated indexes per tenant
1 parent dfa5674 commit 10494ad

File tree

7 files changed

+1610
-0
lines changed

7 files changed

+1610
-0
lines changed
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
package com.redis.om.spring.indexing;
2+
3+
import java.util.regex.Matcher;
4+
import java.util.regex.Pattern;
5+
6+
import org.springframework.context.ApplicationContext;
7+
import org.springframework.expression.Expression;
8+
import org.springframework.expression.ExpressionParser;
9+
import org.springframework.expression.spel.standard.SpelExpressionParser;
10+
import org.springframework.expression.spel.support.StandardEvaluationContext;
11+
12+
import com.redis.om.spring.annotations.IndexingOptions;
13+
14+
/**
15+
* Default implementation of {@link IndexResolver} that supports
16+
* SpEL expressions and context-based resolution.
17+
*
18+
* <p>This implementation evaluates SpEL expressions found in
19+
* {@link IndexingOptions} annotations and incorporates context
20+
* information from {@link RedisIndexContext} when available.
21+
*
22+
* @since 1.0.0
23+
*/
24+
public class DefaultIndexResolver implements IndexResolver {
25+
26+
private final ApplicationContext applicationContext;
27+
private final ExpressionParser parser = new SpelExpressionParser();
28+
private static final Pattern SPEL_TEMPLATE_PATTERN = Pattern.compile("#\\{([^}]+)\\}");
29+
30+
/**
31+
* Creates a new DefaultIndexResolver.
32+
*
33+
* @param applicationContext the Spring application context
34+
*/
35+
public DefaultIndexResolver(ApplicationContext applicationContext) {
36+
this.applicationContext = applicationContext;
37+
}
38+
39+
@Override
40+
public String resolveIndexName(Class<?> entityClass, RedisIndexContext context) {
41+
IndexingOptions indexingOptions = entityClass.getAnnotation(IndexingOptions.class);
42+
43+
if (indexingOptions != null && !indexingOptions.indexName().isEmpty()) {
44+
String indexName = indexingOptions.indexName();
45+
46+
// Check if the index name contains SpEL expressions
47+
if (containsSpelExpression(indexName)) {
48+
String evaluated = evaluateSpelExpression(indexName, entityClass, context);
49+
if (evaluated != null) {
50+
return evaluated;
51+
}
52+
// If SpEL evaluation failed, fall back to default
53+
} else {
54+
// Plain string annotation - always return as is, regardless of context
55+
return indexName;
56+
}
57+
}
58+
59+
// Default naming convention
60+
String baseName = getDefaultIndexName(entityClass);
61+
62+
// Apply context if available
63+
if (context != null) {
64+
StringBuilder sb = new StringBuilder(baseName);
65+
66+
// Remove the _idx suffix if present
67+
if (baseName.endsWith("_idx")) {
68+
sb.setLength(sb.length() - 4);
69+
} else if (baseName.endsWith("Idx")) {
70+
sb.setLength(sb.length() - 3);
71+
}
72+
73+
if (context.getTenantId() != null) {
74+
sb.append("_").append(context.getTenantId());
75+
}
76+
77+
if (context.getEnvironment() != null) {
78+
sb.append("_").append(context.getEnvironment());
79+
}
80+
81+
// Add version attribute if present
82+
Object version = context.getAttribute("version");
83+
if (version != null) {
84+
sb.append("_").append(version);
85+
}
86+
87+
sb.append("_idx");
88+
return sb.toString();
89+
}
90+
91+
return baseName;
92+
}
93+
94+
@Override
95+
public String resolveKeyPrefix(Class<?> entityClass, RedisIndexContext context) {
96+
IndexingOptions indexingOptions = entityClass.getAnnotation(IndexingOptions.class);
97+
98+
if (indexingOptions != null && !indexingOptions.keyPrefix().isEmpty()) {
99+
String keyPrefix = indexingOptions.keyPrefix();
100+
101+
// Check if the prefix contains SpEL expressions
102+
if (containsSpelExpression(keyPrefix)) {
103+
String evaluated = evaluateSpelExpression(keyPrefix, entityClass, context);
104+
if (evaluated != null) {
105+
return evaluated;
106+
}
107+
// If SpEL evaluation failed, fall back to default
108+
} else {
109+
// Plain string annotation - always return as is, regardless of context
110+
return keyPrefix;
111+
}
112+
}
113+
114+
// Default key prefix convention
115+
String basePrefix = getDefaultKeyPrefix(entityClass);
116+
117+
// Apply context if available
118+
if (context != null && context.getTenantId() != null) {
119+
// Prepend tenant ID to the prefix
120+
return context.getTenantId() + ":" + basePrefix;
121+
}
122+
123+
return basePrefix;
124+
}
125+
126+
/**
127+
* Gets the tenant ID from the context.
128+
* Subclasses can override this for custom tenant resolution.
129+
*
130+
* @param context the current context
131+
* @return the tenant ID, or null if not available
132+
*/
133+
protected String getTenantId(RedisIndexContext context) {
134+
if (context != null) {
135+
return context.getTenantId();
136+
}
137+
return null;
138+
}
139+
140+
private boolean containsSpelExpression(String value) {
141+
return value.contains("#{");
142+
}
143+
144+
private String evaluateSpelExpression(String expression, Class<?> entityClass, RedisIndexContext context) {
145+
try {
146+
// Create evaluation context
147+
StandardEvaluationContext evalContext = new StandardEvaluationContext();
148+
149+
// Add context as a variable
150+
if (context != null) {
151+
evalContext.setVariable("context", context);
152+
} else {
153+
// Create empty context to avoid null reference errors
154+
evalContext.setVariable("context", RedisIndexContext.builder().build());
155+
}
156+
157+
// Add application context beans
158+
if (applicationContext != null) {
159+
evalContext.setBeanResolver((ctx, beanName) -> {
160+
// Special handling for environment bean
161+
if ("environment".equals(beanName)) {
162+
return applicationContext.getEnvironment();
163+
}
164+
return applicationContext.getBean(beanName);
165+
});
166+
167+
// Also add environment as a variable for direct access
168+
if (applicationContext.getEnvironment() != null) {
169+
evalContext.setVariable("environment", applicationContext.getEnvironment());
170+
}
171+
}
172+
173+
// Process template expressions - replace #{...} with evaluated values
174+
StringBuffer result = new StringBuffer();
175+
Matcher matcher = SPEL_TEMPLATE_PATTERN.matcher(expression);
176+
boolean hasFailedExpressions = false;
177+
178+
while (matcher.find()) {
179+
String spelExpression = matcher.group(1);
180+
try {
181+
Expression exp = parser.parseExpression(spelExpression);
182+
Object evalResult = exp.getValue(evalContext);
183+
if (evalResult != null) {
184+
matcher.appendReplacement(result, Matcher.quoteReplacement(evalResult.toString()));
185+
} else {
186+
hasFailedExpressions = true;
187+
break;
188+
}
189+
} catch (Exception e) {
190+
// Expression evaluation failed
191+
hasFailedExpressions = true;
192+
break;
193+
}
194+
}
195+
196+
if (hasFailedExpressions) {
197+
return null;
198+
}
199+
200+
matcher.appendTail(result);
201+
return result.toString();
202+
203+
} catch (Exception e) {
204+
// Fall back to default if SpEL evaluation fails
205+
return null;
206+
}
207+
}
208+
209+
private String getDefaultIndexName(Class<?> entityClass) {
210+
// Use the simple name converted to snake_case
211+
String className = entityClass.getSimpleName();
212+
StringBuilder sb = new StringBuilder();
213+
214+
for (int i = 0; i < className.length(); i++) {
215+
char c = className.charAt(i);
216+
if (Character.isUpperCase(c) && i > 0) {
217+
sb.append("_");
218+
}
219+
sb.append(Character.toLowerCase(c));
220+
}
221+
222+
sb.append("_idx");
223+
return sb.toString();
224+
}
225+
226+
private String getDefaultKeyPrefix(Class<?> entityClass) {
227+
// Use the simple name converted to lowercase with colons
228+
String className = entityClass.getSimpleName();
229+
StringBuilder sb = new StringBuilder();
230+
231+
for (int i = 0; i < className.length(); i++) {
232+
char c = className.charAt(i);
233+
if (Character.isUpperCase(c) && i > 0) {
234+
sb.append(":");
235+
}
236+
sb.append(Character.toLowerCase(c));
237+
}
238+
239+
sb.append(":");
240+
return sb.toString();
241+
}
242+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.redis.om.spring.indexing;
2+
3+
/**
4+
* Strategy interface for resolving index names and key prefixes.
5+
* Implementations can provide custom logic for determining index
6+
* configurations based on runtime context.
7+
*
8+
* <p>This interface allows for dynamic index resolution based on
9+
* factors such as tenant ID, environment, or custom attributes
10+
* provided through {@link RedisIndexContext}.
11+
*
12+
* @since 1.0.0
13+
*/
14+
public interface IndexResolver {
15+
16+
/**
17+
* Resolves the index name for a given entity class.
18+
*
19+
* @param entityClass the entity class to resolve the index for
20+
* @param context the current index context, may be null
21+
* @return the resolved index name
22+
*/
23+
String resolveIndexName(Class<?> entityClass, RedisIndexContext context);
24+
25+
/**
26+
* Resolves the key prefix for a given entity class.
27+
*
28+
* @param entityClass the entity class to resolve the prefix for
29+
* @param context the current index context, may be null
30+
* @return the resolved key prefix
31+
*/
32+
String resolveKeyPrefix(Class<?> entityClass, RedisIndexContext context);
33+
}

0 commit comments

Comments
 (0)