diff --git a/README.md b/README.md
index f3d9e73..8a69a65 100644
--- a/README.md
+++ b/README.md
@@ -556,6 +556,36 @@ var cosmos = new CosmosBuilder()
.build();
```
+### PostgreSQL typed index creation
+
+For PostgreSQL, java-cosmos automatically creates a default `GIN(data)` index when a table is created.
+If you need a field-level expression index such as `USING GIN ((data->'targetIdList'))`, you can create it with the typed `TableUtil` API.
+
+```java
+import io.github.thunderz99.cosmos.impl.postgres.PostgresImpl;
+import io.github.thunderz99.cosmos.impl.postgres.dto.IndexOption;
+import io.github.thunderz99.cosmos.impl.postgres.dto.PGIndexField;
+import io.github.thunderz99.cosmos.impl.postgres.util.TableUtil;
+
+try (var conn = ((PostgresImpl) cosmos).getDataSource().getConnection()) {
+ TableUtil.createIndexIfNotExist4SingleField(
+ conn,
+ "Database1",
+ "Collection1",
+ new PGIndexField("targetIdList"),
+ new IndexOption().gin()
+ );
+}
+```
+
+Notes:
+
+* `new IndexOption()` keeps the previous default behavior and creates a `BTREE` index.
+* `new IndexOption().gin()` creates a PostgreSQL expression `GIN` index for a single JSON field.
+* `GIN` currently supports single-field typed indexes only.
+* `unique + GIN` is not supported.
+* `GIN` typed indexes use the JSONB expression directly, so `fieldType(...)` casts are not supported in this mode.
+
### $ELEM_MATCH queries to match fields in array type field
Dealing with array types in json, we can do a query like this using rawSql to find a child whose grade greater than 5 and gender is "female".
diff --git a/pom.xml b/pom.xml
index de2cb84..07f0336 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
com.github.thunderz99
java-cosmos
jar
- 0.8.25
+ 0.8.26.RC1
${project.groupId}:${project.artifactId}$
A lightweight Azure CosmosDB client for Java
https://github.com/thunderz99/java-cosmos
diff --git a/src/main/java/io/github/thunderz99/cosmos/impl/postgres/dto/IndexOption.java b/src/main/java/io/github/thunderz99/cosmos/impl/postgres/dto/IndexOption.java
index 71b95c8..8a1e367 100644
--- a/src/main/java/io/github/thunderz99/cosmos/impl/postgres/dto/IndexOption.java
+++ b/src/main/java/io/github/thunderz99/cosmos/impl/postgres/dto/IndexOption.java
@@ -1,11 +1,21 @@
package io.github.thunderz99.cosmos.impl.postgres.dto;
/**
- * Options for index. currently only unique or not is supported
+ * Options for index.
*/
public class IndexOption {
+ public enum IndexMethod {
+ BTREE,
+ GIN
+ }
+
public boolean unique = false;
+ /**
+ * PostgreSQL index method. default is BTREE.
+ */
+ public IndexMethod indexMethod = IndexMethod.BTREE;
+
/**
* fieldType for this index. default is text. other valid value is bigint / numeric / float8 / etc
*
@@ -37,4 +47,23 @@ public IndexOption fieldType(String fieldType) {
return this;
}
+ /**
+ * set the index method. default is BTREE
+ * @param indexMethod
+ * @return indexOption
+ */
+ public IndexOption indexMethod(IndexMethod indexMethod) {
+ this.indexMethod = indexMethod;
+ return this;
+ }
+
+ /**
+ * use GIN as the index method
+ * @return indexOption
+ */
+ public IndexOption gin() {
+ this.indexMethod = IndexMethod.GIN;
+ return this;
+ }
+
}
diff --git a/src/main/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtil.java b/src/main/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtil.java
index 736f688..786b6e9 100644
--- a/src/main/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtil.java
+++ b/src/main/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtil.java
@@ -1737,18 +1737,14 @@ public static String createIndexIfNotExists(Connection conn, String schemaName,
// create index
// Construct JSON path expression
// data->'address'->'city'->>'street'
-
- var jsonPathExpression = PGKeyUtil.getFormattedKey(fieldName);
-
- if(SUPPORTED_INDEX_FIELD_TYPE.contains(indexOption.fieldType)){
- jsonPathExpression = "(%s)::%s".formatted(jsonPathExpression, indexOption.fieldType);
- }
+ var jsonPathExpression = buildIndexExpression(fieldName, indexOption);
+ var indexMethod = getIndexMethod(indexOption);
var createIndexSQL = """
CREATE %s INDEX IF NOT EXISTS %s
- ON %s.%s ((%s));
+ ON %s.%s USING %s ((%s));
"""
- .formatted(indexOption.unique ? "UNIQUE" : "", indexName, schemaName, tableName, jsonPathExpression);
+ .formatted(indexOption.unique ? "UNIQUE" : "", indexName, schemaName, tableName, indexMethod, jsonPathExpression);
stmt.execute(createIndexSQL);
if (log.isInfoEnabled()) {
@@ -1997,24 +1993,14 @@ public static String createIndexIfNotExist4MultiFields(Connection conn, String s
return "";
}
- // Construct composite JSON path expression e.g. ( ((data->>'lastName')), (((data->>'age')::integer)) )
- var jsonPathExpression = fields.stream()
- .map(field -> {
- var path = PGKeyUtil.getFormattedKey(field.fieldName);
- if (SUPPORTED_INDEX_FIELD_TYPE.contains(field.fieldType.toString())) {
- return "((%s)::%s)".formatted(path, field.fieldType);
- } else {
- // default to text if not a supported castable type
- return "(%s)".formatted(path);
- }
- })
- .collect(Collectors.joining(", "));
+ var indexMethod = getIndexMethod(indexOption);
+ var jsonPathExpression = buildIndexExpression(fields, indexOption);
var createIndexSQL = """
CREATE %s INDEX IF NOT EXISTS %s
- ON %s.%s (%s);
+ ON %s.%s USING %s (%s);
"""
- .formatted(indexOption.unique ? "UNIQUE" : "", indexName, schemaName, tableName, jsonPathExpression);
+ .formatted(indexOption.unique ? "UNIQUE" : "", indexName, schemaName, tableName, indexMethod, jsonPathExpression);
stmt.execute(createIndexSQL);
if (log.isInfoEnabled()) {
@@ -2041,6 +2027,95 @@ public static String createIndexIfNotExist4MultiFields(Connection conn, String s
return "%s.%s".formatted(schemaName, indexName);
}
+ /**
+ * Resolves the PostgreSQL index method keyword from {@link IndexOption}.
+ *
+ *
+ * The returned value is used directly in {@code CREATE INDEX ... USING }.
+ *
+ *
+ * @param indexOption the index option containing the desired method
+ * @return the PostgreSQL index method keyword, e.g. {@code BTREE} or {@code GIN}
+ */
+ static String getIndexMethod(IndexOption indexOption) {
+ Checker.checkNotNull(indexOption, "indexOption");
+ Checker.checkNotNull(indexOption.indexMethod, "indexMethod");
+ return indexOption.indexMethod.name();
+ }
+
+ /**
+ * Builds the PostgreSQL index expression for a single field.
+ *
+ *
+ * For BTREE indexes this returns the existing scalar expression, optionally with a cast
+ * such as {@code ((data->>'age')::numeric)}.
+ * For GIN indexes this returns a JSONB expression such as {@code data->'targetIdList'} so
+ * PostgreSQL can create an expression GIN index on the JSON sub-document.
+ *
+ *
+ * @param fieldName the field name or JSON path under the {@code data} column
+ * @param indexOption the index option that controls the method and expression shape
+ * @return the SQL expression used inside the index definition
+ */
+ static String buildIndexExpression(String fieldName, IndexOption indexOption) {
+ Checker.checkNotBlank(fieldName, "fieldName");
+ Checker.checkNotNull(indexOption, "indexOption");
+
+ if (IndexOption.IndexMethod.GIN.equals(indexOption.indexMethod)) {
+ Checker.check(!indexOption.unique, "GIN index does not support unique=true");
+ Checker.check("text".equalsIgnoreCase(indexOption.fieldType) || "jsonb".equalsIgnoreCase(indexOption.fieldType),
+ "GIN index does not support fieldType cast. use default fieldType/text for jsonb expression indexes");
+ return PGKeyUtil.getFormattedKey4JsonWithAlias(fieldName, DATA);
+ }
+
+ var jsonPathExpression = PGKeyUtil.getFormattedKey(fieldName);
+ if (SUPPORTED_INDEX_FIELD_TYPE.contains(indexOption.fieldType)) {
+ return "(%s)::%s".formatted(jsonPathExpression, indexOption.fieldType);
+ }
+ return jsonPathExpression;
+ }
+
+ /**
+ * Builds the PostgreSQL index expression list for typed index creation.
+ *
+ *
+ * BTREE supports the existing multi-field scalar expressions.
+ * GIN is intentionally limited to a single JSONB field so callers can create expression
+ * indexes such as {@code USING GIN ((data->'targetIdList'))} for array/object queries.
+ *
+ *
+ * @param fields the fields to be indexed
+ * @param indexOption the index option that controls the method and validation rules
+ * @return the SQL expression list used inside the index definition
+ */
+ static String buildIndexExpression(List fields, IndexOption indexOption) {
+ Checker.check(CollectionUtils.isNotEmpty(fields), "fields cannot be empty");
+ Checker.checkNotNull(indexOption, "indexOption");
+
+ if (IndexOption.IndexMethod.GIN.equals(indexOption.indexMethod)) {
+ Checker.check(!indexOption.unique, "GIN index does not support unique=true");
+ Checker.check(fields.size() == 1, "GIN index currently only supports a single field");
+
+ var field = fields.get(0);
+ Checker.check(field.fieldType == PGFieldType.TEXT || field.fieldType == PGFieldType.JSONB,
+ "GIN index does not support fieldType cast. use PGFieldType.TEXT/JSONB or omit fieldType");
+ return "(%s)".formatted(PGKeyUtil.getFormattedKey4JsonWithAlias(field.fieldName, DATA));
+ }
+
+ // Construct composite JSON path expression e.g. ( ((data->>'lastName')), (((data->>'age')::integer)) )
+ return fields.stream()
+ .map(field -> {
+ var path = PGKeyUtil.getFormattedKey(field.fieldName);
+ if (SUPPORTED_INDEX_FIELD_TYPE.contains(field.fieldType.toString())) {
+ return "((%s)::%s)".formatted(path, field.fieldType);
+ } else {
+ // default to text if not a supported castable type
+ return "(%s)".formatted(path);
+ }
+ })
+ .collect(Collectors.joining(", "));
+ }
+
/**
* Sets the parameters for the given {@link PreparedStatement} with the given
* parameters. The parameters are expected to be in the same order as the
diff --git a/src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/PGConditionUtilTest.java b/src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/PGConditionUtilTest.java
index 26600a2..751a351 100644
--- a/src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/PGConditionUtilTest.java
+++ b/src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/PGConditionUtilTest.java
@@ -171,6 +171,55 @@ void buildQuerySpec_should_work_for_sub_cond_or() {
}
}
+ @Test
+ void buildQuerySpec_should_work_for_target_id_list_array_contains() {
+ var cond = Condition.filter("targetIdList ARRAY_CONTAINS", "a");
+ var querySpec = PGConditionUtil.toQuerySpec(coll, cond, partition);
+
+ var expectedSQL = """
+ SELECT *
+ FROM schema1.table1
+ WHERE (data->'targetIdList' ?? @param000_targetIdList) OFFSET 0 LIMIT 100
+ """;
+ assertThat(querySpec.getQueryText().trim()).isEqualTo(expectedSQL.trim());
+ assertThat(querySpec.getParameters()).containsExactly(
+ new CosmosSqlParameter("@param000_targetIdList", "a")
+ );
+ }
+
+ @Test
+ void buildQuerySpec_should_work_for_target_id_list_array_contains_any() {
+ var cond = Condition.filter("targetIdList ARRAY_CONTAINS_ANY", List.of("a", "b"));
+ var querySpec = PGConditionUtil.toQuerySpec(coll, cond, partition);
+
+ var expectedSQL = """
+ SELECT *
+ FROM schema1.table1
+ WHERE (data->'targetIdList' @> @param000_targetIdList__0::jsonb OR data->'targetIdList' @> @param000_targetIdList__1::jsonb) OFFSET 0 LIMIT 100
+ """;
+ assertThat(querySpec.getQueryText().trim()).isEqualTo(expectedSQL.trim());
+ assertThat(querySpec.getParameters()).containsExactly(
+ new CosmosSqlParameter("@param000_targetIdList__0", "\"a\""),
+ new CosmosSqlParameter("@param000_targetIdList__1", "\"b\"")
+ );
+ }
+
+ @Test
+ void buildQuerySpec_should_not_generate_whole_document_contains_for_target_id_list() {
+ var cond = Condition.filter("targetIdList =", List.of("a"));
+ var querySpec = PGConditionUtil.toQuerySpec(coll, cond, partition);
+
+ var expectedSQL = """
+ SELECT *
+ FROM schema1.table1
+ WHERE (data->'targetIdList' = (@param000_targetIdList)::jsonb) OFFSET 0 LIMIT 100
+ """;
+ assertThat(querySpec.getQueryText().trim()).isEqualTo(expectedSQL.trim());
+ assertThat(querySpec.getParameters()).containsExactly(
+ new CosmosSqlParameter("@param000_targetIdList", JsonUtil.toJson(List.of("a")))
+ );
+ }
+
@Test
void buildQuerySpec_should_work_for_and_nested_with_or() {
@@ -1170,4 +1219,4 @@ SELECT COUNT(1) AS "facetCount", data->>'formId' AS "formId"
}
-}
\ No newline at end of file
+}
diff --git a/src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtilTest.java b/src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtilTest.java
index b348b4a..bcf1c52 100644
--- a/src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtilTest.java
+++ b/src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtilTest.java
@@ -1221,6 +1221,36 @@ void createIndexIfNotExist4SingleField_should_work() throws Exception {
assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4SingleField(conn, schemaName, tableName, null, new IndexOption()))
.isInstanceOf(NullPointerException.class);
}
+
+ // 4. Test GIN index on a single jsonb field
+ {
+ var ginField = new PGIndexField("targetIdList");
+ var ginIndexName = TableUtil.getIndexName(tableName, ginField.fieldName);
+
+ assertThat(TableUtil.indexExistsByName(conn, schemaName, tableName, ginIndexName)).isFalse();
+
+ var created = TableUtil.createIndexIfNotExist4SingleField(conn, schemaName, tableName, ginField, new IndexOption().gin());
+ assertThat(created).isEqualTo(formattedSchemaName + "." + TableUtil.checkAndNormalizeValidEntityName(ginIndexName));
+
+ var indexMap = findIndexes(conn, schemaName, tableName);
+ var indexDef = indexMap.get(TableUtil.removeQuotes(TableUtil.checkAndNormalizeValidEntityName(ginIndexName)));
+ assertThat(indexDef).isNotNull()
+ .containsIgnoringCase("USING GIN")
+ .contains("data -> 'targetIdList'::text");
+ }
+
+ // 5. Test invalid GIN combinations
+ {
+ assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4SingleField(conn, schemaName, tableName,
+ new PGIndexField("ginUniqueField"), IndexOption.unique(true).gin()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("GIN index does not support unique=true");
+
+ assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4SingleField(conn, schemaName, tableName,
+ new PGIndexField("ginNumericField", PGFieldType.INTEGER), new IndexOption().gin()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("GIN index does not support fieldType cast. use PGFieldType.TEXT/JSONB or omit fieldType");
+ }
} finally {
try (var conn = cosmos.getDataSource().getConnection()) {
TableUtil.dropTableIfExists(conn, schemaName, tableName);
@@ -1297,6 +1327,11 @@ void createIndexIfNotExist4MultiFields_should_work() throws Exception {
assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4MultiFields(conn, schemaName, tableName, null, new IndexOption()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("fields cannot be empty");
+
+ assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4MultiFields(conn, schemaName, tableName,
+ List.of(new PGIndexField("targetIdList"), new PGIndexField("otherField")), new IndexOption().gin()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("GIN index currently only supports a single field");
}
} finally {
@@ -1372,4 +1407,4 @@ void dropIndexIfExists_shouldDropExistingIndex() throws Exception {
}
}
}
-}
\ No newline at end of file
+}