Skip to content

Commit 6df29c7

Browse files
authored
Add @Interned annotation for low-cardinality string optimization (#16)
* Add @Interned annotation for low-cardinality string optimization - Add @Interned annotation to mark string properties that should use FalkorDB's intern() function - Update FalkorDBPersistentProperty interface with isInterned() method - Implement @Interned support in DefaultFalkorDBEntityConverter with InternedValue marker class - Update FalkorDBTemplate and DefaultFalkorDBEntityConverter to generate Cypher with inline intern() calls - Add comprehensive tests for @Interned functionality - Add README documentation explaining when and how to use @Interned - Add InternedUsageExample demonstrating best practices for using @Interned annotation This feature optimizes storage for properties with limited value sets (status codes, categories, country codes, etc.) by ensuring FalkorDB keeps only a single copy of frequently repeated string values. * Remove ineffective @inherited meta-annotation from @Interned @inherited only affects class-level annotations, not field-level ones. Since @Interned targets ElementType.FIELD, the @inherited annotation has no effect and should be removed. Addresses CodeRabbitAI review comment. * Fix escape sequence in @Interned annotation to properly handle backslashes - Escape backslashes before single quotes to prevent injection issues - Add comprehensive tests for backslash and quote escaping - Addresses CodeRabbitAI security review comment on PR #16 The previous implementation only escaped single quotes, which could lead to incorrect Cypher generation or potential injection issues when values contained backslashes. Now backslashes are escaped first (doubled), then single quotes are escaped with backslash-quote. * Add repositoryFactoryBeanClass attribute to @EnableFalkorDBRepositories - Add repositoryFactoryBeanClass attribute that specifies FalkorDBRepositoryFactoryBean - This attribute is required for Spring Data to correctly instantiate repository proxies - Ensures proper integration with Spring Data infrastructure * Fix parameter binding in StringBasedFalkorDBQuery to prevent collisions - Only use indexed parameter binding (-zsh, , etc.) for parameters without @param - Use named parameter binding exclusively for parameters with @param annotation - Prevents parameter collision when mixing indexed and named parameters - Improves predictability and correctness of query parameter binding Previously, all parameters were first added as indexed, then named parameters were added, which could cause unexpected behavior if parameter names matched index values. * Add support for scalar and Map return types in repository queries - Add flexible query method with result mapper to FalkorDBOperations/Template - Support scalar return types (String, Integer, Long, Boolean, etc.) in @query methods - Support Map and List<Map> return types for raw result handling - Add queryForScalar() to extract single column values - Add queryForMaps() to return raw Map results without entity mapping - Add proper type conversion for Number types - Fix FalkorDBQueryLookupStrategy initialization with proper constructor This allows repository methods to return: - Single scalar values: @query("RETURN count(*)") Long count(); - Collections of scalars: @query("RETURN n.name") List<String> names(); - Single Map: @query("RETURN n{.*}") Map<String, Object> getMap(); - Collections of Maps: @query("RETURN n{.*}") List<Map<String, Object>> getMaps(); Previously, only entity types were supported as return values. * Add support for Number to String conversion in entity converter - Allow automatic conversion of numeric values to String type - Useful for cases where IDs are Long in database but String in entity - Handles scenarios like internal FalkorDB IDs being mapped to String properties This enables flexibility in entity ID type declarations while maintaining compatibility with FalkorDB's internal numeric ID system.
1 parent 4a4fea6 commit 6df29c7

File tree

12 files changed

+754
-52
lines changed

12 files changed

+754
-52
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,29 @@ private String name; // Maps to "full_name" property
225225
private String email; // Maps to "email" property (default)
226226
```
227227

228+
### @Interned
229+
Marks string properties as low-cardinality, applying FalkorDB's `intern()` function to optimize storage:
230+
231+
```java
232+
@Interned
233+
private String status; // Uses intern() - ideal for limited values like "ACTIVE", "INACTIVE"
234+
235+
@Interned
236+
private String country; // Uses intern() - ideal for country codes "US", "UK", "CA"
237+
238+
@Interned
239+
private String category; // Uses intern() - ideal for categories like "SPORTS", "NEWS"
240+
```
241+
242+
The `@Interned` annotation is useful for string properties that have a limited set of possible values (low cardinality). When a property is marked with `@Interned`, FalkorDB's `intern()` function is automatically applied when writing to the database, which keeps only a single copy of frequently repeated string values, optimizing storage and query performance.
243+
244+
**Use cases:**
245+
- Status codes (ACTIVE, INACTIVE, PENDING)
246+
- Country/region codes
247+
- Categories and types
248+
- Enum-like string values
249+
- Any string with a limited vocabulary
250+
228251
### @Relationship
229252
Maps relationships between entities:
230253

src/main/java/org/springframework/data/falkordb/core/FalkorDBOperations.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,15 @@ public interface FalkorDBOperations {
151151
*/
152152
<T> Optional<T> queryForObject(String cypher, java.util.Map<String, Object> parameters, Class<T> clazz);
153153

154+
/**
155+
* Executes a custom Cypher query with a result mapper function.
156+
* @param <T> the type of the result
157+
* @param cypher the Cypher query
158+
* @param parameters the query parameters
159+
* @param resultMapper function to map the query result
160+
* @return the mapped result
161+
*/
162+
<T> T query(String cypher, java.util.Map<String, Object> parameters,
163+
java.util.function.Function<FalkorDBClient.QueryResult, T> resultMapper);
164+
154165
}

src/main/java/org/springframework/data/falkordb/core/FalkorDBTemplate.java

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,36 @@ public <T> T save(T instance) {
9898
cypher.append(primaryLabel);
9999
cypher.append(" ");
100100

101+
// Separate regular properties from interned properties
102+
Map<String, Object> regularParams = new HashMap<>();
103+
List<String> propertyAssignments = new ArrayList<>();
104+
105+
for (Map.Entry<String, Object> entry : properties.entrySet()) {
106+
String key = entry.getKey();
107+
Object value = entry.getValue();
108+
109+
if (value instanceof DefaultFalkorDBEntityConverter.InternedValue) {
110+
// For interned values, inline the intern() function call
111+
DefaultFalkorDBEntityConverter.InternedValue internedValue =
112+
(DefaultFalkorDBEntityConverter.InternedValue) value;
113+
propertyAssignments.add(key + ": intern('" + internedValue.getValue() + "')");
114+
} else {
115+
// For regular values, use parameters
116+
propertyAssignments.add(key + ": $" + key);
117+
regularParams.put(key, value);
118+
}
119+
}
120+
101121
// Add properties
102-
if (!properties.isEmpty()) {
122+
if (!propertyAssignments.isEmpty()) {
103123
cypher.append("{ ");
104-
String propertiesStr = properties.keySet()
105-
.stream()
106-
.map(key -> key + ": $" + key)
107-
.collect(Collectors.joining(", "));
108-
cypher.append(propertiesStr);
124+
cypher.append(String.join(", ", propertyAssignments));
109125
cypher.append(" }");
110126
}
111127

112128
cypher.append(") RETURN n, id(n) as nodeId");
113129

114-
return this.falkorDBClient.query(cypher.toString(), properties, result -> {
130+
return this.falkorDBClient.query(cypher.toString(), regularParams, result -> {
115131
// Convert back to entity
116132
for (FalkorDBClient.Record record : result.records()) {
117133
T savedEntity = (T) this.entityConverter.read(entityType, record);
@@ -345,6 +361,16 @@ public <T> Optional<T> queryForObject(String cypher, Map<String, Object> paramet
345361
});
346362
}
347363

364+
@Override
365+
public <T> T query(String cypher, Map<String, Object> parameters,
366+
java.util.function.Function<FalkorDBClient.QueryResult, T> resultMapper) {
367+
Assert.hasText(cypher, "Cypher query must not be null or empty");
368+
Assert.notNull(parameters, "Parameters must not be null");
369+
Assert.notNull(resultMapper, "Result mapper must not be null");
370+
371+
return this.falkorDBClient.query(cypher, parameters, resultMapper);
372+
}
373+
348374
/**
349375
* Returns the {@link FalkorDBEntityConverter} used by this template.
350376
*

src/main/java/org/springframework/data/falkordb/core/mapping/DefaultFalkorDBEntityConverter.java

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -188,28 +188,54 @@ public final void write(final Object source, final Map<String, Object> sink) {
188188
Object value = accessor.getProperty(property);
189189
if (value != null) {
190190
// Convert value to FalkorDB-compatible format
191-
Object convertedValue = convertValueForFalkorDB(value);
191+
Object convertedValue = convertValueForFalkorDB(value, property);
192192
sink.put(property.getGraphPropertyName(), convertedValue);
193193
}
194194
});
195195
}
196196

197197
/**
198198
* Convert a value to a format compatible with FalkorDB. This handles special types
199-
* like LocalDateTime that need formatting.
199+
* like LocalDateTime that need formatting and applies intern() for low-cardinality strings.
200200
* @param value the value to convert
201+
* @param property the property metadata (optional, can be null)
201202
* @return the converted value compatible with FalkorDB
202203
*/
203-
private Object convertValueForFalkorDB(final Object value) {
204+
private Object convertValueForFalkorDB(final Object value, final FalkorDBPersistentProperty property) {
204205
if (value instanceof LocalDateTime) {
205206
// Convert LocalDateTime to ISO string format that FalkorDB
206207
// can handle. Using ISO_LOCAL_DATE_TIME format.
207208
return ((LocalDateTime) value).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
208209
}
210+
211+
// Apply intern() function for low-cardinality string properties
212+
if (property != null && property.isInterned() && value instanceof String) {
213+
String strValue = (String) value;
214+
// Escape backslashes first, then single quotes
215+
String escapedValue = strValue.replace("\\", "\\\\").replace("'", "\\'");
216+
// Return as a special marker object that will be handled during Cypher generation
217+
return new InternedValue(escapedValue);
218+
}
219+
209220
// Add more type conversions as needed
210221
return value;
211222
}
212223

224+
/**
225+
* Marker class to indicate that a value should use FalkorDB's intern() function.
226+
*/
227+
public static class InternedValue {
228+
private final String value;
229+
230+
public InternedValue(final String value) {
231+
this.value = value;
232+
}
233+
234+
public String getValue() {
235+
return value;
236+
}
237+
}
238+
213239
/**
214240
* Check if the given type is a primitive type or its wrapper.
215241
* @param type the type to check
@@ -312,6 +338,10 @@ else if (targetType == Short.class || targetType == short.class) {
312338
else if (targetType == Byte.class || targetType == byte.class) {
313339
return numValue.byteValue();
314340
}
341+
else if (targetType == String.class) {
342+
// Convert number to string (e.g., Long ID to String)
343+
return numValue.toString();
344+
}
315345
}
316346

317347
// Handle String conversions
@@ -612,7 +642,7 @@ private Object saveEntityAndGetId(Object entity, FalkorDBPersistentEntity<?> per
612642

613643
Object value = accessor.getProperty(property);
614644
if (value != null) {
615-
Object convertedValue = convertValueForFalkorDB(value);
645+
Object convertedValue = convertValueForFalkorDB(value, property);
616646
properties.put(property.getGraphPropertyName(), convertedValue);
617647
}
618648
});
@@ -630,24 +660,39 @@ private Object saveEntityAndGetId(Object entity, FalkorDBPersistentEntity<?> per
630660
}
631661
}
632662

633-
StringBuilder cypher = new StringBuilder("CREATE (n:");
634-
cypher.append(String.join(":", labels));
635-
cypher.append(" ");
636-
637-
if (!properties.isEmpty()) {
638-
cypher.append("{ ");
639-
String propertiesStr = properties.keySet()
640-
.stream()
641-
.map(key -> key + ": $" + key)
642-
.collect(java.util.stream.Collectors.joining(", "));
643-
cypher.append(propertiesStr);
644-
cypher.append(" }");
663+
StringBuilder cypher = new StringBuilder("CREATE (n:");
664+
cypher.append(String.join(":", labels));
665+
cypher.append(" ");
666+
667+
// Separate regular properties from interned properties
668+
Map<String, Object> regularParams = new HashMap<>();
669+
List<String> propertyAssignments = new ArrayList<>();
670+
671+
for (Map.Entry<String, Object> entry : properties.entrySet()) {
672+
String key = entry.getKey();
673+
Object value = entry.getValue();
674+
675+
if (value instanceof InternedValue) {
676+
// For interned values, inline the intern() function call
677+
InternedValue internedValue = (InternedValue) value;
678+
propertyAssignments.add(key + ": intern('" + internedValue.getValue() + "')");
679+
} else {
680+
// For regular values, use parameters
681+
propertyAssignments.add(key + ": $" + key);
682+
regularParams.put(key, value);
645683
}
684+
}
685+
686+
if (!propertyAssignments.isEmpty()) {
687+
cypher.append("{ ");
688+
cypher.append(String.join(", ", propertyAssignments));
689+
cypher.append(" }");
690+
}
646691

647-
cypher.append(") RETURN id(n) as nodeId");
692+
cypher.append(") RETURN id(n) as nodeId");
648693

649-
// Execute save and get the ID
650-
Object nodeId = this.falkorDBClient.query(cypher.toString(), properties, result -> {
694+
// Execute save and get the ID
695+
Object nodeId = this.falkorDBClient.query(cypher.toString(), regularParams, result -> {
651696
for (FalkorDBClient.Record record : result.records()) {
652697
return record.get("nodeId");
653698
}

src/main/java/org/springframework/data/falkordb/core/mapping/DefaultFalkorDBPersistentProperty.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
package org.springframework.data.falkordb.core.mapping;
2424

2525
import org.springframework.data.falkordb.core.schema.GeneratedValue;
26+
import org.springframework.data.falkordb.core.schema.Interned;
2627
import org.springframework.data.falkordb.core.schema.Property;
2728
import org.springframework.data.falkordb.core.schema.Relationship;
2829
import org.springframework.data.mapping.Association;
@@ -114,6 +115,15 @@ else if (StringUtils.hasText(relationshipAnnotation.value())) {
114115
return null;
115116
}
116117

118+
/**
119+
* Checks if this property should use FalkorDB's intern() function.
120+
* @return true if this property is marked with @Interned
121+
*/
122+
@Override
123+
public final boolean isInterned() {
124+
return isAnnotationPresent(Interned.class);
125+
}
126+
117127
/**
118128
* Creates an association for this property.
119129
* @return the association

src/main/java/org/springframework/data/falkordb/core/mapping/FalkorDBPersistentProperty.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,11 @@ public interface FalkorDBPersistentProperty extends PersistentProperty<FalkorDBP
6464
*/
6565
String getRelationshipType();
6666

67+
/**
68+
* Returns whether this property should use FalkorDB's intern() function for
69+
* low-cardinality string values.
70+
* @return true if this property is marked with @Interned
71+
*/
72+
boolean isInterned();
73+
6774
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright (c) 2023-2025 FalkorDB Ltd.
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
23+
package org.springframework.data.falkordb.core.schema;
24+
25+
import java.lang.annotation.Documented;
26+
import java.lang.annotation.ElementType;
27+
import java.lang.annotation.Retention;
28+
import java.lang.annotation.RetentionPolicy;
29+
import java.lang.annotation.Target;
30+
31+
import org.apiguardian.api.API;
32+
33+
/**
34+
* Annotation to mark a string property as low-cardinality, which will cause
35+
* FalkorDB's {@code intern()} function to be applied when writing the value
36+
* to the database. This helps FalkorDB optimize storage by keeping only a
37+
* single copy of frequently repeated string values.
38+
* <p>
39+
* This annotation is useful for properties like categories, status codes,
40+
* country codes, or any other string field with a limited set of possible values.
41+
* <p>
42+
* Example usage:
43+
* <pre>
44+
* {@code
45+
* @Node("User")
46+
* public class User {
47+
* @Id
48+
* private String id;
49+
*
50+
* @Interned
51+
* private String status; // e.g., "ACTIVE", "INACTIVE", "PENDING"
52+
*
53+
* @Interned
54+
* private String country; // e.g., "US", "UK", "CA"
55+
* }
56+
* }
57+
* </pre>
58+
*
59+
* @author Shahar Biron (FalkorDB)
60+
* @since 1.0
61+
*/
62+
@Retention(RetentionPolicy.RUNTIME)
63+
@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE })
64+
@Documented
65+
@API(status = API.Status.STABLE, since = "1.0")
66+
public @interface Interned {
67+
68+
}

src/main/java/org/springframework/data/falkordb/repository/config/EnableFalkorDBRepositories.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,10 @@
131131
*/
132132
boolean considerNestedRepositories() default false;
133133

134+
/**
135+
* Configures the repository factory bean class to be used.
136+
* @return repository factory bean class
137+
*/
138+
Class<?> repositoryFactoryBeanClass() default org.springframework.data.falkordb.repository.support.FalkorDBRepositoryFactoryBean.class;
139+
134140
}

0 commit comments

Comments
 (0)