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 +}