Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<groupId>com.github.thunderz99</groupId>
<artifactId>java-cosmos</artifactId>
<packaging>jar</packaging>
<version>0.8.25</version>
<version>0.8.26.RC1</version>
<name>${project.groupId}:${project.artifactId}$</name>
<description>A lightweight Azure CosmosDB client for Java</description>
<url>https://github.com/thunderz99/java-cosmos</url>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
*
Expand Down Expand Up @@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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()) {
Expand All @@ -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}.
*
* <p>
* The returned value is used directly in {@code CREATE INDEX ... USING <method>}.
* </p>
*
* @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.
*
* <p>
* 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.
* </p>
*
* @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.
*
* <p>
* 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.
* </p>
*
* @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<PGIndexField> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -1170,4 +1219,4 @@ SELECT COUNT(1) AS "facetCount", data->>'formId' AS "formId"

}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1372,4 +1407,4 @@ void dropIndexIfExists_shouldDropExistingIndex() throws Exception {
}
}
}
}
}
Loading