SortSpec.java
package jasper.repository.spec;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Root;
import java.util.Set;
import java.util.regex.Pattern;
import static java.util.Arrays.stream;
/**
* Shared utility class for JSONB sorting logic.
* Used by entity-specific Spec classes (RefSpec, ExtSpec, UserSpec, PluginSpec, TemplateSpec).
*/
public class SortSpec {
private static final Pattern ARRAY_INDEX_PATTERN = Pattern.compile("\\[(\\d+)]");
/**
* Allowed metadata fields for sorting.
* Only these fields can be accessed under metadata->.
*/
private static final Set<String> ALLOWED_METADATA_FIELDS = Set.of(
"modified", "expandedTags", "responses", "internalResponses", "plugins"
);
/**
* Metadata fields that should automatically use :len suffix (array fields).
*/
private static final Set<String> METADATA_LEN_FIELDS = Set.of(
"expandedTags", "responses", "internalResponses"
);
/**
* Metadata fields that should automatically use :num suffix (numeric fields).
*/
private static final Set<String> METADATA_NUM_FIELDS = Set.of(
);
/**
* Creates a JSONB sort expression for the given property path.
* Supports ":num" suffix for numeric sorting and ":len" suffix for array length sorting.
* Uses COALESCE to handle nulls (0 for numeric/length, '' for string).
*
* For metadata fields, automatically applies the correct suffix and restricts access
* to only allowed fields: modified, expandedTags, responses, internalResponses, plugins.
*
* @param root the query root
* @param cb the criteria builder
* @param property the sort property (e.g., "config->field->subfield:num")
* @param prefixes list of allowed JSONB field prefixes (e.g., ["config", "defaults", "schema"])
* @return the sort expression, or null if property doesn't match allowed prefixes
*/
public static Expression<?> createJsonbSortExpression(Root<?> root, CriteriaBuilder cb, String property, String... prefixes) {
var numericSort = property.endsWith(":num");
var lengthSort = property.endsWith(":len");
if (property.contains(":")) property = property.substring(0, property.lastIndexOf(":"));
var parts = property.split("->");
if (parts.length < 2) return null;
var jsonbFieldName = parts[0];
if (stream(prefixes).noneMatch(jsonbFieldName::equals)) return null;
// Special handling for metadata prefix
if ("metadata".equals(jsonbFieldName)) {
var metadataField = parts[1];
// Only allow access to specific metadata fields
if (!ALLOWED_METADATA_FIELDS.contains(metadataField)) return null;
// Auto-apply correct suffix for known metadata fields (only if not already specified)
if (!numericSort && !lengthSort) {
if (METADATA_LEN_FIELDS.contains(metadataField)) {
lengthSort = true;
} else if (METADATA_NUM_FIELDS.contains(metadataField)) {
numericSort = true;
}
}
}
Expression<?> expr = root.get(jsonbFieldName);
for (int i = 1; i < parts.length; i++) {
var field = parts[i];
// Check for array index notation like "ids[0]"
var matcher = ARRAY_INDEX_PATTERN.matcher(field);
if (matcher.find()) {
var fieldName = field.substring(0, matcher.start());
var indexStr = matcher.group(1);
if (indexStr == null || !indexStr.matches("\\d+")) throw new IllegalArgumentException("Invalid array index in field: '" + field + "'");
var index = Integer.parseInt(indexStr);
if (!fieldName.isEmpty()) {
expr = cb.function("jsonb_object_field", Object.class, expr, cb.literal(fieldName));
}
expr = cb.function("jsonb_array_element_text", String.class, expr, cb.literal(index));
} else if (i == parts.length - 1 && lengthSort) {
// Last field with length sort - get as JSONB and apply jsonb_array_length
expr = cb.function("jsonb_object_field", Object.class, expr, cb.literal(field));
return cb.coalesce(cb.function("jsonb_array_length", Integer.class, expr), cb.literal(0));
} else if (i == parts.length - 1) {
// Last field - get as text
expr = cb.function("jsonb_object_field_text", String.class, expr, cb.literal(field));
} else {
// Intermediate field - get as JSONB object
expr = cb.function("jsonb_object_field", Object.class, expr, cb.literal(field));
}
}
if (numericSort) {
return cb.coalesce(cb.function("cast_to_numeric", Double.class, expr), cb.literal(0.0));
} else {
return cb.coalesce(expr, cb.literal(""));
}
}
/**
* Checks if a property is a JSONB sort property.
*/
public static boolean isJsonbSortProperty(String property, String... prefixes) {
return stream(prefixes).anyMatch(p -> property.startsWith(p + "->"));
}
/**
* Handles origin:len sorting (origin nesting level).
*/
public static Expression<?> createOriginNestingExpression(Root<?> root, CriteriaBuilder cb) {
return cb.function("origin_nesting", Integer.class, root.get("origin"));
}
/**
* Handles tag:len sorting (tag nesting levels).
*/
public static Expression<?> createTagLevelsExpression(Root<?> root, CriteriaBuilder cb) {
return cb.function("tag_levels", Integer.class, root.get("tag"));
}
/**
* Handles direct array field length sorting (e.g., "tags:len", "sources:len").
*/
public static Expression<?> createArrayLengthExpression(Root<?> root, CriteriaBuilder cb, String fieldName) {
return cb.coalesce(cb.function("jsonb_array_length", Integer.class, root.get(fieldName)), cb.literal(0));
}
/**
* Creates a sort expression for the tag field with binary collation (COLLATE "C").
* This ensures ASCII ordering where '+' comes before '_'.
*/
public static Expression<String> createTagSortExpression(Root<?> root, CriteriaBuilder cb) {
return cb.function("collate_c", String.class, root.get("tag"));
}
}