Skip to content

Commit 03cd9ef

Browse files
authored
feat: support GIN index type for postgres (#218)
1 parent 6114bf3 commit 03cd9ef

6 files changed

Lines changed: 244 additions & 26 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,36 @@ var cosmos = new CosmosBuilder()
556556
.build();
557557
```
558558

559+
### PostgreSQL typed index creation
560+
561+
For PostgreSQL, java-cosmos automatically creates a default `GIN(data)` index when a table is created.
562+
If you need a field-level expression index such as `USING GIN ((data->'targetIdList'))`, you can create it with the typed `TableUtil` API.
563+
564+
```java
565+
import io.github.thunderz99.cosmos.impl.postgres.PostgresImpl;
566+
import io.github.thunderz99.cosmos.impl.postgres.dto.IndexOption;
567+
import io.github.thunderz99.cosmos.impl.postgres.dto.PGIndexField;
568+
import io.github.thunderz99.cosmos.impl.postgres.util.TableUtil;
569+
570+
try (var conn = ((PostgresImpl) cosmos).getDataSource().getConnection()) {
571+
TableUtil.createIndexIfNotExist4SingleField(
572+
conn,
573+
"Database1",
574+
"Collection1",
575+
new PGIndexField("targetIdList"),
576+
new IndexOption().gin()
577+
);
578+
}
579+
```
580+
581+
Notes:
582+
583+
* `new IndexOption()` keeps the previous default behavior and creates a `BTREE` index.
584+
* `new IndexOption().gin()` creates a PostgreSQL expression `GIN` index for a single JSON field.
585+
* `GIN` currently supports single-field typed indexes only.
586+
* `unique + GIN` is not supported.
587+
* `GIN` typed indexes use the JSONB expression directly, so `fieldType(...)` casts are not supported in this mode.
588+
559589
### $ELEM_MATCH queries to match fields in array type field
560590

561591
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".

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<groupId>com.github.thunderz99</groupId>
55
<artifactId>java-cosmos</artifactId>
66
<packaging>jar</packaging>
7-
<version>0.8.25</version>
7+
<version>0.8.26.RC1</version>
88
<name>${project.groupId}:${project.artifactId}$</name>
99
<description>A lightweight Azure CosmosDB client for Java</description>
1010
<url>https://github.com/thunderz99/java-cosmos</url>

src/main/java/io/github/thunderz99/cosmos/impl/postgres/dto/IndexOption.java

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
package io.github.thunderz99.cosmos.impl.postgres.dto;
22

33
/**
4-
* Options for index. currently only unique or not is supported
4+
* Options for index.
55
*/
66
public class IndexOption {
7+
public enum IndexMethod {
8+
BTREE,
9+
GIN
10+
}
11+
712
public boolean unique = false;
813

14+
/**
15+
* PostgreSQL index method. default is BTREE.
16+
*/
17+
public IndexMethod indexMethod = IndexMethod.BTREE;
18+
919
/**
1020
* fieldType for this index. default is text. other valid value is bigint / numeric / float8 / etc
1121
*
@@ -37,4 +47,23 @@ public IndexOption fieldType(String fieldType) {
3747
return this;
3848
}
3949

50+
/**
51+
* set the index method. default is BTREE
52+
* @param indexMethod
53+
* @return indexOption
54+
*/
55+
public IndexOption indexMethod(IndexMethod indexMethod) {
56+
this.indexMethod = indexMethod;
57+
return this;
58+
}
59+
60+
/**
61+
* use GIN as the index method
62+
* @return indexOption
63+
*/
64+
public IndexOption gin() {
65+
this.indexMethod = IndexMethod.GIN;
66+
return this;
67+
}
68+
4069
}

src/main/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtil.java

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,18 +1737,14 @@ public static String createIndexIfNotExists(Connection conn, String schemaName,
17371737
// create index
17381738
// Construct JSON path expression
17391739
// data->'address'->'city'->>'street'
1740-
1741-
var jsonPathExpression = PGKeyUtil.getFormattedKey(fieldName);
1742-
1743-
if(SUPPORTED_INDEX_FIELD_TYPE.contains(indexOption.fieldType)){
1744-
jsonPathExpression = "(%s)::%s".formatted(jsonPathExpression, indexOption.fieldType);
1745-
}
1740+
var jsonPathExpression = buildIndexExpression(fieldName, indexOption);
1741+
var indexMethod = getIndexMethod(indexOption);
17461742

17471743
var createIndexSQL = """
17481744
CREATE %s INDEX IF NOT EXISTS %s
1749-
ON %s.%s ((%s));
1745+
ON %s.%s USING %s ((%s));
17501746
"""
1751-
.formatted(indexOption.unique ? "UNIQUE" : "", indexName, schemaName, tableName, jsonPathExpression);
1747+
.formatted(indexOption.unique ? "UNIQUE" : "", indexName, schemaName, tableName, indexMethod, jsonPathExpression);
17521748

17531749
stmt.execute(createIndexSQL);
17541750
if (log.isInfoEnabled()) {
@@ -1997,24 +1993,14 @@ public static String createIndexIfNotExist4MultiFields(Connection conn, String s
19971993
return "";
19981994
}
19991995

2000-
// Construct composite JSON path expression e.g. ( ((data->>'lastName')), (((data->>'age')::integer)) )
2001-
var jsonPathExpression = fields.stream()
2002-
.map(field -> {
2003-
var path = PGKeyUtil.getFormattedKey(field.fieldName);
2004-
if (SUPPORTED_INDEX_FIELD_TYPE.contains(field.fieldType.toString())) {
2005-
return "((%s)::%s)".formatted(path, field.fieldType);
2006-
} else {
2007-
// default to text if not a supported castable type
2008-
return "(%s)".formatted(path);
2009-
}
2010-
})
2011-
.collect(Collectors.joining(", "));
1996+
var indexMethod = getIndexMethod(indexOption);
1997+
var jsonPathExpression = buildIndexExpression(fields, indexOption);
20121998

20131999
var createIndexSQL = """
20142000
CREATE %s INDEX IF NOT EXISTS %s
2015-
ON %s.%s (%s);
2001+
ON %s.%s USING %s (%s);
20162002
"""
2017-
.formatted(indexOption.unique ? "UNIQUE" : "", indexName, schemaName, tableName, jsonPathExpression);
2003+
.formatted(indexOption.unique ? "UNIQUE" : "", indexName, schemaName, tableName, indexMethod, jsonPathExpression);
20182004

20192005
stmt.execute(createIndexSQL);
20202006
if (log.isInfoEnabled()) {
@@ -2041,6 +2027,95 @@ public static String createIndexIfNotExist4MultiFields(Connection conn, String s
20412027
return "%s.%s".formatted(schemaName, indexName);
20422028
}
20432029

2030+
/**
2031+
* Resolves the PostgreSQL index method keyword from {@link IndexOption}.
2032+
*
2033+
* <p>
2034+
* The returned value is used directly in {@code CREATE INDEX ... USING <method>}.
2035+
* </p>
2036+
*
2037+
* @param indexOption the index option containing the desired method
2038+
* @return the PostgreSQL index method keyword, e.g. {@code BTREE} or {@code GIN}
2039+
*/
2040+
static String getIndexMethod(IndexOption indexOption) {
2041+
Checker.checkNotNull(indexOption, "indexOption");
2042+
Checker.checkNotNull(indexOption.indexMethod, "indexMethod");
2043+
return indexOption.indexMethod.name();
2044+
}
2045+
2046+
/**
2047+
* Builds the PostgreSQL index expression for a single field.
2048+
*
2049+
* <p>
2050+
* For BTREE indexes this returns the existing scalar expression, optionally with a cast
2051+
* such as {@code ((data->>'age')::numeric)}.
2052+
* For GIN indexes this returns a JSONB expression such as {@code data->'targetIdList'} so
2053+
* PostgreSQL can create an expression GIN index on the JSON sub-document.
2054+
* </p>
2055+
*
2056+
* @param fieldName the field name or JSON path under the {@code data} column
2057+
* @param indexOption the index option that controls the method and expression shape
2058+
* @return the SQL expression used inside the index definition
2059+
*/
2060+
static String buildIndexExpression(String fieldName, IndexOption indexOption) {
2061+
Checker.checkNotBlank(fieldName, "fieldName");
2062+
Checker.checkNotNull(indexOption, "indexOption");
2063+
2064+
if (IndexOption.IndexMethod.GIN.equals(indexOption.indexMethod)) {
2065+
Checker.check(!indexOption.unique, "GIN index does not support unique=true");
2066+
Checker.check("text".equalsIgnoreCase(indexOption.fieldType) || "jsonb".equalsIgnoreCase(indexOption.fieldType),
2067+
"GIN index does not support fieldType cast. use default fieldType/text for jsonb expression indexes");
2068+
return PGKeyUtil.getFormattedKey4JsonWithAlias(fieldName, DATA);
2069+
}
2070+
2071+
var jsonPathExpression = PGKeyUtil.getFormattedKey(fieldName);
2072+
if (SUPPORTED_INDEX_FIELD_TYPE.contains(indexOption.fieldType)) {
2073+
return "(%s)::%s".formatted(jsonPathExpression, indexOption.fieldType);
2074+
}
2075+
return jsonPathExpression;
2076+
}
2077+
2078+
/**
2079+
* Builds the PostgreSQL index expression list for typed index creation.
2080+
*
2081+
* <p>
2082+
* BTREE supports the existing multi-field scalar expressions.
2083+
* GIN is intentionally limited to a single JSONB field so callers can create expression
2084+
* indexes such as {@code USING GIN ((data->'targetIdList'))} for array/object queries.
2085+
* </p>
2086+
*
2087+
* @param fields the fields to be indexed
2088+
* @param indexOption the index option that controls the method and validation rules
2089+
* @return the SQL expression list used inside the index definition
2090+
*/
2091+
static String buildIndexExpression(List<PGIndexField> fields, IndexOption indexOption) {
2092+
Checker.check(CollectionUtils.isNotEmpty(fields), "fields cannot be empty");
2093+
Checker.checkNotNull(indexOption, "indexOption");
2094+
2095+
if (IndexOption.IndexMethod.GIN.equals(indexOption.indexMethod)) {
2096+
Checker.check(!indexOption.unique, "GIN index does not support unique=true");
2097+
Checker.check(fields.size() == 1, "GIN index currently only supports a single field");
2098+
2099+
var field = fields.get(0);
2100+
Checker.check(field.fieldType == PGFieldType.TEXT || field.fieldType == PGFieldType.JSONB,
2101+
"GIN index does not support fieldType cast. use PGFieldType.TEXT/JSONB or omit fieldType");
2102+
return "(%s)".formatted(PGKeyUtil.getFormattedKey4JsonWithAlias(field.fieldName, DATA));
2103+
}
2104+
2105+
// Construct composite JSON path expression e.g. ( ((data->>'lastName')), (((data->>'age')::integer)) )
2106+
return fields.stream()
2107+
.map(field -> {
2108+
var path = PGKeyUtil.getFormattedKey(field.fieldName);
2109+
if (SUPPORTED_INDEX_FIELD_TYPE.contains(field.fieldType.toString())) {
2110+
return "((%s)::%s)".formatted(path, field.fieldType);
2111+
} else {
2112+
// default to text if not a supported castable type
2113+
return "(%s)".formatted(path);
2114+
}
2115+
})
2116+
.collect(Collectors.joining(", "));
2117+
}
2118+
20442119
/**
20452120
* Sets the parameters for the given {@link PreparedStatement} with the given
20462121
* parameters. The parameters are expected to be in the same order as the

src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/PGConditionUtilTest.java

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,55 @@ void buildQuerySpec_should_work_for_sub_cond_or() {
171171
}
172172
}
173173

174+
@Test
175+
void buildQuerySpec_should_work_for_target_id_list_array_contains() {
176+
var cond = Condition.filter("targetIdList ARRAY_CONTAINS", "a");
177+
var querySpec = PGConditionUtil.toQuerySpec(coll, cond, partition);
178+
179+
var expectedSQL = """
180+
SELECT *
181+
FROM schema1.table1
182+
WHERE (data->'targetIdList' ?? @param000_targetIdList) OFFSET 0 LIMIT 100
183+
""";
184+
assertThat(querySpec.getQueryText().trim()).isEqualTo(expectedSQL.trim());
185+
assertThat(querySpec.getParameters()).containsExactly(
186+
new CosmosSqlParameter("@param000_targetIdList", "a")
187+
);
188+
}
189+
190+
@Test
191+
void buildQuerySpec_should_work_for_target_id_list_array_contains_any() {
192+
var cond = Condition.filter("targetIdList ARRAY_CONTAINS_ANY", List.of("a", "b"));
193+
var querySpec = PGConditionUtil.toQuerySpec(coll, cond, partition);
194+
195+
var expectedSQL = """
196+
SELECT *
197+
FROM schema1.table1
198+
WHERE (data->'targetIdList' @> @param000_targetIdList__0::jsonb OR data->'targetIdList' @> @param000_targetIdList__1::jsonb) OFFSET 0 LIMIT 100
199+
""";
200+
assertThat(querySpec.getQueryText().trim()).isEqualTo(expectedSQL.trim());
201+
assertThat(querySpec.getParameters()).containsExactly(
202+
new CosmosSqlParameter("@param000_targetIdList__0", "\"a\""),
203+
new CosmosSqlParameter("@param000_targetIdList__1", "\"b\"")
204+
);
205+
}
206+
207+
@Test
208+
void buildQuerySpec_should_not_generate_whole_document_contains_for_target_id_list() {
209+
var cond = Condition.filter("targetIdList =", List.of("a"));
210+
var querySpec = PGConditionUtil.toQuerySpec(coll, cond, partition);
211+
212+
var expectedSQL = """
213+
SELECT *
214+
FROM schema1.table1
215+
WHERE (data->'targetIdList' = (@param000_targetIdList)::jsonb) OFFSET 0 LIMIT 100
216+
""";
217+
assertThat(querySpec.getQueryText().trim()).isEqualTo(expectedSQL.trim());
218+
assertThat(querySpec.getParameters()).containsExactly(
219+
new CosmosSqlParameter("@param000_targetIdList", JsonUtil.toJson(List.of("a")))
220+
);
221+
}
222+
174223

175224
@Test
176225
void buildQuerySpec_should_work_for_and_nested_with_or() {
@@ -1170,4 +1219,4 @@ SELECT COUNT(1) AS "facetCount", data->>'formId' AS "formId"
11701219

11711220
}
11721221

1173-
}
1222+
}

src/test/java/io/github/thunderz99/cosmos/impl/postgres/util/TableUtilTest.java

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,36 @@ void createIndexIfNotExist4SingleField_should_work() throws Exception {
12211221
assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4SingleField(conn, schemaName, tableName, null, new IndexOption()))
12221222
.isInstanceOf(NullPointerException.class);
12231223
}
1224+
1225+
// 4. Test GIN index on a single jsonb field
1226+
{
1227+
var ginField = new PGIndexField("targetIdList");
1228+
var ginIndexName = TableUtil.getIndexName(tableName, ginField.fieldName);
1229+
1230+
assertThat(TableUtil.indexExistsByName(conn, schemaName, tableName, ginIndexName)).isFalse();
1231+
1232+
var created = TableUtil.createIndexIfNotExist4SingleField(conn, schemaName, tableName, ginField, new IndexOption().gin());
1233+
assertThat(created).isEqualTo(formattedSchemaName + "." + TableUtil.checkAndNormalizeValidEntityName(ginIndexName));
1234+
1235+
var indexMap = findIndexes(conn, schemaName, tableName);
1236+
var indexDef = indexMap.get(TableUtil.removeQuotes(TableUtil.checkAndNormalizeValidEntityName(ginIndexName)));
1237+
assertThat(indexDef).isNotNull()
1238+
.containsIgnoringCase("USING GIN")
1239+
.contains("data -> 'targetIdList'::text");
1240+
}
1241+
1242+
// 5. Test invalid GIN combinations
1243+
{
1244+
assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4SingleField(conn, schemaName, tableName,
1245+
new PGIndexField("ginUniqueField"), IndexOption.unique(true).gin()))
1246+
.isInstanceOf(IllegalArgumentException.class)
1247+
.hasMessage("GIN index does not support unique=true");
1248+
1249+
assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4SingleField(conn, schemaName, tableName,
1250+
new PGIndexField("ginNumericField", PGFieldType.INTEGER), new IndexOption().gin()))
1251+
.isInstanceOf(IllegalArgumentException.class)
1252+
.hasMessage("GIN index does not support fieldType cast. use PGFieldType.TEXT/JSONB or omit fieldType");
1253+
}
12241254
} finally {
12251255
try (var conn = cosmos.getDataSource().getConnection()) {
12261256
TableUtil.dropTableIfExists(conn, schemaName, tableName);
@@ -1297,6 +1327,11 @@ void createIndexIfNotExist4MultiFields_should_work() throws Exception {
12971327
assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4MultiFields(conn, schemaName, tableName, null, new IndexOption()))
12981328
.isInstanceOf(IllegalArgumentException.class)
12991329
.hasMessage("fields cannot be empty");
1330+
1331+
assertThatThrownBy(() -> TableUtil.createIndexIfNotExist4MultiFields(conn, schemaName, tableName,
1332+
List.of(new PGIndexField("targetIdList"), new PGIndexField("otherField")), new IndexOption().gin()))
1333+
.isInstanceOf(IllegalArgumentException.class)
1334+
.hasMessage("GIN index currently only supports a single field");
13001335
}
13011336

13021337
} finally {
@@ -1372,4 +1407,4 @@ void dropIndexIfExists_shouldDropExistingIndex() throws Exception {
13721407
}
13731408
}
13741409
}
1375-
}
1410+
}

0 commit comments

Comments
 (0)