diff --git a/CHANGELOG.md b/CHANGELOG.md index aea57a6a6d2c..5cc055fa48f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ This file lists Solr's raw release notes with details of every change to Solr. M - Added new ConcurrentUpdateJdkSolrClient that works with HttpJdkSolrClient [SOLR-18065](https://issues.apache.org/jira/browse/SOLR-18065) (James Dyer) - Support including stored fields in Export Writer output. [SOLR-18071](https://issues.apache.org/jira/browse/SOLR-18071) (Luke Kot-Zaniewski) - Introducing support for multi valued dense vector representation in documents through nested vectors [SOLR-18074](https://issues.apache.org/jira/browse/SOLR-18074) (Alessandro Benedetti) -- CoreAdmin API (/admin/cores?action=UPGRADECOREINDEX) to upgrade an index in-place [SOLR-18096](https://issues.apache.org/jira/browse/SOLR-18096) (Rahul Goswami) +- CoreAdmin API (/admin/cores?action=UPGRADEINDEX) to upgrade an index in-place [SOLR-18096](https://issues.apache.org/jira/browse/SOLR-18096) (Rahul Goswami) - CrossDC Consumer - add Prometheus metrics [SOLR-18060](https://issues.apache.org/jira/browse/SOLR-18060) (Andrzej Bialecki @ab) - CrossDC - support arbitrary Kafka properties [SOLR-18062](https://issues.apache.org/jira/browse/SOLR-18062) (Andrzej Bialecki @ab) diff --git a/changelog/unreleased/PR#4367-filestore-getmetadata-delete-race.yml b/changelog/unreleased/PR#4367-filestore-getmetadata-delete-race.yml new file mode 100644 index 000000000000..96be57260aa1 --- /dev/null +++ b/changelog/unreleased/PR#4367-filestore-getmetadata-delete-race.yml @@ -0,0 +1,7 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Filestore metadata API no longer returns HTTP 500 when a file is deleted concurrently with a metadata read; the response now matches the not-found case (null entry). +type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other +authors: + - name: Eric Pugh +links: + - https://github.com/apache/solr/pull/4367 diff --git a/changelog/unreleased/SOLR-17924-configset-creation-new-ui.yml b/changelog/unreleased/SOLR-17924-configset-creation-new-ui.yml new file mode 100644 index 000000000000..b586b35ab145 --- /dev/null +++ b/changelog/unreleased/SOLR-17924-configset-creation-new-ui.yml @@ -0,0 +1,10 @@ +title: SOLR 17845 - Implement Configset Creation and Importing in New UI +type: added +authors: + - name: Christos Malliaridis + nick: malliaridis +links: + - name: SOLR-17924 + url: https://issues.apache.org/jira/browse/SOLR-17924 + - name: PR#4166 + url: https://github.com/apache/solr/pull/4166 diff --git a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml new file mode 100644 index 000000000000..b850dfe995a9 --- /dev/null +++ b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Schema Designer sample-doc analysis now works correctly when analyze sample documents. +type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other +authors: + - name: Eric Pugh +links: + - name: SOLR-18152 + url: https://issues.apache.org/jira/browse/SOLR-18152 diff --git a/changelog/unreleased/SOLR-18198-support-missing-stats-count-in-rollup.yml b/changelog/unreleased/SOLR-18198-support-missing-stats-count-in-rollup.yml new file mode 100644 index 000000000000..f614517e3689 --- /dev/null +++ b/changelog/unreleased/SOLR-18198-support-missing-stats-count-in-rollup.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: "Support 'missing' stats count in rollup function for streaming expressions" +type: added +authors: + - name: khushjain +links: + - name: SOLR-18198 + url: https://issues.apache.org/jira/browse/SOLR-18198 diff --git a/changelog/unreleased/SOLR-18210-fix-GPU-vector-indexing.yml b/changelog/unreleased/SOLR-18210-fix-GPU-vector-indexing.yml new file mode 100644 index 000000000000..54a479f18f37 --- /dev/null +++ b/changelog/unreleased/SOLR-18210-fix-GPU-vector-indexing.yml @@ -0,0 +1,7 @@ +title: Fix for GPU vector indexing silently falling back to using CPU instead +type: fixed +authors: + - name: Rahul Goswami +links: + - name: SOLR-18210 + url: https://issues.apache.org/jira/browse/SOLR-18210 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6c402e8d9b9..7018c17a31ec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,9 @@ [versions] adobe-testing-s3mock = "3.12.0" amazon-awssdk = "2.42.34" +androidx-adaptive = "1.3.0-alpha07" +androidx-lifecycle = "2.10.0" +androidx-navigation3 = "1.1.0" # @keep Antora version used in ref-guide antora = "3.1.12" # @keep Most recent commit as of 2025-08-03, this repo does not have tags @@ -236,6 +239,12 @@ amazon-awssdk-retries-spi = { module = "software.amazon.awssdk:retries-spi", ver amazon-awssdk-s3 = { module = "software.amazon.awssdk:s3", version.ref = "amazon-awssdk" } amazon-awssdk-sdkcore = { module = "software.amazon.awssdk:sdk-core", version.ref = "amazon-awssdk" } amazon-awssdk-sts = { module = "software.amazon.awssdk:sts", version.ref = "amazon-awssdk" } +androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewModelNav3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" } +androidx-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "androidx-adaptive" } +androidx-material3-adaptive-nav3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "androidx-adaptive" } +androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3" } apache-calcite-avatica-core = { module = "org.apache.calcite.avatica:avatica-core", version.ref = "apache-calcite-avatica" } apache-calcite-core = { module = "org.apache.calcite:calcite-core", version.ref = "apache-calcite" } apache-calcite-linq4j = { module = "org.apache.calcite:calcite-linq4j", version.ref = "apache-calcite" } diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java new file mode 100644 index 000000000000..09c66395e068 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -0,0 +1,200 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.endpoint; + +import static org.apache.solr.client.api.util.Constants.GENERIC_ENTITY_PROPERTY; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import java.io.InputStream; +import java.util.List; +import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SchemaDesignerAddRequestBody; +import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; +import org.apache.solr.client.api.model.SchemaDesignerConfigsResponse; +import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; +import org.apache.solr.client.api.model.SchemaDesignerPublishResponse; +import org.apache.solr.client.api.model.SchemaDesignerResponse; +import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; +import org.apache.solr.client.api.model.SchemaDesignerUpdateRequestBody; +import org.apache.solr.client.api.model.SolrJerseyResponse; + +/** V2 API definitions for the Solr Schema Designer. */ +@Path("/schema-designer") +public interface SchemaDesignerApi { + + @GET + @Path("/{configSet}/info") + @Operation( + summary = "Get info about a configSet being designed.", + tags = {"schema-designer"}) + SchemaDesignerInfoResponse getInfo(@PathParam("configSet") String configSet) throws Exception; + + @POST + @Path("/{configSet}/prep") + @Operation( + summary = "Prepare a mutable configSet copy for schema design.", + tags = {"schema-designer"}) + SchemaDesignerResponse prepNewSchema( + @PathParam("configSet") String configSet, @QueryParam("copyFrom") String copyFrom) + throws Exception; + + @DELETE + @Path("/{configSet}") + @Operation( + summary = "Clean up temporary resources for a schema being designed.", + tags = {"schema-designer"}) + SolrJerseyResponse cleanupTempSchema(@PathParam("configSet") String configSet) throws Exception; + + @PUT + @Path("/{configSet}/file") + @Operation( + summary = "Update the contents of a file in a configSet being designed.", + tags = {"schema-designer"}) + SchemaDesignerResponse updateFileContents( + @PathParam("configSet") String configSet, + @QueryParam("file") String file, + @RequestBody( + required = true, + extensions = { + @Extension( + properties = { + @ExtensionProperty(name = GENERIC_ENTITY_PROPERTY, value = "true") + }) + }) + InputStream fileContents) + throws Exception; + + @GET + @Path("/{configSet}/sample") + @Operation( + summary = "Get a sample value and analysis for a field.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse getSampleValue( + @PathParam("configSet") String configSet, + @QueryParam("field") String fieldName, + @QueryParam("uniqueKeyField") String idField, + @QueryParam("docId") String docId) + throws Exception; + + @GET + @Path("/{configSet}/collectionsForConfig") + @Operation( + summary = "List collections that use a given configSet.", + tags = {"schema-designer"}) + SchemaDesignerCollectionsResponse listCollectionsForConfig( + @PathParam("configSet") String configSet) throws Exception; + + @GET + @Path("/configs") + @Operation( + summary = "List all configSets available for schema design.", + tags = {"schema-designer"}) + SchemaDesignerConfigsResponse listConfigs() throws Exception; + + @POST + @Path("/{configSet}/add") + @Operation( + summary = "Add a new field, field type, or dynamic field to the schema being designed.", + tags = {"schema-designer"}) + SchemaDesignerResponse addSchemaObject( + @PathParam("configSet") String configSet, + @QueryParam("schemaVersion") Integer schemaVersion, + SchemaDesignerAddRequestBody requestBody) + throws Exception; + + @PUT + @Path("/{configSet}/update") + @Operation( + summary = "Update an existing field or field type in the schema being designed.", + tags = {"schema-designer"}) + SchemaDesignerResponse updateSchemaObject( + @PathParam("configSet") String configSet, + @QueryParam("schemaVersion") Integer schemaVersion, + SchemaDesignerUpdateRequestBody requestBody) + throws Exception; + + @PUT + @Path("/{configSet}/publish") + @Operation( + summary = "Publish the designed schema to a live configSet.", + tags = {"schema-designer"}) + SchemaDesignerPublishResponse publish( + @PathParam("configSet") String configSet, + @QueryParam("schemaVersion") Integer schemaVersion, + @QueryParam("newCollection") String newCollection, + @QueryParam("reloadCollections") @DefaultValue("false") Boolean reloadCollections, + @QueryParam("numShards") @DefaultValue("1") Integer numShards, + @QueryParam("replicationFactor") @DefaultValue("1") Integer replicationFactor, + @QueryParam("indexToCollection") @DefaultValue("false") Boolean indexToCollection, + @QueryParam("cleanupTemp") @DefaultValue("true") Boolean cleanupTempParam, + @QueryParam("disableDesigner") @DefaultValue("false") Boolean disableDesigner) + throws Exception; + + /** + * Analyzes sample documents to suggest a schema. + * + *

Sample documents are read from the HTTP request body (not declared as a parameter on this + * interface — see {@code SchemaDesigner#loadSampleDocuments}) and dispatched to a parser based on + * the {@code Content-Type} header. + */ + @POST + @Path("/{configSet}/analyze") + @Operation( + summary = "Analyze sample documents and suggest a schema.", + description = + "Sample documents are supplied in the request body. The Content-Type header selects the" + + " parser: application/json, text/xml or application/xml, text/csv or" + + " application/csv, or text/plain or application/octet-stream (treated as JSON" + + " lines). Capped at 5MB and 1000 documents.", + tags = {"schema-designer"}) + SchemaDesignerResponse analyze( + @PathParam("configSet") String configSet, + @QueryParam("schemaVersion") Integer schemaVersion, + @QueryParam("copyFrom") String copyFrom, + @QueryParam("uniqueKeyField") String uniqueKeyField, + @QueryParam("languages") List languages, + @QueryParam("enableDynamicFields") Boolean enableDynamicFields, + @QueryParam("enableFieldGuessing") Boolean enableFieldGuessing, + @QueryParam("enableNestedDocs") Boolean enableNestedDocs) + throws Exception; + + @GET + @Path("/{configSet}/query") + @Operation( + summary = "Query the temporary collection used during schema design.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse query(@PathParam("configSet") String configSet) throws Exception; + + @GET + @Path("/{configSet}/diff") + @Operation( + summary = "Get the diff between the designed schema and the published schema.", + tags = {"schema-designer"}) + SchemaDesignerSchemaDiffResponse getSchemaDiff(@PathParam("configSet") String configSet) + throws Exception; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerAddRequestBody.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerAddRequestBody.java new file mode 100644 index 000000000000..821cabc4246a --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerAddRequestBody.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; + +/** + * Request body for the Schema Designer add endpoint. Exactly one of the four fields should be + * populated; the populated field's name is the action and its value carries the schema-object + * attributes (e.g. for {@code addField}: {@code name}, {@code type}, {@code stored}, etc.). + */ +public class SchemaDesignerAddRequestBody { + + @Schema(name = "addField") + @JsonProperty("add-field") + public Map addField; + + @Schema(name = "addDynamicField") + @JsonProperty("add-dynamic-field") + public Map addDynamicField; + + @Schema(name = "addCopyField") + @JsonProperty("add-copy-field") + public Map addCopyField; + + @Schema(name = "addFieldType") + @JsonProperty("add-field-type") + public Map addFieldType; +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/overview/integration/DefaultConfigsetsOverviewComponent.kt b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java similarity index 60% rename from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/overview/integration/DefaultConfigsetsOverviewComponent.kt rename to solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java index 240b394815b5..2e0d31a27243 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/overview/integration/DefaultConfigsetsOverviewComponent.kt +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java @@ -14,16 +14,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.ui.components.configsets.overview.integration +package org.apache.solr.client.api.model; -import com.arkivanov.mvikotlin.core.store.StoreFactory -import io.ktor.client.HttpClient -import org.apache.solr.ui.components.configsets.overview.ConfigsetsOverviewComponent -import org.apache.solr.ui.utils.AppComponentContext +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; -class DefaultConfigsetsOverviewComponent( - componentContext: AppComponentContext, - storeFactory: StoreFactory, - httpClient: HttpClient, -) : ConfigsetsOverviewComponent, - AppComponentContext by componentContext +/** Response body for the Schema Designer list-collections-for-config endpoint. */ +public class SchemaDesignerCollectionsResponse extends SolrJerseyResponse { + + @JsonProperty("collections") + public List collections; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerConfigsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerConfigsResponse.java new file mode 100644 index 000000000000..8f8025980822 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerConfigsResponse.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** Response body for the Schema Designer list-configs endpoint. */ +public class SchemaDesignerConfigsResponse extends SolrJerseyResponse { + + /** + * Map of configSet name to status: 0 = in-progress (temp only), 1 = disabled, 2 = enabled and + * published. + */ + @JsonProperty("configSets") + public Map configSets; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java new file mode 100644 index 000000000000..528f426c0abe --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Response body for the Schema Designer get-info endpoint. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SchemaDesignerInfoResponse extends SchemaDesignerSettingsResponse { + + @JsonProperty("configSet") + public String configSet; + + /** Whether the configSet has a published (live) version. */ + @JsonProperty("published") + public Boolean published; + + @JsonProperty("schemaVersion") + public Integer schemaVersion; + + /** Collections currently using this configSet. */ + @JsonProperty("collections") + public List collections; + + /** Number of sample documents stored for this configSet, if available. */ + @JsonProperty("numDocs") + public Integer numDocs; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java new file mode 100644 index 000000000000..b6d7038ff2fa --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Response body for the Schema Designer publish endpoint. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SchemaDesignerPublishResponse extends SolrJerseyResponse { + + @JsonProperty("configSet") + public String configSet; + + @JsonProperty("schemaVersion") + public Integer schemaVersion; + + /** The new collection created during publish, if requested. */ + @JsonProperty("newCollection") + public String newCollection; + + /** Error message if indexing sample docs into the new collection failed. */ + @JsonProperty("updateError") + public String updateError; + + @JsonProperty("updateErrorCode") + public Integer updateErrorCode; + + @JsonProperty("errorDetails") + public Object errorDetails; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java new file mode 100644 index 000000000000..f7e3325d9f83 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + +/** + * Response body for Schema Designer endpoints that operate on a full schema: {@code prepNewSchema}, + * {@code updateFileContents}, {@code addSchemaObject}, {@code updateSchemaObject}, and {@code + * analyze}. + * + *

All nullable fields are omitted from JSON output when null. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SchemaDesignerResponse extends SchemaDesignerSettingsResponse { + + // --- core schema identification --- + + @JsonProperty("configSet") + public String configSet; + + @JsonProperty("schemaVersion") + public Integer schemaVersion; + + /** The temporary mutable collection used during design (e.g. {@code ._designer_myConfig}). */ + @JsonProperty("tempCollection") + public String tempCollection; + + /** Active replica core name for the temp collection, used for Luke API calls. */ + @JsonProperty("core") + public String core; + + @JsonProperty("uniqueKeyField") + public String uniqueKeyField; + + /** Collections currently using the published version of this configSet. */ + @JsonProperty("collectionsForConfig") + public List collectionsForConfig; + + // --- schema objects --- + + @JsonProperty("fields") + public List> fields; + + @JsonProperty("dynamicFields") + public List> dynamicFields; + + @JsonProperty("fieldTypes") + public List> fieldTypes; + + /** ConfigSet files available in ZooKeeper (excluding managed-schema and internal files). */ + @JsonProperty("files") + public List files; + + /** IDs of the first 100 sample documents (present when docs were loaded/analyzed). */ + @JsonProperty("docIds") + public List docIds; + + /** Total number of sample documents, or -1 when no docs were passed to the endpoint. */ + @JsonProperty("numDocs") + public Integer numDocs; + + // --- error fields (set when sample-doc indexing fails) --- + + @JsonProperty("updateError") + public String updateError; + + @JsonProperty("updateErrorCode") + public Integer updateErrorCode; + + @JsonProperty("errorDetails") + public Object errorDetails; + + // --- endpoint-specific fields --- + + /** Source of the sample documents (e.g. "blob", "request"); set by {@code analyze}. */ + @JsonProperty("sampleSource") + public String sampleSource; + + /** Analysis warning when field-type inference produced errors; set by {@code analyze}. */ + @JsonProperty("analysisError") + public String analysisError; + + /** + * The type of schema object that was updated: {@code "field"} or {@code "type"}; set by {@code + * updateSchemaObject}. + */ + @JsonProperty("updateType") + public String updateType; + + /** + * The updated field definition map; populated when {@code updateType} is {@code "field"} in + * {@code updateSchemaObject}, or the field name string when returned by {@code addSchemaObject}. + */ + @JsonProperty("field") + public Object field; + + /** + * The updated field-type definition map; populated when {@code updateType} is {@code "type"} in + * {@code updateSchemaObject}, or the type name string when returned by {@code addSchemaObject}. + */ + @JsonProperty("type") + public Object type; + + /** The added dynamic-field name; set by {@code addSchemaObject} when adding a dynamic field. */ + @JsonProperty("dynamicField") + public Object dynamicField; + + /** The added field-type name; set by {@code addSchemaObject} when adding a field type. */ + @JsonProperty("fieldType") + public Object fieldType; + + /** + * Whether the temp collection needs to be rebuilt after this update; set by {@code + * updateSchemaObject}. + */ + @JsonProperty("rebuild") + public Boolean rebuild; + + /** + * Error message when a file update (e.g. {@code solrconfig.xml}) fails validation; set by {@code + * updateFileContents}. + */ + @JsonProperty("updateFileError") + public String updateFileError; + + /** + * The raw file content returned when a file update fails validation; set by {@code + * updateFileContents} so the UI can display the attempted content alongside the error. + */ + @JsonProperty("fileContent") + public String fileContent; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java new file mode 100644 index 000000000000..dfe60441117b --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** Response body for the Schema Designer get-schema-diff endpoint. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SchemaDesignerSchemaDiffResponse extends SchemaDesignerSettingsResponse { + + /** The list of field-level differences between the designed schema and the source. */ + @JsonProperty("diff") + public Map diff; + + /** The configSet used as the diff source (either the published configSet or copyFrom). */ + @JsonProperty("diff-source") + public String diffSource; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java new file mode 100644 index 000000000000..7f695ef48a42 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Base response for Schema Designer endpoints that surface the designer settings. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public abstract class SchemaDesignerSettingsResponse extends SolrJerseyResponse { + + @JsonProperty("languages") + public List languages; + + @JsonProperty("enableFieldGuessing") + public Boolean enableFieldGuessing; + + @JsonProperty("enableDynamicFields") + public Boolean enableDynamicFields; + + @JsonProperty("enableNestedDocs") + public Boolean enableNestedDocs; + + @JsonProperty("disabled") + public Boolean disabled; + + @JsonProperty("publishedVersion") + public Integer publishedVersion; + + @JsonProperty("copyFrom") + public String copyFrom; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerUpdateRequestBody.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerUpdateRequestBody.java new file mode 100644 index 000000000000..54b9bb9e56cb --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerUpdateRequestBody.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.HashMap; +import java.util.Map; + +/** + * Request body for the Schema Designer update endpoint: a flat field or field-type definition. The + * {@code name} property is required; remaining schema attributes (e.g. {@code type}, {@code + * indexed}, {@code stored}, {@code analyzer}, {@code copyDest}) are captured via the dynamic {@code + * additionalProperties} map and forwarded to the Schema API. + */ +public class SchemaDesignerUpdateRequestBody { + + @JsonProperty public String name; + + // Non-final + public so the OpenAPI-generated SolrJ client can assign to it directly. + // Accessed via @JsonAnyGetter / @JsonAnySetter for JSON (de)serialization. + public Map additionalProperties = new HashMap<>(); + + @JsonAnyGetter + public Map getAdditionalProperties() { + return additionalProperties; + } + + @JsonAnySetter + public void setAdditionalProperty(String key, Object value) { + additionalProperties.put(key, value); + } +} diff --git a/solr/core/src/java/org/apache/solr/core/CachingDirectoryFactory.java b/solr/core/src/java/org/apache/solr/core/CachingDirectoryFactory.java index dcf084670f62..8b943b7dc791 100644 --- a/solr/core/src/java/org/apache/solr/core/CachingDirectoryFactory.java +++ b/solr/core/src/java/org/apache/solr/core/CachingDirectoryFactory.java @@ -95,14 +95,6 @@ public String toString() { protected Set removeEntries = new HashSet<>(); - private Double maxWriteMBPerSecFlush; - - private Double maxWriteMBPerSecMerge; - - private Double maxWriteMBPerSecRead; - - private Double maxWriteMBPerSecDefault; - private boolean closed; public interface CloseListener { @@ -471,11 +463,6 @@ public void incRef(Directory directory) { @Override public void init(NamedList args) { - maxWriteMBPerSecFlush = (Double) args.get("maxWriteMBPerSecFlush"); - maxWriteMBPerSecMerge = (Double) args.get("maxWriteMBPerSecMerge"); - maxWriteMBPerSecRead = (Double) args.get("maxWriteMBPerSecRead"); - maxWriteMBPerSecDefault = (Double) args.get("maxWriteMBPerSecDefault"); - // override global config if (args.get(SolrXmlConfig.SOLR_DATA_HOME) != null) { dataHomePath = diff --git a/solr/core/src/java/org/apache/solr/core/CoreContainer.java b/solr/core/src/java/org/apache/solr/core/CoreContainer.java index a759ce80461a..2e2cb6007f96 100644 --- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java +++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java @@ -125,7 +125,7 @@ import org.apache.solr.handler.admin.ZookeeperRead; import org.apache.solr.handler.admin.ZookeeperStatusHandler; import org.apache.solr.handler.component.ShardHandlerFactory; -import org.apache.solr.handler.designer.SchemaDesignerAPI; +import org.apache.solr.handler.designer.SchemaDesigner; import org.apache.solr.jersey.InjectionFactories; import org.apache.solr.jersey.JerseyAppHandlerCache; import org.apache.solr.logging.LogWatcher; @@ -875,7 +875,7 @@ private void loadInternal() { registerV2Api(clusterAPI.commands); if (isZooKeeperAware()) { - registerV2Api(new SchemaDesignerAPI(this)); + registerV2Api(SchemaDesigner.class); } // else Schema Designer not available in standalone (non-cloud) mode /* diff --git a/solr/core/src/java/org/apache/solr/filestore/ClusterFileStore.java b/solr/core/src/java/org/apache/solr/filestore/ClusterFileStore.java index c405fcbcfe95..bcd4e5620534 100644 --- a/solr/core/src/java/org/apache/solr/filestore/ClusterFileStore.java +++ b/solr/core/src/java/org/apache/solr/filestore/ClusterFileStore.java @@ -31,6 +31,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import org.apache.commons.codec.digest.DigestUtils; import org.apache.solr.api.JerseyResource; @@ -228,13 +229,14 @@ public static FileStoreDirectoryListingResponse getMetadata( String parentPath = path.substring(0, path.lastIndexOf('/')); List l = fileStore.list(parentPath, s -> s.equals(fileName)); - dirListingResponse.files = - Collections.singletonMap(path, l.isEmpty() ? null : convertToResponse(l.get(0))); + FileStoreEntryMetadata entry = l.isEmpty() ? null : convertToResponse(l.get(0)); + dirListingResponse.files = Collections.singletonMap(path, entry); break; case DIRECTORY: final var directoryContents = fileStore.list(path, null).stream() .map(details -> convertToResponse(details)) + .filter(Objects::nonNull) .collect(Collectors.toList()); dirListingResponse.files = Map.of(path, directoryContents); break; @@ -254,8 +256,18 @@ private static FileStoreEntryMetadata convertToResponse(FileStore.FileDetails de return entryMetadata; } - entryMetadata.size = details.size(); - entryMetadata.timestamp = details.getTimeStamp(); + long size = details.size(); + if (size < 0) { + // File was deleted concurrently between listing and reading its attributes. + return null; + } + final var timestamp = details.getTimeStamp(); + if (timestamp == null) { + // File was deleted concurrently between reading its size and timestamp. + return null; + } + entryMetadata.size = size; + entryMetadata.timestamp = timestamp; if (details.getMetaData() != null) { details.getMetaData().toMap(entryMetadata.unknownProperties()); } diff --git a/solr/core/src/java/org/apache/solr/filestore/DistribFileStore.java b/solr/core/src/java/org/apache/solr/filestore/DistribFileStore.java index 398075663a4c..7fffd9ebf08a 100644 --- a/solr/core/src/java/org/apache/solr/filestore/DistribFileStore.java +++ b/solr/core/src/java/org/apache/solr/filestore/DistribFileStore.java @@ -30,6 +30,7 @@ import java.nio.channels.SeekableByteChannel; import java.nio.file.FileSystems; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; @@ -288,6 +289,9 @@ public MetaData getMetaData() { public Date getTimeStamp() { try { return new Date(Files.getLastModifiedTime(realPath()).toMillis()); + } catch (NoSuchFileException e) { + // File was deleted concurrently between listing and reading its attributes. + return null; } catch (IOException e) { throw new SolrException( SERVER_ERROR, "Failed to retrieve the last modified time for: " + realPath(), e); @@ -303,6 +307,9 @@ public boolean isDir() { public long size() { try { return Files.size(realPath()); + } catch (NoSuchFileException e) { + // File was deleted concurrently between listing and reading its attributes. + return -1; } catch (IOException e) { throw new SolrException( SERVER_ERROR, "Failed to retrieve the file size for: " + realPath(), e); diff --git a/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminOperation.java b/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminOperation.java index 3cd2bb141e73..2edc70aef30b 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminOperation.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminOperation.java @@ -38,7 +38,7 @@ import static org.apache.solr.common.params.CoreAdminParams.CoreAdminAction.STATUS; import static org.apache.solr.common.params.CoreAdminParams.CoreAdminAction.SWAP; import static org.apache.solr.common.params.CoreAdminParams.CoreAdminAction.UNLOAD; -import static org.apache.solr.common.params.CoreAdminParams.CoreAdminAction.UPGRADECOREINDEX; +import static org.apache.solr.common.params.CoreAdminParams.CoreAdminAction.UPGRADEINDEX; import static org.apache.solr.handler.admin.CoreAdminHandler.CallInfo; import java.lang.invoke.MethodHandles; @@ -258,7 +258,7 @@ public enum CoreAdminOperation implements CoreAdminOp { V2ApiUtils.squashIntoSolrResponseWithoutHeader(it.rsp, response); }), - UPGRADECOREINDEX_OP(UPGRADECOREINDEX, new UpgradeCoreIndexOp()); + UPGRADEINDEX_OP(UPGRADEINDEX, new UpgradeCoreIndexOp()); final CoreAdminParams.CoreAdminAction action; final CoreAdminOp fun; diff --git a/solr/core/src/java/org/apache/solr/handler/admin/UpgradeCoreIndexOp.java b/solr/core/src/java/org/apache/solr/handler/admin/UpgradeCoreIndexOp.java index 8fff5c93d310..350d07d99527 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/UpgradeCoreIndexOp.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/UpgradeCoreIndexOp.java @@ -54,7 +54,7 @@ public void execute(CoreAdminHandler.CallInfo it) throws Exception { if (it.handler.coreContainer.isZooKeeperAware()) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, - "action=UPGRADECOREINDEX is not supported in SolrCloud mode. As an alternative, in order to upgrade index, configure LatestVersionMergePolicyFactory in solrconfig.xml and reindex the data in your collection."); + "action=UPGRADEINDEX is not supported in SolrCloud mode. As an alternative, in order to upgrade index, configure LatestVersionMergePolicyFactory in solrconfig.xml and reindex the data in your collection."); } SolrParams params = it.req.getParams(); diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/UpgradeCoreIndex.java b/solr/core/src/java/org/apache/solr/handler/admin/api/UpgradeCoreIndex.java index c92d3e209471..5e80526c0e39 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/UpgradeCoreIndex.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/UpgradeCoreIndex.java @@ -68,9 +68,9 @@ import org.slf4j.LoggerFactory; /** - * Implements the UPGRADECOREINDEX CoreAdmin action, which upgrades an existing core's index - * in-place by reindexing documents from segments belonging to older Lucene versions, so that they - * get written into latest version segments. + * Implements the UPGRADEINDEX CoreAdmin action, which upgrades an existing core's index in-place by + * reindexing documents from segments belonging to older Lucene versions, so that they get written + * into latest version segments. * *

The upgrade process: * @@ -153,7 +153,7 @@ private UpgradeCoreIndexResponse performUpgradeImpl( if (indexContainsChildDocs(searcherRef.get())) { throw new SolrException( BAD_REQUEST, - "UPGRADECOREINDEX does not support indexes containing child/nested documents. " + "UPGRADEINDEX does not support indexes containing child/nested documents. " + " Consider reindexing your data " + "from the original source."); } diff --git a/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java b/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java index 3b26c5e2fc2b..9b6337528374 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java @@ -51,7 +51,7 @@ public DeleteConfigSet( @PermissionName(CONFIG_EDIT_PERM) public SolrJerseyResponse deleteConfigSet(String configSetName) throws Exception { final var response = instantiateJerseyResponse(SolrJerseyResponse.class); - if (StrUtils.isNullOrEmpty(configSetName)) { + if (StrUtils.isNullOrEmpty(configSetName) || configSetName.isBlank()) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "No configset name provided to delete"); } diff --git a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java index 729aaf00d914..5ea6d6be3208 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java @@ -63,20 +63,34 @@ public Response downloadConfigSet(String configSetName) throws Exception { throw new SolrException( SolrException.ErrorCode.NOT_FOUND, "ConfigSet " + configSetName + " not found!"); } - return buildZipResponse(configSetService, configSetName); + return buildZipResponse(configSetService, configSetName, deriveDisplayName(configSetName)); + } + + // This is to support the schema designer's internal name and + // lets us not duplicate the download endpoint. + static String deriveDisplayName(String configSetName) { + if (configSetName.startsWith("._designer_")) { + return configSetName.substring("._designer_".length()); + } + return configSetName; } /** * Build a ZIP download {@link Response} for the given configset. * * @param configSetService the service to use for downloading the configset files - * @param configSetName the name of the configset to download + * @param configSetName the name of the configset to download (internal id) + * @param displayName the sanitized name to use in the Content-Disposition filename */ - public static Response buildZipResponse(ConfigSetService configSetService, String configSetName) + public static Response buildZipResponse( + ConfigSetService configSetService, String configSetName, String displayName) throws IOException { final byte[] zipBytes = zipConfigSet(configSetService, configSetName); + final String safeName = displayName.replaceAll("[^a-zA-Z0-9_\\-.]", "_"); + final String fileName = safeName + "_configset.zip"; return Response.ok((StreamingOutput) outputStream -> outputStream.write(zipBytes)) .type("application/zip") + .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"") .build(); } diff --git a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java index 785be168ca93..5584ec47e9d6 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java @@ -101,7 +101,7 @@ public SampleDocuments parseDocsFromStream( + MAX_STREAM_SIZE + " bytes is the max upload size for sample documents."); } - // use a byte stream for the parsers in case they need to re-parse using a different strategy + // use a byte stream for the parsers in case they need to reparse using a different strategy // e.g. JSON vs. JSON lines or different CSV strategies ... ContentStreamBase.ByteArrayStream byteStream = new ContentStreamBase.ByteArrayStream(uploadedBytes, fileSource, contentType); @@ -152,7 +152,6 @@ protected List loadCsvDocs( .loadDocs(stream); } - @SuppressWarnings("unchecked") protected List loadJsonLines( ContentStreamBase.ByteArrayStream stream, final int maxDocsToLoad) throws IOException { List> docs = new ArrayList<>(); @@ -160,13 +159,7 @@ protected List loadJsonLines( BufferedReader br = new BufferedReader(r); String line; while ((line = br.readLine()) != null) { - line = line.trim(); - if (!line.isEmpty() && line.startsWith("{") && line.endsWith("}")) { - Object jsonLine = ObjectBuilder.getVal(new JSONParser(line)); - if (jsonLine instanceof Map) { - docs.add((Map) jsonLine); - } - } + parseStringToJson(docs, line); if (maxDocsToLoad > 0 && docs.size() == maxDocsToLoad) { break; } @@ -176,6 +169,19 @@ protected List loadJsonLines( return docs.stream().map(JsonLoader::buildDoc).collect(Collectors.toList()); } + private void parseStringToJson(List> docs, String line) throws IOException { + line = line.trim(); + if (line.startsWith("{") && line.endsWith("}")) { + Object jsonLine = ObjectBuilder.getVal(new JSONParser(line)); + if (jsonLine instanceof Map rawMap) { + // JSON object keys are always Strings; the cast is safe + @SuppressWarnings("unchecked") + Map typedMap = (Map) rawMap; + docs.add(typedMap); + } + } + } + @SuppressWarnings("unchecked") protected List loadJsonDocs( ContentStreamBase.ByteArrayStream stream, final int maxDocsToLoad) throws IOException { @@ -203,7 +209,7 @@ protected List loadJsonDocs( if (lines.length > 1) { for (String line : lines) { line = line.trim(); - if (!line.isEmpty() && line.startsWith("{") && line.endsWith("}")) { + if (line.startsWith("{") && line.endsWith("}")) { isJsonLines = true; break; } @@ -293,17 +299,10 @@ protected List parseXmlDocs(XMLStreamReader parser, final int } } - @SuppressWarnings("unchecked") protected List> loadJsonLines(String[] lines) throws IOException { List> docs = new ArrayList<>(lines.length); for (String line : lines) { - line = line.trim(); - if (!line.isEmpty() && line.startsWith("{") && line.endsWith("}")) { - Object jsonLine = ObjectBuilder.getVal(new JSONParser(line)); - if (jsonLine instanceof Map) { - docs.add((Map) jsonLine); - } - } + parseStringToJson(docs, line); } return docs; } diff --git a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSchemaSuggester.java b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSchemaSuggester.java index 543b7a77af16..963cae662830 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSchemaSuggester.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSchemaSuggester.java @@ -170,8 +170,7 @@ public Optional suggestField( throw new IllegalStateException("FieldType '" + fieldTypeName + "' not found in the schema!"); } - Map fieldProps = - guessFieldProps(fieldName, fieldType, sampleValues, isMV, schema); + Map fieldProps = guessFieldProps(fieldName, fieldType, isMV, schema); SchemaField schemaField = schema.newField(fieldName, fieldTypeName, fieldProps); return Optional.of(schemaField); } @@ -179,9 +178,9 @@ public Optional suggestField( @Override public ManagedIndexSchema adaptExistingFieldToData( SchemaField schemaField, List sampleValues, ManagedIndexSchema schema) { - // Promote a single-valued to multi-valued if needed + // Promote a single-valued to multivalued if needed if (!schemaField.multiValued() && isMultiValued(sampleValues)) { - // this existing field needs to be promoted to multi-valued + // this existing field needs to be promoted to multivalued SimpleOrderedMap fieldProps = schemaField.getNamedPropertyValues(false); fieldProps.add("multiValued", true); fieldProps.remove("name"); @@ -210,7 +209,7 @@ public Map> transposeDocs(List docs) { Collection fieldValues = doc.getFieldValues(f); if (fieldValues != null && !fieldValues.isEmpty()) { if (fieldValues.size() == 1) { - // flatten so every field doesn't end up multi-valued + // flatten so every field doesn't end up multivalued values.add(fieldValues.iterator().next()); } else { // truly multi-valued @@ -395,11 +394,7 @@ protected boolean isMultiValued(final List sampleValues) { } protected Map guessFieldProps( - String fieldName, - FieldType fieldType, - List sampleValues, - boolean isMV, - IndexSchema schema) { + String fieldName, FieldType fieldType, boolean isMV, IndexSchema schema) { Map props = new HashMap<>(); props.put("indexed", "true"); diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SampleDocuments.java b/solr/core/src/java/org/apache/solr/handler/designer/SampleDocuments.java index b98c5995db28..6037db515241 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SampleDocuments.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SampleDocuments.java @@ -56,7 +56,7 @@ public List appendDocs( return id != null && !ids.contains(id); // doc has ID, and it's not already in the set }) - .collect(Collectors.toList()); + .toList(); parsed.addAll(toAdd); if (maxDocsToLoad > 0 && parsed.size() > maxDocsToLoad) { parsed = parsed.subList(0, maxDocsToLoad); diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java similarity index 67% rename from solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java rename to solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index bced3660ef74..068959645de3 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -17,14 +17,12 @@ package org.apache.solr.handler.designer; -import static org.apache.solr.client.solrj.SolrRequest.METHOD.GET; -import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST; -import static org.apache.solr.client.solrj.SolrRequest.METHOD.PUT; import static org.apache.solr.common.params.CommonParams.JSON_MIME; import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_EDIT_PERM; import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_READ_PERM; +import jakarta.inject.Inject; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -49,7 +47,19 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -import org.apache.solr.api.EndPoint; +import org.apache.solr.api.JerseyResource; +import org.apache.solr.client.api.endpoint.SchemaDesignerApi; +import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SchemaDesignerAddRequestBody; +import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; +import org.apache.solr.client.api.model.SchemaDesignerConfigsResponse; +import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; +import org.apache.solr.client.api.model.SchemaDesignerPublishResponse; +import org.apache.solr.client.api.model.SchemaDesignerResponse; +import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; +import org.apache.solr.client.api.model.SchemaDesignerSettingsResponse; +import org.apache.solr.client.api.model.SchemaDesignerUpdateRequestBody; +import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.impl.CloudSolrClient; import org.apache.solr.client.solrj.request.CollectionAdminRequest; @@ -57,6 +67,7 @@ import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.cloud.ZkConfigSetService; import org.apache.solr.cloud.ZkSolrResourceLoader; +import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.common.SolrInputField; @@ -66,16 +77,14 @@ import org.apache.solr.common.cloud.ZkMaintenanceUtils; import org.apache.solr.common.cloud.ZkStateReader; import org.apache.solr.common.util.ContentStream; -import org.apache.solr.common.util.ContentStreamBase; import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.common.util.StrUtils; import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.core.SolrResourceLoader; +import org.apache.solr.jersey.PermissionName; import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.RawResponseWriter; -import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; import org.apache.solr.util.RTimer; @@ -86,7 +95,8 @@ import org.slf4j.LoggerFactory; /** All V2 APIs have a prefix of /api/schema-designer/ */ -public class SchemaDesignerAPI implements SchemaDesignerConstants { +public class SchemaDesigner extends JerseyResource + implements SchemaDesignerApi, SchemaDesignerConstants { private static final Set excludeConfigSetNames = Set.of(DEFAULT_CONFIGSET_NAME); @@ -98,21 +108,26 @@ public class SchemaDesignerAPI implements SchemaDesignerConstants { private final SchemaDesignerSettingsDAO settingsDAO; private final SchemaDesignerConfigSetHelper configSetHelper; private final Map indexedVersion = new ConcurrentHashMap<>(); + private final SolrQueryRequest solrQueryRequest; - public SchemaDesignerAPI(CoreContainer coreContainer) { + @Inject + public SchemaDesigner(CoreContainer coreContainer, SolrQueryRequest solrQueryRequest) { this( coreContainer, - SchemaDesignerAPI.newSchemaSuggester(), - SchemaDesignerAPI.newSampleDocumentsLoader()); + SchemaDesigner.newSchemaSuggester(), + SchemaDesigner.newSampleDocumentsLoader(), + solrQueryRequest); } - SchemaDesignerAPI( + SchemaDesigner( CoreContainer coreContainer, SchemaSuggester schemaSuggester, - SampleDocumentsLoader sampleDocLoader) { + SampleDocumentsLoader sampleDocLoader, + SolrQueryRequest solrQueryRequest) { this.coreContainer = coreContainer; this.schemaSuggester = schemaSuggester; this.sampleDocLoader = sampleDocLoader; + this.solrQueryRequest = solrQueryRequest; this.configSetHelper = new SchemaDesignerConfigSetHelper(this.coreContainer, this.schemaSuggester); this.settingsDAO = new SchemaDesignerSettingsDAO(coreContainer, configSetHelper); @@ -146,14 +161,16 @@ static String getMutableId(final String configSet) { return DESIGNER_PREFIX + configSet; } - @EndPoint(method = GET, path = "/schema-designer/info", permission = CONFIG_READ_PERM) - public void getInfo(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_READ_PERM) + public SchemaDesignerInfoResponse getInfo(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); - Map responseMap = new HashMap<>(); - responseMap.put(CONFIG_SET_PARAM, configSet); + SchemaDesignerInfoResponse response = + instantiateJerseyResponse(SchemaDesignerInfoResponse.class); + response.configSet = configSet; boolean exists = configExists(configSet); - responseMap.put("published", exists); + response.published = exists; // mutable config may not exist yet as this is just an info check to gather some basic info the // UI needs @@ -164,31 +181,31 @@ public void getInfo(SolrQueryRequest req, SolrQueryResponse rsp) throws IOExcept SolrConfig srcConfig = exists ? configSetHelper.loadSolrConfig(configSet) : null; SolrConfig solrConfig = configExists(mutableId) ? configSetHelper.loadSolrConfig(mutableId) : srcConfig; - addSettingsToResponse(settingsDAO.getSettings(solrConfig), responseMap); + addSettingsToResponse(settingsDAO.getSettings(solrConfig), response); - responseMap.put(SCHEMA_VERSION_PARAM, configSetHelper.getCurrentSchemaVersion(mutableId)); - responseMap.put( - "collections", exists ? configSetHelper.listCollectionsForConfig(configSet) : List.of()); + response.schemaVersion = configSetHelper.getCurrentSchemaVersion(mutableId); + response.collections = exists ? configSetHelper.listCollectionsForConfig(configSet) : List.of(); // don't fail if loading sample docs fails try { - responseMap.put("numDocs", configSetHelper.retrieveSampleDocs(configSet).size()); + response.numDocs = configSetHelper.retrieveSampleDocs(configSet).size(); } catch (Exception exc) { log.warn("Failed to load sample docs from blob store for {}", configSet, exc); } - rsp.getValues().addAll(responseMap); + return response; } - @EndPoint(method = POST, path = "/schema-designer/prep", permission = CONFIG_EDIT_PERM) - public void prepNewSchema(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public SchemaDesignerResponse prepNewSchema(String configSet, String copyFrom) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); validateNewConfigSetName(configSet); - final String copyFrom = req.getParams().get(COPY_FROM_PARAM, DEFAULT_CONFIGSET_NAME); + final String effectiveCopyFrom = copyFrom != null ? copyFrom : DEFAULT_CONFIGSET_NAME; - SchemaDesignerSettings settings = getMutableSchemaForConfigSet(configSet, -1, copyFrom); + SchemaDesignerSettings settings = + getMutableSchemaForConfigSet(configSet, -1, effectiveCopyFrom); ManagedIndexSchema schema = settings.getSchema(); String mutableId = getMutableId(configSet); @@ -200,36 +217,27 @@ public void prepNewSchema(SolrQueryRequest req, SolrQueryResponse rsp) settingsDAO.persistIfChanged(mutableId, settings); - rsp.getValues().addAll(buildResponse(configSet, schema, settings, null)); + return buildSchemaDesignerResponse(configSet, schema, settings, null); } - @EndPoint(method = PUT, path = "/schema-designer/cleanup", permission = CONFIG_EDIT_PERM) - public void cleanupTemp(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - cleanupTemp(getRequiredParam(CONFIG_SET_PARAM, req)); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public SolrJerseyResponse cleanupTempSchema(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + doCleanupTemp(configSet); + return instantiateJerseyResponse(SolrJerseyResponse.class); } - @EndPoint(method = GET, path = "/schema-designer/file", permission = CONFIG_READ_PERM) - public void getFileContents(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String file = getRequiredParam("file", req); - String filePath = getConfigSetZkPath(getMutableId(configSet), file); - byte[] data; - try { - data = zkStateReader().getZkClient().getData(filePath, null, null); - } catch (KeeperException | InterruptedException e) { - throw new IOException("Error reading file: " + filePath, SolrZkClient.checkInterrupted(e)); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public SchemaDesignerResponse updateFileContents( + String configSet, String file, InputStream fileContents) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireNotEmpty("file", file); + if (fileContents == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Request body with file contents is required!"); } - String stringData = - data != null && data.length > 0 ? new String(data, StandardCharsets.UTF_8) : ""; - rsp.getValues().addAll(Map.of(file, stringData)); - } - - @EndPoint(method = POST, path = "/schema-designer/file", permission = CONFIG_EDIT_PERM) - public void updateFileContents(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String file = getRequiredParam("file", req); String mutableId = getMutableId(configSet); String zkPath = getConfigSetZkPath(mutableId, file); @@ -240,7 +248,7 @@ public void updateFileContents(SolrQueryRequest req, SolrQueryResponse rsp) } byte[] data; - try (InputStream in = extractSingleContentStream(req, true).getStream()) { + try (InputStream in = fileContents) { data = in.readAllBytes(); } Exception updateFileError = null; @@ -261,11 +269,11 @@ public void updateFileContents(SolrQueryRequest req, SolrQueryResponse rsp) // solrconfig.xml update failed, but haven't impacted the configSet yet, so just return the // error directly Throwable causedBy = SolrException.getRootCause(updateFileError); - Map response = new HashMap<>(); - response.put("updateFileError", causedBy.getMessage()); - response.put(file, new String(data, StandardCharsets.UTF_8)); - rsp.getValues().addAll(response); - return; + SchemaDesignerResponse errorResponse = + instantiateJerseyResponse(SchemaDesignerResponse.class); + errorResponse.updateFileError = causedBy.getMessage(); + errorResponse.fileContent = new String(data, StandardCharsets.UTF_8); + return errorResponse; } // apply the update and reload the temp collection / re-index sample docs @@ -295,10 +303,10 @@ public void updateFileContents(SolrQueryRequest req, SolrQueryResponse rsp) } } - Map response = buildResponse(configSet, schema, null, docs); + SchemaDesignerResponse response = buildSchemaDesignerResponse(configSet, schema, null, docs); if (analysisErrorHolder[0] != null) { - response.put(ANALYSIS_ERROR, analysisErrorHolder[0]); + response.analysisError = analysisErrorHolder[0]; } addErrorToResponse( @@ -308,15 +316,16 @@ public void updateFileContents(SolrQueryRequest req, SolrQueryResponse rsp) response, "Failed to re-index sample documents after update to the " + file + " file"); - rsp.getValues().addAll(response); + return response; } - @EndPoint(method = GET, path = "/schema-designer/sample", permission = CONFIG_READ_PERM) - public void getSampleValue(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String fieldName = getRequiredParam(FIELD_PARAM, req); - final String idField = getRequiredParam(UNIQUE_KEY_FIELD_PARAM, req); - String docId = req.getParams().get(DOC_ID_PARAM); + @Override + @PermissionName(CONFIG_READ_PERM) + public FlexibleSolrJerseyResponse getSampleValue( + String configSet, String fieldName, String idField, String docId) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireNotEmpty(FIELD_PARAM, fieldName); + requireNotEmpty(UNIQUE_KEY_FIELD_PARAM, idField); final List docs = configSetHelper.retrieveSampleDocs(configSet); String textValue = null; @@ -347,25 +356,31 @@ public void getSampleValue(SolrQueryRequest req, SolrQueryResponse rsp) throws I if (textValue != null) { var analysis = configSetHelper.analyzeField(configSet, fieldName, textValue); - rsp.getValues().addAll(Map.of(idField, docId, fieldName, textValue, "analysis", analysis)); + return buildFlexibleResponse( + Map.of(idField, docId, fieldName, textValue, "analysis", analysis)); } + return instantiateJerseyResponse(FlexibleSolrJerseyResponse.class); } - @EndPoint( - method = GET, - path = "/schema-designer/collectionsForConfig", - permission = CONFIG_READ_PERM) - public void listCollectionsForConfig(SolrQueryRequest req, SolrQueryResponse rsp) { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - rsp.getValues() - .addAll(Map.of("collections", configSetHelper.listCollectionsForConfig(configSet))); + @Override + @PermissionName(CONFIG_READ_PERM) + public SchemaDesignerCollectionsResponse listCollectionsForConfig(String configSet) { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + SchemaDesignerCollectionsResponse response = + instantiateJerseyResponse(SchemaDesignerCollectionsResponse.class); + response.collections = configSetHelper.listCollectionsForConfig(configSet); + return response; } // CONFIG_EDIT_PERM is required here since this endpoint is used by the UI to determine if the // user has access to the Schema Designer UI - @EndPoint(method = GET, path = "/schema-designer/configs", permission = CONFIG_EDIT_PERM) - public void listConfigs(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - rsp.getValues().addAll(Map.of("configSets", listEnabledConfigs())); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public SchemaDesignerConfigsResponse listConfigs() throws Exception { + SchemaDesignerConfigsResponse response = + instantiateJerseyResponse(SchemaDesignerConfigsResponse.class); + response.configSets = listEnabledConfigs(); + return response; } protected Map listEnabledConfigs() throws IOException { @@ -384,69 +399,73 @@ protected Map listEnabledConfigs() throws IOException { return configs; } - @EndPoint(method = GET, path = "/schema-designer/download/*", permission = CONFIG_READ_PERM) - public void downloadConfig(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - String mutableId = getMutableId(configSet); - - // find the configSet to download - SolrZkClient zkClient = zkStateReader().getZkClient(); - String configId = mutableId; - try { - if (!zkClient.exists(getConfigSetZkPath(mutableId, null))) { - if (zkClient.exists(getConfigSetZkPath(configSet, null))) { - configId = configSet; - } else { - throw new SolrException( - SolrException.ErrorCode.NOT_FOUND, "ConfigSet " + configSet + " not found!"); - } - } - } catch (KeeperException | InterruptedException e) { - throw new IOException("Error reading config from ZK", SolrZkClient.checkInterrupted(e)); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public SchemaDesignerResponse addSchemaObject( + String configSet, Integer schemaVersion, SchemaDesignerAddRequestBody requestBody) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireSchemaVersion(schemaVersion); + final String mutableId = checkMutable(configSet, schemaVersion); + + String action; + Map attrs; + if (requestBody == null) { + action = null; + attrs = null; + } else if (requestBody.addField != null) { + action = "add-field"; + attrs = requestBody.addField; + } else if (requestBody.addDynamicField != null) { + action = "add-dynamic-field"; + attrs = requestBody.addDynamicField; + } else if (requestBody.addCopyField != null) { + action = "add-copy-field"; + attrs = requestBody.addCopyField; + } else if (requestBody.addFieldType != null) { + action = "add-field-type"; + attrs = requestBody.addFieldType; + } else { + action = null; + attrs = null; + } + if (action == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Request body must contain exactly one of: add-field, add-dynamic-field, add-copy-field, add-field-type"); } - ContentStreamBase content = - new ContentStreamBase.ByteArrayStream( - configSetHelper.downloadAndZipConfigSet(configId), - configSet + ".zip", - "application/zip"); - rsp.add(RawResponseWriter.CONTENT, content); - } - - @EndPoint(method = POST, path = "/schema-designer/add", permission = CONFIG_EDIT_PERM) - public void addSchemaObject(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String mutableId = checkMutable(configSet, req); - - Map addJson = readJsonFromRequest(req); + Map addJson = Map.of(action, attrs); log.info("Adding new schema object from JSON: {}", addJson); String objectName = configSetHelper.addSchemaObject(configSet, addJson); - String action = addJson.keySet().iterator().next(); ManagedIndexSchema schema = loadLatestSchema(mutableId); - Map response = - buildResponse(configSet, schema, null, configSetHelper.retrieveSampleDocs(configSet)); - response.put(action, objectName); - rsp.getValues().addAll(response); + SchemaDesignerResponse response = + buildSchemaDesignerResponse( + configSet, schema, null, configSetHelper.retrieveSampleDocs(configSet)); + setSchemaObjectField(response, action, objectName); + return response; } - @EndPoint(method = PUT, path = "/schema-designer/update", permission = CONFIG_EDIT_PERM) - public void updateSchemaObject(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String mutableId = checkMutable(configSet, req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public SchemaDesignerResponse updateSchemaObject( + String configSet, Integer schemaVersion, SchemaDesignerUpdateRequestBody requestBody) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireSchemaVersion(schemaVersion); + final String mutableId = checkMutable(configSet, schemaVersion); - // Updated field definition is in the request body as JSON - Map updateField = readJsonFromRequest(req); - String name = (String) updateField.get("name"); - if (StrUtils.isNullOrEmpty(name)) { + if (requestBody == null || StrUtils.isNullOrEmpty(requestBody.name)) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "Invalid update request! JSON payload is missing the required name property: " - + updateField); + + requestBody); } + String name = requestBody.name; + Map updateField = new HashMap<>(requestBody.getAdditionalProperties()); + updateField.put("name", name); log.info( "Updating schema object: configSet={}, mutableId={}, name={}, JSON={}", configSet, @@ -486,29 +505,41 @@ public void updateSchemaObject(SolrQueryRequest req, SolrQueryResponse rsp) } } - Map response = buildResponse(configSet, schema, settings, docs); - response.put("updateType", updateType); + SchemaDesignerResponse response = + buildSchemaDesignerResponse(configSet, schema, settings, docs); + response.updateType = updateType; if (FIELD_PARAM.equals(updateType)) { - response.put(updateType, fieldToMap(schema.getField(name), schema)); + response.field = fieldToMap(schema.getField(name), schema); } else if ("type".equals(updateType)) { - response.put(updateType, schema.getFieldTypeByName(name).getNamedPropertyValues(true)); + response.type = schema.getFieldTypeByName(name).getNamedPropertyValues(true); } if (analysisErrorHolder[0] != null) { - response.put(ANALYSIS_ERROR, analysisErrorHolder[0]); + response.analysisError = analysisErrorHolder[0]; } addErrorToResponse(mutableId, solrExc, errorsDuringIndexing, response, updateError); - response.put("rebuild", needsRebuild); - rsp.getValues().addAll(response); + response.rebuild = needsRebuild; + return response; } - @EndPoint(method = PUT, path = "/schema-designer/publish", permission = CONFIG_EDIT_PERM) - public void publish(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String mutableId = checkMutable(configSet, req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public SchemaDesignerPublishResponse publish( + String configSet, + Integer schemaVersion, + String newCollection, + Boolean reloadCollections, + Integer numShards, + Integer replicationFactor, + Boolean indexToCollection, + Boolean cleanupTempParam, + Boolean disableDesigner) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireSchemaVersion(schemaVersion); + final String mutableId = checkMutable(configSet, schemaVersion); // verify the configSet we're going to apply changes to hasn't been changed since being loaded // for @@ -531,7 +562,6 @@ public void publish(SolrQueryRequest req, SolrQueryResponse rsp) } } - String newCollection = req.getParams().get(NEW_COLLECTION_PARAM); if (StrUtils.isNotNullOrEmpty(newCollection) && zkStateReader().getClusterState().hasCollection(newCollection)) { throw new SolrException( @@ -552,7 +582,6 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { copyConfig(mutableId, configSet); } - boolean reloadCollections = req.getParams().getBool(RELOAD_COLLECTIONS_PARAM, false); if (reloadCollections) { log.debug("Reloading collections after update to configSet: {}", configSet); List collectionsForConfig = configSetHelper.listCollectionsForConfig(configSet); @@ -565,10 +594,8 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { // create new collection Map errorsDuringIndexing = null; if (StrUtils.isNotNullOrEmpty(newCollection)) { - int numShards = req.getParams().getInt("numShards", 1); - int rf = req.getParams().getInt("replicationFactor", 1); - configSetHelper.createCollection(newCollection, configSet, numShards, rf); - if (req.getParams().getBool(INDEX_TO_COLLECTION_PARAM, false)) { + configSetHelper.createCollection(newCollection, configSet, numShards, replicationFactor); + if (indexToCollection) { List docs = configSetHelper.retrieveSampleDocs(configSet); if (!docs.isEmpty()) { ManagedIndexSchema schema = loadLatestSchema(mutableId); @@ -578,35 +605,45 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { } } - if (req.getParams().getBool(CLEANUP_TEMP_PARAM, true)) { + if (cleanupTempParam) { try { - cleanupTemp(configSet); + doCleanupTemp(configSet); } catch (IOException | SolrServerException | SolrException exc) { final String excStr = exc.toString(); log.warn("Failed to clean-up temp collection {} due to: {}", mutableId, excStr); } } - settings.setDisabled(req.getParams().getBool(DISABLE_DESIGNER_PARAM, false)); + settings.setDisabled(disableDesigner); settingsDAO.persistIfChanged(configSet, settings); - Map response = new HashMap<>(); - response.put(CONFIG_SET_PARAM, configSet); - response.put(SCHEMA_VERSION_PARAM, configSetHelper.getCurrentSchemaVersion(configSet)); + SchemaDesignerPublishResponse response = + instantiateJerseyResponse(SchemaDesignerPublishResponse.class); + response.configSet = configSet; + response.schemaVersion = configSetHelper.getCurrentSchemaVersion(configSet); if (StrUtils.isNotNullOrEmpty(newCollection)) { - response.put(NEW_COLLECTION_PARAM, newCollection); + response.newCollection = newCollection; } - addErrorToResponse(newCollection, null, errorsDuringIndexing, response, null); + addErrorToResponse(newCollection, null, errorsDuringIndexing, response); - rsp.getValues().addAll(response); + return response; } - @EndPoint(method = POST, path = "/schema-designer/analyze", permission = CONFIG_EDIT_PERM) - public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final int schemaVersion = req.getParams().getInt(SCHEMA_VERSION_PARAM, -1); - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public SchemaDesignerResponse analyze( + String configSet, + Integer schemaVersion, + String copyFrom, + String uniqueKeyField, + List languages, + Boolean enableDynamicFields, + Boolean enableFieldGuessing, + Boolean enableNestedDocs) + throws Exception { + final int schemaVersionInt = schemaVersion != null ? schemaVersion : -1; + requireNotEmpty(CONFIG_SET_PARAM, configSet); // don't let the user edit the _default configSet with the designer (for now) if (DEFAULT_CONFIGSET_NAME.equals(configSet)) { @@ -620,27 +657,25 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) // Get the sample documents to analyze, preferring those in the request but falling back to // previously stored - SampleDocuments sampleDocuments = loadSampleDocuments(req, configSet); + SampleDocuments sampleDocuments = loadSampleDocuments(configSet); // Get a mutable "temp" schema either from the specified copy source or configSet if it already // exists. - String copyFrom = - configExists(configSet) - ? configSet - : req.getParams().get(COPY_FROM_PARAM, DEFAULT_CONFIGSET_NAME); + if (copyFrom == null) { + copyFrom = configExists(configSet) ? configSet : DEFAULT_CONFIGSET_NAME; + } String mutableId = getMutableId(configSet); // holds additional settings needed by the designer to maintain state SchemaDesignerSettings settings = - getMutableSchemaForConfigSet(configSet, schemaVersion, copyFrom); + getMutableSchemaForConfigSet(configSet, schemaVersionInt, copyFrom); ManagedIndexSchema schema = settings.getSchema(); - String uniqueKeyFieldParam = req.getParams().get(UNIQUE_KEY_FIELD_PARAM); - if (StrUtils.isNotNullOrEmpty(uniqueKeyFieldParam)) { - String uniqueKeyField = + if (StrUtils.isNotNullOrEmpty(uniqueKeyField)) { + String existingKeyField = schema.getUniqueKeyField() != null ? schema.getUniqueKeyField().getName() : null; - if (!uniqueKeyFieldParam.equals(uniqueKeyField)) { + if (!uniqueKeyField.equals(existingKeyField)) { // The Schema API doesn't support changing the ID field so would have to use XML directly throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, @@ -649,13 +684,12 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) } boolean langsUpdated = false; - String[] languages = req.getParams().getParams(LANGUAGES_PARAM); List langs; if (languages != null) { langs = - languages.length == 0 || (languages.length == 1 && "*".equals(languages[0])) + languages.isEmpty() || (languages.size() == 1 && "*".equals(languages.getFirst())) ? List.of() - : Arrays.asList(languages); + : languages; if (!langs.equals(settings.getLanguages())) { settings.setLanguages(langs); langsUpdated = true; @@ -666,7 +700,6 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) } boolean dynamicUpdated = false; - Boolean enableDynamicFields = req.getParams().getBool(ENABLE_DYNAMIC_FIELDS_PARAM); if (enableDynamicFields != null && enableDynamicFields != settings.dynamicFieldsEnabled()) { settings.setDynamicFieldsEnabled(enableDynamicFields); dynamicUpdated = true; @@ -697,7 +730,6 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) // persist the updated schema schema.persistManagedSchema(false); - Boolean enableFieldGuessing = req.getParams().getBool(ENABLE_FIELD_GUESSING_PARAM); if (enableFieldGuessing != null && enableFieldGuessing != settings.fieldGuessingEnabled()) { settings.setFieldGuessingEnabled(enableFieldGuessing); } @@ -712,7 +744,6 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) } // nested docs - Boolean enableNestedDocs = req.getParams().getBool(ENABLE_NESTED_DOCS_PARAM); if (enableNestedDocs != null && enableNestedDocs != settings.nestedDocsEnabled()) { settings.setNestedDocsEnabled(enableNestedDocs); configSetHelper.toggleNestedDocsFields(schema, enableNestedDocs); @@ -732,20 +763,20 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) CollectionAdminRequest.reloadCollection(mutableId).process(cloudClient()); } - Map response = - buildResponse(configSet, loadLatestSchema(mutableId), settings, docs); - response.put("sampleSource", sampleDocuments.getSource()); + SchemaDesignerResponse response = + buildSchemaDesignerResponse(configSet, loadLatestSchema(mutableId), settings, docs); + response.sampleSource = sampleDocuments.getSource(); if (analysisErrorHolder[0] != null) { - response.put(ANALYSIS_ERROR, analysisErrorHolder[0]); + response.analysisError = analysisErrorHolder[0]; } addErrorToResponse(mutableId, null, errorsDuringIndexing, response, null); - rsp.getValues().addAll(response); + return response; } - @EndPoint(method = GET, path = "/schema-designer/query", permission = CONFIG_READ_PERM) - public void query(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_READ_PERM) + public FlexibleSolrJerseyResponse query(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); String mutableId = getMutableId(configSet); if (!configExists(mutableId)) { throw new SolrException( @@ -772,57 +803,80 @@ public void query(SolrQueryRequest req, SolrQueryResponse rsp) version, currentVersion); List docs = configSetHelper.retrieveSampleDocs(configSet); - ManagedIndexSchema schema = loadLatestSchema(mutableId); - errorsDuringIndexing = - indexSampleDocsWithRebuildOnAnalysisError( - schema.getUniqueKeyField().getName(), docs, mutableId, true, null); - // the version changes when you index (due to field guessing URP) - currentVersion = configSetHelper.getCurrentSchemaVersion(mutableId); + if (!docs.isEmpty()) { + ManagedIndexSchema schema = loadLatestSchema(mutableId); + errorsDuringIndexing = + indexSampleDocsWithRebuildOnAnalysisError( + schema.getUniqueKeyField().getName(), docs, mutableId, true, null); + // the version changes when you index (due to field guessing URP) + currentVersion = configSetHelper.getCurrentSchemaVersion(mutableId); + } indexedVersion.put(mutableId, currentVersion); } if (errorsDuringIndexing != null) { - Map response = new HashMap<>(); - rsp.setException( + Map errorResponse = new HashMap<>(); + addErrorToResponse( + mutableId, new SolrException( SolrException.ErrorCode.BAD_REQUEST, - "Failed to re-index sample documents after schema updated.")); - response.put(ERROR_DETAILS, errorsDuringIndexing); - rsp.getValues().addAll(response); - return; + "Failed to re-index sample documents after schema updated."), + errorsDuringIndexing, + errorResponse, + "Failed to re-index sample documents after schema updated."); + return buildFlexibleResponse(errorResponse); } // execute the user's query against the temp collection - QueryResponse qr = cloudClient().query(mutableId, req.getParams()); - rsp.getValues().addAll(qr.getResponse()); + QueryResponse qr = cloudClient().query(mutableId, solrQueryRequest.getParams()); + Map responseMap = new HashMap<>(); + qr.getResponse() + .forEach( + (name, val) -> { + if ("response".equals(name) && val instanceof SolrDocumentList) { + // SolrDocumentList extends ArrayList, so Jackson would serialize it as a plain + // array, losing numFound/start metadata that the UI expects at data.response.docs + SolrDocumentList docList = (SolrDocumentList) val; + Map responseObj = new HashMap<>(); + responseObj.put("numFound", docList.getNumFound()); + responseObj.put("start", docList.getStart()); + responseObj.put("docs", new ArrayList<>(docList)); + responseMap.put(name, responseObj); + } else { + responseMap.put(name, val); + } + }); + return buildFlexibleResponse(responseMap); } /** * Return the diff of designer schema with the source schema (either previously published or the * copyFrom). */ - @EndPoint(method = GET, path = "/schema-designer/diff", permission = CONFIG_READ_PERM) - public void getSchemaDiff(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_READ_PERM) + public SchemaDesignerSchemaDiffResponse getSchemaDiff(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); SchemaDesignerSettings settings = getMutableSchemaForConfigSet(configSet, -1, null); // diff the published if found, else use the original source schema String sourceSchema = configExists(configSet) ? configSet : settings.getCopyFrom(); - Map response = new HashMap<>(); - response.put( - "diff", ManagedSchemaDiff.diff(loadLatestSchema(sourceSchema), settings.getSchema())); - response.put("diff-source", sourceSchema); + SchemaDesignerSchemaDiffResponse response = + instantiateJerseyResponse(SchemaDesignerSchemaDiffResponse.class); + response.diff = ManagedSchemaDiff.diff(loadLatestSchema(sourceSchema), settings.getSchema()); + response.diffSource = sourceSchema; addSettingsToResponse(settings, response); - rsp.getValues().addAll(response); + return response; } - protected SampleDocuments loadSampleDocuments(SolrQueryRequest req, String configSet) - throws IOException { + protected SampleDocuments loadSampleDocuments(String configSet) throws IOException { List docs = null; - ContentStream stream = extractSingleContentStream(req, false); + ContentStream stream = extractSingleContentStream(false); SampleDocuments sampleDocs = null; if (stream != null && stream.getContentType() != null) { - sampleDocs = sampleDocLoader.parseDocsFromStream(req.getParams(), stream, MAX_SAMPLE_DOCS); + sampleDocs = + sampleDocLoader.parseDocsFromStream( + solrQueryRequest.getParams(), stream, MAX_SAMPLE_DOCS); docs = sampleDocs.parsed; if (!docs.isEmpty()) { // user posted in some docs, if there are already docs stored in the blob store, then add @@ -883,7 +937,7 @@ protected ManagedIndexSchema analyzeInputDocs( return schema; } - protected SchemaDesignerSettings getMutableSchemaForConfigSet( + SchemaDesignerSettings getMutableSchemaForConfigSet( final String configSet, final int schemaVersion, String copyFrom) throws IOException { // The designer works with mutable config sets stored in a "temp" znode in ZK instead of the // "live" configSet @@ -963,8 +1017,8 @@ ManagedIndexSchema loadLatestSchema(String configSet) { return configSetHelper.loadLatestSchema(configSet); } - protected ContentStream extractSingleContentStream(final SolrQueryRequest req, boolean required) { - Iterable streams = req.getContentStreams(); + protected ContentStream extractSingleContentStream(boolean required) { + Iterable streams = solrQueryRequest.getContentStreams(); Iterator iter = streams != null ? streams.iterator() : null; ContentStream stream = iter != null && iter.hasNext() ? iter.next() : null; if (required && stream == null) @@ -1104,7 +1158,7 @@ protected long waitToSeeSampleDocs(String collectionName, long numAdded) return numFound; } - protected Map buildResponse( + SchemaDesignerResponse buildSchemaDesignerResponse( String configSet, final ManagedIndexSchema schema, SchemaDesignerSettings settings, @@ -1114,50 +1168,44 @@ protected Map buildResponse( int currentVersion = configSetHelper.getCurrentSchemaVersion(mutableId); indexedVersion.put(mutableId, currentVersion); - // response is a map of data structures to support the schema designer - Map response = new HashMap<>(); + SchemaDesignerResponse response = instantiateJerseyResponse(SchemaDesignerResponse.class); DocCollection coll = zkStateReader().getCollection(mutableId); Collection activeSlices = coll.getActiveSlices(); if (!activeSlices.isEmpty()) { - String coreName = activeSlices.stream().findAny().orElseThrow().getLeader().getCoreName(); - response.put("core", coreName); + response.core = activeSlices.stream().findAny().orElseThrow().getLeader().getCoreName(); } - response.put(UNIQUE_KEY_FIELD_PARAM, schema.getUniqueKeyField().getName()); - - response.put(CONFIG_SET_PARAM, configSet); + response.uniqueKeyField = schema.getUniqueKeyField().getName(); + response.configSet = configSet; // important: pass the designer the current schema zk version for MVCC - response.put(SCHEMA_VERSION_PARAM, currentVersion); - response.put(TEMP_COLLECTION_PARAM, mutableId); - response.put("collectionsForConfig", configSetHelper.listCollectionsForConfig(configSet)); + response.schemaVersion = currentVersion; + response.tempCollection = mutableId; + response.collectionsForConfig = configSetHelper.listCollectionsForConfig(configSet); // Guess at a schema for each field found in the sample docs // Collect all fields across all docs with mapping to values - response.put( - "fields", + response.fields = schema.getFields().values().stream() .map(f -> fieldToMap(f, schema)) .sorted(Comparator.comparing(map -> ((String) map.get("name")))) - .collect(Collectors.toList())); + .collect(Collectors.toList()); if (settings == null) { settings = settingsDAO.getSettings(mutableId); } addSettingsToResponse(settings, response); - response.put( - "dynamicFields", + response.dynamicFields = Arrays.stream(schema.getDynamicFieldPrototypes()) .map(e -> e.getNamedPropertyValues(true)) .sorted(Comparator.comparing(map -> ((String) map.get("name")))) - .collect(Collectors.toList())); + .collect(Collectors.toList()); - response.put( - "fieldTypes", + response.fieldTypes = schema.getFieldTypes().values().stream() .map(fieldType -> fieldType.getNamedPropertyValues(true)) .sorted(Comparator.comparing(map -> ((String) map.get("name")))) - .collect(Collectors.toList())); + .collect(Collectors.toList()); // files SolrZkClient zkClient = zkStateReader().getZkClient(); @@ -1184,25 +1232,69 @@ protected Map buildResponse( List sortedFiles = new ArrayList<>(stripPrefix); Collections.sort(sortedFiles); - response.put("files", sortedFiles); + response.files = sortedFiles; // info about the sample docs if (docs != null) { final String uniqueKeyField = schema.getUniqueKeyField().getName(); - response.put( - "docIds", + response.docIds = docs.stream() .map(d -> (String) d.getFieldValue(uniqueKeyField)) .filter(Objects::nonNull) .limit(100) - .collect(Collectors.toList())); + .collect(Collectors.toList()); } - response.put("numDocs", docs != null ? docs.size() : -1); + response.numDocs = docs != null ? docs.size() : -1; return response; } + /** + * Sets the response's name field for the schema object that was just added, based on the action + * key from the Schema API request body. The four valid actions are pre-validated by {@link + * SchemaDesignerConfigSetHelper#addSchemaObject}, so reaching {@code default} indicates a + * programmer error (e.g. a new action added upstream without a corresponding case here). + */ + private static void setSchemaObjectField( + SchemaDesignerResponse response, String action, Object value) { + switch (action) { + case "add-field" -> response.field = value; + case "add-dynamic-field" -> response.dynamicField = value; + case "add-field-type" -> response.fieldType = value; + case "add-copy-field" -> { + // Copy fields have no single "name" to surface on the response — the JS UI only checks + // for an error and refreshes; nothing to set. + } + default -> throw new IllegalStateException( + "Unhandled schema-designer action '" + + action + + "'; addSchemaObject should have rejected this upstream."); + } + } + + /** + * Merges sample-document indexing errors into a response so the endpoint can return them to the + * UI instead of throwing. Sample docs are indexed into a temp collection to drive field-type + * inference; when that indexing fails, callers continue to build a normal response and use this + * method to attach the error details. + * + *

Two error sources are accepted (either or both may be present): + * + *

    + *
  • {@code solrExc} — a top-level exception (e.g. the whole indexing call failed). May be + * {@code null}. + *
  • {@code errorsDuringIndexing} — per-document failures. Keys are the failing document's + * unique-id field VALUE (typed as {@code Object} because the schema's id field type is + * unknown — typically {@code String}, but could be e.g. {@code Long}); values are the root + * cause for that doc. May be {@code null} or empty. + *
+ * + *

If both are absent, the response is left untouched. Otherwise, populates {@code updateError} + * (message), {@code updateErrorCode} (HTTP-style code; defaults to 400), and {@code errorDetails} + * (the per-doc map). The {@code updateError} parameter, when non-null, overrides {@code + * solrExc.getMessage()} as the user-facing message. + */ protected void addErrorToResponse( String collection, SolrException solrExc, @@ -1230,6 +1322,75 @@ protected void addErrorToResponse( } } + /** + * Overload that writes into the typed {@link SchemaDesignerResponse}. See {@link + * #addErrorToResponse(String, SolrException, Map, Map, String)} for full semantics. + */ + protected void addErrorToResponse( + String collection, + SolrException solrExc, + Map errorsDuringIndexing, + SchemaDesignerResponse response, + String updateError) { + + if (solrExc == null && (errorsDuringIndexing == null || errorsDuringIndexing.isEmpty())) { + return; // no errors + } + + if (updateError != null) { + response.updateError = updateError; + } + + if (solrExc != null) { + response.updateErrorCode = solrExc.code(); + if (response.updateError == null) { + response.updateError = solrExc.getMessage(); + } + } + + if (response.updateError == null) { + response.updateError = "Index sample documents into " + collection + " failed!"; + } + if (response.updateErrorCode == null) { + response.updateErrorCode = 400; + } + if (errorsDuringIndexing != null) { + response.errorDetails = errorsDuringIndexing; + } + } + + /** + * Overload that writes into the typed {@link SchemaDesignerPublishResponse}. See {@link + * #addErrorToResponse(String, SolrException, Map, Map, String)} for full semantics. Note this + * variant has no {@code updateError} override parameter — publish callers always derive the + * message from {@code solrExc} or the default. + */ + protected void addErrorToResponse( + String collection, + SolrException solrExc, + Map errorsDuringIndexing, + SchemaDesignerPublishResponse response) { + + if (solrExc == null && (errorsDuringIndexing == null || errorsDuringIndexing.isEmpty())) { + return; // no errors + } + + if (solrExc != null) { + response.updateErrorCode = solrExc.code(); + response.updateError = solrExc.getMessage(); + } + + if (response.updateError == null) { + response.updateError = "Index sample documents into " + collection + " failed!"; + } + if (response.updateErrorCode == null) { + response.updateErrorCode = 400; + } + if (errorsDuringIndexing != null) { + response.errorDetails = errorsDuringIndexing; + } + } + protected SimpleOrderedMap fieldToMap(SchemaField f, ManagedIndexSchema schema) { SimpleOrderedMap map = f.getNamedPropertyValues(true); @@ -1244,8 +1405,8 @@ protected SimpleOrderedMap fieldToMap(SchemaField f, ManagedIndexSchema } @SuppressWarnings("unchecked") - protected Map readJsonFromRequest(SolrQueryRequest req) throws IOException { - ContentStream stream = extractSingleContentStream(req, true); + protected Map readJsonFromRequest() throws IOException { + ContentStream stream = extractSingleContentStream(true); String contentType = stream.getContentType(); if (StrUtils.isNullOrEmpty(contentType) || !contentType.toLowerCase(Locale.ROOT).contains(JSON_MIME)) { @@ -1258,22 +1419,18 @@ protected Map readJsonFromRequest(SolrQueryRequest req) throws I return (Map) json; } - protected void addSettingsToResponse( - SchemaDesignerSettings settings, final Map response) { - response.put(LANGUAGES_PARAM, settings.getLanguages()); - response.put(ENABLE_FIELD_GUESSING_PARAM, settings.fieldGuessingEnabled()); - response.put(ENABLE_DYNAMIC_FIELDS_PARAM, settings.dynamicFieldsEnabled()); - response.put(ENABLE_NESTED_DOCS_PARAM, settings.nestedDocsEnabled()); - response.put(DISABLED, settings.isDisabled()); - Optional publishedVersion = settings.getPublishedVersion(); - publishedVersion.ifPresent(version -> response.put(PUBLISHED_VERSION, version)); - String copyFrom = settings.getCopyFrom(); - if (copyFrom != null) { - response.put(COPY_FROM_PARAM, copyFrom); - } + void addSettingsToResponse( + SchemaDesignerSettings settings, final SchemaDesignerSettingsResponse response) { + response.languages = settings.getLanguages(); + response.enableFieldGuessing = settings.fieldGuessingEnabled(); + response.enableDynamicFields = settings.dynamicFieldsEnabled(); + response.enableNestedDocs = settings.nestedDocsEnabled(); + response.disabled = settings.isDisabled(); + settings.getPublishedVersion().ifPresent(v -> response.publishedVersion = v); + response.copyFrom = settings.getCopyFrom(); } - protected String checkMutable(String configSet, SolrQueryRequest req) throws IOException { + protected String checkMutable(String configSet, int clientSchemaVersion) throws IOException { // an apply just copies over the temp config to the "live" location String mutableId = getMutableId(configSet); if (!configExists(mutableId)) { @@ -1288,34 +1445,27 @@ protected String checkMutable(String configSet, SolrQueryRequest req) throws IOE final int schemaVersionInZk = configSetHelper.getCurrentSchemaVersion(mutableId); if (schemaVersionInZk != -1) { // check the versions agree - configSetHelper.checkSchemaVersion( - mutableId, requireSchemaVersionFromClient(req), schemaVersionInZk); + configSetHelper.checkSchemaVersion(mutableId, clientSchemaVersion, schemaVersionInZk); } // else the stored is -1, can't really enforce here return mutableId; } - protected int requireSchemaVersionFromClient(SolrQueryRequest req) { - final int schemaVersion = req.getParams().getInt(SCHEMA_VERSION_PARAM, -1); - if (schemaVersion == -1) { + protected void requireSchemaVersion(Integer schemaVersion) { + if (schemaVersion == null || schemaVersion < 0) { throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - SCHEMA_VERSION_PARAM + " is a required parameter for the " + req.getPath() + " endpoint"); + SolrException.ErrorCode.BAD_REQUEST, SCHEMA_VERSION_PARAM + " is a required parameter!"); } - return schemaVersion; } - protected String getRequiredParam(final String param, final SolrQueryRequest req) { - final String paramValue = req.getParams().get(param); - if (StrUtils.isNullOrEmpty(paramValue)) { + protected void requireNotEmpty(final String param, final String value) { + if (StrUtils.isNullOrEmpty(value)) { throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - param + " is a required parameter for the " + req.getPath() + " endpoint!"); + SolrException.ErrorCode.BAD_REQUEST, param + " is a required parameter!"); } - return paramValue; } - protected void cleanupTemp(String configSet) throws IOException, SolrServerException { + protected void doCleanupTemp(String configSet) throws IOException, SolrServerException { String mutableId = getMutableId(configSet); indexedVersion.remove(mutableId); CollectionAdminRequest.deleteCollection(mutableId).process(cloudClient()); @@ -1323,6 +1473,13 @@ protected void cleanupTemp(String configSet) throws IOException, SolrServerExcep deleteConfig(mutableId); } + protected FlexibleSolrJerseyResponse buildFlexibleResponse(Map responseMap) { + FlexibleSolrJerseyResponse response = + instantiateJerseyResponse(FlexibleSolrJerseyResponse.class); + responseMap.forEach(response::setUnknownProperty); + return response; + } + private boolean configExists(String configSet) throws IOException { return coreContainer.getConfigSetService().checkConfigExists(configSet); } diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java index 907e0933acf4..7b5a0d41db51 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java @@ -20,24 +20,18 @@ import static org.apache.solr.common.params.CommonParams.VERSION_FIELD; import static org.apache.solr.common.util.Utils.toJavabin; import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getConfigSetZkPath; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getMutableId; +import static org.apache.solr.handler.designer.SchemaDesigner.getConfigSetZkPath; +import static org.apache.solr.handler.designer.SchemaDesigner.getMutableId; import static org.apache.solr.schema.IndexSchema.NEST_PATH_FIELD_NAME; import static org.apache.solr.schema.IndexSchema.ROOT_FIELD_NAME; import static org.apache.solr.schema.ManagedIndexSchemaFactory.DEFAULT_MANAGED_SCHEMA_RESOURCE_NAME; -import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandles; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -46,16 +40,11 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; -import org.apache.commons.io.FilenameUtils; -import org.apache.commons.io.file.PathUtils; import org.apache.lucene.util.IOSupplier; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrResponse; @@ -73,7 +62,6 @@ import org.apache.solr.common.SolrException.ErrorCode; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.common.cloud.DocCollection; -import org.apache.solr.common.cloud.Replica; import org.apache.solr.common.cloud.SolrZkClient; import org.apache.solr.common.cloud.ZkMaintenanceUtils; import org.apache.solr.common.cloud.ZkStateReader; @@ -382,7 +370,7 @@ boolean updateField( } } - // detect if they're trying to copy multi-valued fields into a single-valued field + // detect if they're trying to copy multivalued fields into a single-valued field Object multiValued = diff.get(MULTIVALUED); if (multiValued == null) { // mv not overridden explicitly, but we need the actual value, which will come from the new @@ -403,7 +391,7 @@ boolean updateField( name, src); multiValued = Boolean.TRUE; - diff.put(MULTIVALUED, multiValued); + diff.put(MULTIVALUED, true); break; } } @@ -414,8 +402,8 @@ boolean updateField( validateMultiValuedChange(configSet, schemaField, Boolean.FALSE); } - // switch from single-valued to multi-valued requires a full rebuild - // See SOLR-12185 ... if we're switching from single to multi-valued, then it's a big operation + // switch from single-valued to multivalued requires a full rebuild + // See SOLR-12185 ... if we're switching from single to multivalued, then it's a big operation if (fieldHasMultiValuedChange(multiValued, schemaField)) { needsRebuild = true; log.warn( @@ -535,29 +523,6 @@ static byte[] readAllBytes(IOSupplier hasStream) throws IOException } } - private String getBaseUrl(final String collection) { - String baseUrl = null; - try { - Set liveNodes = zkStateReader().getClusterState().getLiveNodes(); - DocCollection docColl = zkStateReader().getCollection(collection); - if (docColl != null && !liveNodes.isEmpty()) { - Optional maybeActive = - docColl.getReplicas().stream().filter(r -> r.isActive(liveNodes)).findAny(); - if (maybeActive.isPresent()) { - baseUrl = maybeActive.get().getBaseUrl(); - } - } - } catch (Exception exc) { - log.warn("Failed to lookup base URL for collection {}", collection, exc); - } - - if (baseUrl == null) { - baseUrl = zkStateReader().getBaseUrlForNodeName(cc.getZkController().getNodeName()); - } - - return baseUrl; - } - protected String getManagedSchemaZkPath(final String configSet) { return getConfigSetZkPath(configSet, DEFAULT_MANAGED_SCHEMA_RESOURCE_NAME); } @@ -706,7 +671,7 @@ boolean applyCopyFieldUpdates( continue; // cannot copy to self } - // make sure the field exists and is multi-valued if this field is + // make sure the field exists and is multivalued if this field is SchemaField toAddField = schema.getFieldOrNull(toAdd); if (toAddField != null) { if (!field.multiValued() || toAddField.multiValued()) { @@ -816,8 +781,8 @@ protected ManagedIndexSchema removeLanguageSpecificObjectsAndFiles( final Set toRemove = types.values().stream() .filter(this::isTextType) - .filter(t -> !languages.contains(t.getTypeName().substring(TEXT_PREFIX_LEN))) .map(FieldType::getTypeName) + .filter(typeName -> !languages.contains(typeName.substring(TEXT_PREFIX_LEN))) .filter(t -> !usedTypes.contains(t)) // not explicitly used by a field .collect(Collectors.toSet()); @@ -958,9 +923,9 @@ protected ManagedIndexSchema restoreLanguageSpecificObjectsAndFiles( List addDynFields = Arrays.stream(copyFromSchema.getDynamicFields()) - .filter(df -> langFieldTypeNames.contains(df.getPrototype().getType().getTypeName())) - .filter(df -> !existingDynFields.contains(df.getPrototype().getName())) .map(IndexSchema.DynamicField::getPrototype) + .filter(prototype -> langFieldTypeNames.contains(prototype.getType().getTypeName())) + .filter(prototype -> !existingDynFields.contains(prototype.getName())) .collect(Collectors.toList()); if (!addDynFields.isEmpty()) { schema = schema.addDynamicFields(addDynFields, null, false); @@ -1032,8 +997,8 @@ protected ManagedIndexSchema restoreDynamicFields( .collect(Collectors.toSet()); List toAdd = Arrays.stream(dynamicFields) - .filter(df -> !existingDFNames.contains(df.getPrototype().getName())) .map(IndexSchema.DynamicField::getPrototype) + .filter(prototype -> !existingDFNames.contains(prototype.getName())) .collect(Collectors.toList()); // only restore language specific dynamic fields that match our langSet @@ -1097,52 +1062,6 @@ List listConfigsInZk() throws IOException { return cc.getConfigSetService().listConfigs(); } - byte[] downloadAndZipConfigSet(String configId) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - Path tmpDirectory = - Files.createTempDirectory("schema-designer-" + FilenameUtils.getName(configId)); - try { - cc.getConfigSetService().downloadConfig(configId, tmpDirectory); - try (ZipOutputStream zipOut = new ZipOutputStream(baos)) { - Files.walkFileTree( - tmpDirectory, - new SimpleFileVisitor<>() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) - throws IOException { - if (Files.isHidden(dir)) { - return FileVisitResult.SKIP_SUBTREE; - } - - String dirName = tmpDirectory.relativize(dir).toString(); - if (!dirName.endsWith("/")) { - dirName += "/"; - } - zipOut.putNextEntry(new ZipEntry(dirName)); - zipOut.closeEntry(); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) - throws IOException { - if (!Files.isHidden(file)) { - try (InputStream fis = Files.newInputStream(file)) { - ZipEntry zipEntry = new ZipEntry(tmpDirectory.relativize(file).toString()); - zipOut.putNextEntry(zipEntry); - fis.transferTo(zipOut); - } - } - return FileVisitResult.CONTINUE; - } - }); - } - } finally { - PathUtils.deleteDirectory(tmpDirectory); - } - return baos.toByteArray(); - } - protected ZkSolrResourceLoader zkLoaderForConfigSet(final String configSet) { SolrResourceLoader loader = cc.getResourceLoader(); return new ZkSolrResourceLoader( diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConstants.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConstants.java index 0ad93d90d27c..5cb31ec954a6 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConstants.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConstants.java @@ -21,18 +21,13 @@ public interface SchemaDesignerConstants { String CONFIG_SET_PARAM = "configSet"; String COPY_FROM_PARAM = "copyFrom"; String SCHEMA_VERSION_PARAM = "schemaVersion"; - String RELOAD_COLLECTIONS_PARAM = "reloadCollections"; - String INDEX_TO_COLLECTION_PARAM = "indexToCollection"; String NEW_COLLECTION_PARAM = "newCollection"; - String CLEANUP_TEMP_PARAM = "cleanupTemp"; String ENABLE_DYNAMIC_FIELDS_PARAM = "enableDynamicFields"; String ENABLE_FIELD_GUESSING_PARAM = "enableFieldGuessing"; String ENABLE_NESTED_DOCS_PARAM = "enableNestedDocs"; String TEMP_COLLECTION_PARAM = "tempCollection"; String PUBLISHED_VERSION = "publishedVersion"; - String DISABLE_DESIGNER_PARAM = "disableDesigner"; String DISABLED = "disabled"; - String DOC_ID_PARAM = "docId"; String FIELD_PARAM = "field"; String UNIQUE_KEY_FIELD_PARAM = "uniqueKeyField"; String AUTO_CREATE_FIELDS = "update.autoCreateFields"; diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettingsDAO.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettingsDAO.java index 9a09e8e5da94..939a89004978 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettingsDAO.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettingsDAO.java @@ -17,7 +17,7 @@ package org.apache.solr.handler.designer; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getConfigSetZkPath; +import static org.apache.solr.handler.designer.SchemaDesigner.getConfigSetZkPath; import java.io.IOException; import java.lang.invoke.MethodHandles; diff --git a/solr/core/src/java/org/apache/solr/handler/designer/package-info.java b/solr/core/src/java/org/apache/solr/handler/designer/package-info.java index 17e3b7af2761..c8516c1c3273 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/package-info.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/package-info.java @@ -20,5 +20,5 @@ * limitations under the License. */ -/** The {@link org.apache.solr.handler.designer.SchemaDesignerAPI} and supporting classes. */ +/** The {@link org.apache.solr.handler.designer.SchemaDesigner} and supporting classes. */ package org.apache.solr.handler.designer; diff --git a/solr/core/src/java/org/apache/solr/search/ReRankOperator.java b/solr/core/src/java/org/apache/solr/search/ReRankOperator.java index b3b5582836cc..9072a18e25e3 100644 --- a/solr/core/src/java/org/apache/solr/search/ReRankOperator.java +++ b/solr/core/src/java/org/apache/solr/search/ReRankOperator.java @@ -25,6 +25,12 @@ public enum ReRankOperator implements DoubleBinaryOperator { MULTIPLY((firstPass, secondPass) -> firstPass * secondPass), REPLACE((firstPass, secondPass) -> secondPass); + /** + * The operators we use are immutable, even if we can't annotate them as such, and + * errorprone's "TIP" of using abstract methods would make this enum declaration 5 times longer -- + * so no thanks. + */ + @SuppressWarnings("ImmutableEnumChecker") private final DoubleBinaryOperator op; private ReRankOperator(final DoubleBinaryOperator op) { diff --git a/solr/core/src/test-files/solr/collection1/conf/solrconfig-analytics-query.xml b/solr/core/src/test-files/solr/collection1/conf/solrconfig-analytics-query.xml index 1012b14a0c8a..df769ec03f43 100644 --- a/solr/core/src/test-files/solr/collection1/conf/solrconfig-analytics-query.xml +++ b/solr/core/src/test-files/solr/collection1/conf/solrconfig-analytics-query.xml @@ -39,10 +39,6 @@ solr.StandardDirectoryFactory, the default, is filesystem based. solr.RAMDirectoryFactory is memory based and not persistent. --> - 1000000 - 2000000 - 3000000 - 4000000 diff --git a/solr/core/src/test-files/solr/collection1/conf/solrconfig-collapseqparser.xml b/solr/core/src/test-files/solr/collection1/conf/solrconfig-collapseqparser.xml index b346eb08db09..de5721946bf1 100644 --- a/solr/core/src/test-files/solr/collection1/conf/solrconfig-collapseqparser.xml +++ b/solr/core/src/test-files/solr/collection1/conf/solrconfig-collapseqparser.xml @@ -39,10 +39,6 @@ solr.StandardDirectoryFactory, the default, is filesystem based. solr.RAMDirectoryFactory is memory based and not persistent. --> - 1000000 - 2000000 - 3000000 - 4000000 diff --git a/solr/core/src/test-files/solr/collection1/conf/solrconfig-minhash.xml b/solr/core/src/test-files/solr/collection1/conf/solrconfig-minhash.xml index 8d6e8e842b84..92cc381d60cd 100644 --- a/solr/core/src/test-files/solr/collection1/conf/solrconfig-minhash.xml +++ b/solr/core/src/test-files/solr/collection1/conf/solrconfig-minhash.xml @@ -39,10 +39,6 @@ solr.StandardDirectoryFactory, the default, is filesystem based. solr.RAMDirectoryFactory is memory based and not persistent. --> - 1000000 - 2000000 - 3000000 - 4000000 diff --git a/solr/core/src/test-files/solr/collection1/conf/solrconfig-plugcollector.xml b/solr/core/src/test-files/solr/collection1/conf/solrconfig-plugcollector.xml index f543311b8562..e3afeae98e55 100644 --- a/solr/core/src/test-files/solr/collection1/conf/solrconfig-plugcollector.xml +++ b/solr/core/src/test-files/solr/collection1/conf/solrconfig-plugcollector.xml @@ -39,10 +39,6 @@ solr.StandardDirectoryFactory, the default, is filesystem based. solr.RAMDirectoryFactory is memory based and not persistent. --> - 1000000 - 2000000 - 3000000 - 4000000 diff --git a/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml b/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml index 6a960666848c..d7865f2a73aa 100644 --- a/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml +++ b/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml @@ -39,10 +39,6 @@ solr.StandardDirectoryFactory, the default, is filesystem based. solr.RAMDirectoryFactory is memory based and not persistent. --> - 1000000 - 2000000 - 3000000 - 4000000 diff --git a/solr/core/src/test/org/apache/solr/handler/admin/UpgradeCoreIndexActionTest.java b/solr/core/src/test/org/apache/solr/handler/admin/UpgradeCoreIndexActionTest.java index 536652f270d4..3a8e83171262 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/UpgradeCoreIndexActionTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/UpgradeCoreIndexActionTest.java @@ -87,7 +87,7 @@ public void testUpgradeCoreIndexSelectiveReindexDeletesOldSegments() throws Exce admin.handleRequestBody( req( CoreAdminParams.ACTION, - CoreAdminParams.CoreAdminAction.UPGRADECOREINDEX.toString(), + CoreAdminParams.CoreAdminAction.UPGRADEINDEX.toString(), CoreAdminParams.CORE, coreName), resp); @@ -132,14 +132,14 @@ public void testUpgradeCoreIndexAsyncRequestStatusContainsOperationResponse() th final Set segmentsBeforeUpgrade = listSegmentNames(core); - final String requestId = "upgradecoreindex_async_1"; + final String requestId = "upgradeindex_async_1"; CoreAdminHandler admin = new CoreAdminHandler(h.getCoreContainer()); try { SolrQueryResponse submitResp = new SolrQueryResponse(); admin.handleRequestBody( req( CoreAdminParams.ACTION, - CoreAdminParams.CoreAdminAction.UPGRADECOREINDEX.toString(), + CoreAdminParams.CoreAdminAction.UPGRADEINDEX.toString(), CoreAdminParams.CORE, coreName, CommonAdminParams.ASYNC, @@ -213,7 +213,7 @@ public void testNoUpgradeNeededWhenAllSegmentsCurrent() throws Exception { admin.handleRequestBody( req( CoreAdminParams.ACTION, - CoreAdminParams.CoreAdminAction.UPGRADECOREINDEX.toString(), + CoreAdminParams.CoreAdminAction.UPGRADEINDEX.toString(), CoreAdminParams.CORE, coreName), resp); @@ -360,7 +360,7 @@ public void testUpgradeCoreIndexFailsWithChildDocuments() throws Exception { admin.handleRequestBody( req( CoreAdminParams.ACTION, - CoreAdminParams.CoreAdminAction.UPGRADECOREINDEX.toString(), + CoreAdminParams.CoreAdminAction.UPGRADEINDEX.toString(), CoreAdminParams.CORE, coreName), resp)); @@ -386,7 +386,7 @@ public void testChildDocsDetection_noChildDocs() throws Exception { admin.handleRequestBody( req( CoreAdminParams.ACTION, - CoreAdminParams.CoreAdminAction.UPGRADECOREINDEX.toString(), + CoreAdminParams.CoreAdminAction.UPGRADEINDEX.toString(), CoreAdminParams.CORE, coreName), resp); @@ -413,7 +413,7 @@ public void testChildDocsDetection_withChildDocs() throws Exception { admin.handleRequestBody( req( CoreAdminParams.ACTION, - CoreAdminParams.CoreAdminAction.UPGRADECOREINDEX.toString(), + CoreAdminParams.CoreAdminAction.UPGRADEINDEX.toString(), CoreAdminParams.CORE, coreName), resp)); diff --git a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java index 10325b278ee8..7ae288e7fdaa 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java @@ -89,6 +89,29 @@ public void testSuccessfulDownloadReturnsZipResponse() throws Exception { try (final Response response = api.downloadConfigSet("myconfig")) { assertEquals(200, response.getStatus()); assertEquals("application/zip", response.getMediaType().toString()); + assertEquals( + "attachment; filename=\"myconfig_configset.zip\"", + response.getHeaderString("Content-Disposition")); } } + + @Test + public void testDesignerPrefixStrippedFromFilename() throws Exception { + createConfigSet("._designer_myschema", "solrconfig.xml", ""); + + final var api = new DownloadConfigSet(mockCoreContainer, null, null); + try (final Response response = api.downloadConfigSet("._designer_myschema")) { + assertEquals(200, response.getStatus()); + assertEquals( + "attachment; filename=\"myschema_configset.zip\"", + response.getHeaderString("Content-Disposition")); + } + } + + @Test + public void testDeriveDisplayName() { + assertEquals("myschema", DownloadConfigSet.deriveDisplayName("._designer_myschema")); + assertEquals("plain", DownloadConfigSet.deriveDisplayName("plain")); + assertEquals("", DownloadConfigSet.deriveDisplayName("._designer_")); + } } diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java new file mode 100644 index 000000000000..2845fe0707fa --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -0,0 +1,840 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.handler.designer; + +import static org.apache.solr.common.params.CommonParams.JSON_MIME; +import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; +import static org.apache.solr.handler.designer.SchemaDesigner.getMutableId; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SchemaDesignerAddRequestBody; +import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; +import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; +import org.apache.solr.client.api.model.SchemaDesignerResponse; +import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; +import org.apache.solr.client.api.model.SchemaDesignerUpdateRequestBody; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.request.SolrQuery; +import org.apache.solr.client.solrj.response.QueryResponse; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.SolrInputDocument; +import org.apache.solr.common.cloud.SolrZkClient; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.ModifiableSolrParams; +import org.apache.solr.common.util.ContentStream; +import org.apache.solr.common.util.ContentStreamBase; +import org.apache.solr.common.util.SimpleOrderedMap; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.handler.TestSampleDocumentsLoader; +import org.apache.solr.jersey.SolrJacksonMapper; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.ManagedIndexSchema; +import org.apache.solr.schema.SchemaField; +import org.apache.solr.util.ExternalPaths; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class TestSchemaDesigner extends SolrCloudTestCase implements SchemaDesignerConstants { + + private CoreContainer cc; + private SchemaDesigner schemaDesigner; + private SolrQueryRequest mockReq; + + @BeforeClass + public static void createCluster() throws Exception { + System.setProperty("managed.schema.mutable", "true"); + configureCluster(1) + .addConfig(DEFAULT_CONFIGSET_NAME, ExternalPaths.DEFAULT_CONFIGSET) + .configure(); + } + + @AfterClass + public static void tearDownCluster() throws Exception { + if (cluster != null && cluster.getSolrClient() != null) { + cluster.deleteAllCollections(); + cluster.deleteAllConfigSets(); + } + } + + @Before + public void setupTest() { + assumeWorkingMockito(); + assertNotNull(cluster); + cc = cluster.getJettySolrRunner(0).getCoreContainer(); + assertNotNull(cc); + mockReq = mock(SolrQueryRequest.class); + schemaDesigner = + new SchemaDesigner( + cc, + SchemaDesigner.newSchemaSuggester(), + SchemaDesigner.newSampleDocumentsLoader(), + mockReq); + } + + public void testTSV() throws Exception { + String configSet = "testTSV"; + + ModifiableSolrParams reqParams = new ModifiableSolrParams(); + reqParams.set(CONFIG_SET_PARAM, configSet); + reqParams.set(LANGUAGES_PARAM, "en"); + reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, false); + when(mockReq.getParams()).thenReturn(reqParams); + + String tsv = "id\tcol1\tcol2\n1\tfoo\tbar\n2\tbaz\tbah\n"; + + // POST some sample TSV docs + ContentStream stream = new ContentStreamBase.StringStream(tsv, "text/csv"); + when(mockReq.getContentStreams()).thenReturn(List.of(stream)); + + // POST /schema-designer/analyze + SchemaDesignerResponse response = + schemaDesigner.analyze(configSet, null, null, null, List.of("en"), false, null, null); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + assertEquals(Integer.valueOf(2), response.numDocs); + + reqParams.clear(); + reqParams.set(CONFIG_SET_PARAM, configSet); + when(mockReq.getContentStreams()).thenReturn(null); + schemaDesigner.cleanupTempSchema(configSet); + + String mutableId = getMutableId(configSet); + assertFalse(cc.getZkController().getClusterState().hasCollection(mutableId)); + SolrZkClient zkClient = cc.getZkController().getZkClient(); + assertFalse(zkClient.exists("/configs/" + mutableId)); + } + + @Test + @SuppressWarnings("unchecked") + public void testAddTechproductsProgressively() throws Exception { + Path docsDir = ExternalPaths.SOURCE_HOME.resolve("example/exampledocs"); + assertTrue(docsDir + " not found!", Files.isDirectory(docsDir)); + List toAdd; + try (Stream files = Files.list(docsDir)) { + toAdd = + files + .filter( + (dir) -> { + String name = dir.getFileName().toString(); + return name.endsWith(".xml") + || name.endsWith(".json") + || name.endsWith(".csv") + || name.endsWith(".jsonl"); + }) + .toList(); + assertNotNull("No test data files found in " + docsDir, toAdd); + } + + String configSet = "techproducts"; + + // GET /schema-designer/info + SchemaDesignerInfoResponse infoResponse = schemaDesigner.getInfo(configSet); + // response should just be the default values + Map expSettings = + Map.of( + ENABLE_DYNAMIC_FIELDS_PARAM, true, + ENABLE_FIELD_GUESSING_PARAM, true, + ENABLE_NESTED_DOCS_PARAM, false, + LANGUAGES_PARAM, List.of()); + assertDesignerSettings(expSettings, infoResponse); + int schemaVersion = infoResponse.schemaVersion; + assertEquals(schemaVersion, -1); // shouldn't exist yet + + // Use the prep endpoint to prepare the new schema + SchemaDesignerResponse response = schemaDesigner.prepNewSchema(configSet, null); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + schemaVersion = response.schemaVersion; + + for (Path next : toAdd) { + // Analyze some sample documents to refine the schema + ModifiableSolrParams reqParams = new ModifiableSolrParams(); + reqParams.set(CONFIG_SET_PARAM, configSet); + when(mockReq.getParams()).thenReturn(reqParams); + + // POST some sample JSON docs + ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(next); + stream.setContentType( + TestSampleDocumentsLoader.guessContentTypeFromFilename(next.getFileName().toString())); + when(mockReq.getContentStreams()).thenReturn(List.of(stream)); + + // POST /schema-designer/analyze + response = + schemaDesigner.analyze( + configSet, schemaVersion, null, null, List.of("en"), false, null, null); + + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + assertNotNull(response.fields); + assertNotNull(response.fieldTypes); + assertNotNull(response.docIds); + + // capture the schema version for MVCC + schemaVersion = response.schemaVersion; + } + + // get info (from the temp) + // GET /schema-designer/info + infoResponse = schemaDesigner.getInfo(configSet); + expSettings = + Map.of( + ENABLE_DYNAMIC_FIELDS_PARAM, false, + ENABLE_FIELD_GUESSING_PARAM, true, + ENABLE_NESTED_DOCS_PARAM, false, + LANGUAGES_PARAM, List.of("en"), + COPY_FROM_PARAM, "_default"); + assertDesignerSettings(expSettings, infoResponse); + + // query to see how the schema decisions impact retrieval / ranking + ModifiableSolrParams queryParams = new ModifiableSolrParams(); + queryParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); + queryParams.set(CONFIG_SET_PARAM, configSet); + queryParams.set(CommonParams.Q, "*:*"); + when(mockReq.getParams()).thenReturn(queryParams); + when(mockReq.getContentStreams()).thenReturn(null); + + // GET /schema-designer/query + FlexibleSolrJerseyResponse queryResp = schemaDesigner.query(configSet); + assertNotNull(queryResp.unknownProperties().get("responseHeader")); + @SuppressWarnings("unchecked") + Map queryResponse = + (Map) queryResp.unknownProperties().get("response"); + assertNotNull("response object must be a map with numFound/docs", queryResponse); + assertEquals(47L, queryResponse.get("numFound")); + @SuppressWarnings("unchecked") + List queryDocs = (List) queryResponse.get("docs"); + assertNotNull("response.docs must be a list", queryDocs); + assertFalse("response.docs must be non-empty", queryDocs.isEmpty()); + + // publish schema to a config set that can be used by real collections + String collection = "techproducts"; + schemaDesigner.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, true); + assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); + + // listCollectionsForConfig + SchemaDesignerCollectionsResponse collectionsResp = + schemaDesigner.listCollectionsForConfig(configSet); + List collections = collectionsResp.collections; + assertNotNull(collections); + assertTrue(collections.contains(collection)); + + // now try to create another temp, which should fail since designer is disabled for this + // configSet now + try { + schemaDesigner.prepNewSchema(configSet, null); + fail("Prep should fail for locked schema " + configSet); + } catch (SolrException solrExc) { + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, solrExc.code()); + } + } + + @Test + @SuppressWarnings("unchecked") + public void testSuggestFilmsXml() throws Exception { + String configSet = "films"; + + Path filmsDir = ExternalPaths.SOURCE_HOME.resolve("example/films"); + assertTrue(filmsDir + " not found!", Files.isDirectory(filmsDir)); + Path filmsXml = filmsDir.resolve("films.xml"); + assertTrue("example/films/films.xml not found", Files.isRegularFile(filmsXml)); + + // POST some sample XML docs + ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(filmsXml); + stream.setContentType("application/xml"); + ModifiableSolrParams reqParams = new ModifiableSolrParams(); + reqParams.set(CONFIG_SET_PARAM, configSet); + when(mockReq.getParams()).thenReturn(reqParams); + when(mockReq.getContentStreams()).thenReturn(List.of(stream)); + + // POST /schema-designer/analyze + SchemaDesignerResponse response = + schemaDesigner.analyze(configSet, null, null, null, null, true, null, null); + + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + assertNotNull(response.fields); + assertNotNull(response.fieldTypes); + List docIds = response.docIds; + assertNotNull(docIds); + assertEquals(100, docIds.size()); // designer limits the doc ids to top 100 + + String idField = response.uniqueKeyField; + assertNotNull(idField); + } + + @Test + @SuppressWarnings("unchecked") + public void testBasicUserWorkflow() throws Exception { + String configSet = "testJson"; + + // Use the prep endpoint to prepare the new schema + SchemaDesignerResponse response = schemaDesigner.prepNewSchema(configSet, null); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + + Map expSettings = + Map.of( + ENABLE_DYNAMIC_FIELDS_PARAM, true, + ENABLE_FIELD_GUESSING_PARAM, true, + ENABLE_NESTED_DOCS_PARAM, false, + LANGUAGES_PARAM, List.of(), + COPY_FROM_PARAM, "_default"); + assertDesignerSettings(expSettings, response); + + // Analyze some sample documents to refine the schema + Path booksJson = ExternalPaths.SOURCE_HOME.resolve("example/exampledocs/books.json"); + ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(booksJson); + stream.setContentType(JSON_MIME); + ModifiableSolrParams reqParams = new ModifiableSolrParams(); + reqParams.set(CONFIG_SET_PARAM, configSet); + when(mockReq.getParams()).thenReturn(reqParams); + when(mockReq.getContentStreams()).thenReturn(List.of(stream)); + + // POST /schema-designer/analyze + response = schemaDesigner.analyze(configSet, null, null, null, null, null, null, null); + + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + assertNotNull(response.fields); + assertNotNull(response.fieldTypes); + assertNotNull(response.docIds); + String idField = response.uniqueKeyField; + assertNotNull(idField); + assertDesignerSettings(expSettings, response); + + // capture the schema version for MVCC + int schemaVersion = response.schemaVersion; + + // load the contents of a file + Collection files = response.files; + assertTrue(files != null && !files.isEmpty()); + + String file = null; + for (String f : files) { + if ("solrconfig.xml".equals(f)) { + file = f; + break; + } + } + assertNotNull("solrconfig.xml not found in files!", file); + byte[] solrconfigBytes = + cc.getConfigSetService().downloadFileFromConfig(getMutableId(configSet), file); + assertNotNull(solrconfigBytes); + String solrconfigXml = new String(solrconfigBytes, StandardCharsets.UTF_8); + + // Update solrconfig.xml + response = + schemaDesigner.updateFileContents( + configSet, + file, + new ByteArrayInputStream(solrconfigXml.getBytes(StandardCharsets.UTF_8))); + schemaVersion = response.schemaVersion; + + // this should fail b/c the updated solrconfig.xml is invalid + response = + schemaDesigner.updateFileContents( + configSet, + file, + new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8))); + assertNotNull(response.updateFileError); + + // remove dynamic fields and change the language to "en" only + when(mockReq.getContentStreams()).thenReturn(null); + // POST /schema-designer/analyze + response = + schemaDesigner.analyze( + configSet, schemaVersion, null, null, List.of("en"), false, false, null); + + expSettings = + Map.of( + ENABLE_DYNAMIC_FIELDS_PARAM, false, + ENABLE_FIELD_GUESSING_PARAM, false, + ENABLE_NESTED_DOCS_PARAM, false, + LANGUAGES_PARAM, List.of("en"), + COPY_FROM_PARAM, "_default"); + assertDesignerSettings(expSettings, response); + + List filesInResp = response.files; + assertEquals(5, filesInResp.size()); + assertTrue(filesInResp.contains("lang/stopwords_en.txt")); + + schemaVersion = response.schemaVersion; + + // add the dynamic fields back and change the languages too + response = + schemaDesigner.analyze( + configSet, schemaVersion, null, null, Arrays.asList("en", "fr"), true, false, null); + + expSettings = + Map.of( + ENABLE_DYNAMIC_FIELDS_PARAM, true, + ENABLE_FIELD_GUESSING_PARAM, false, + ENABLE_NESTED_DOCS_PARAM, false, + LANGUAGES_PARAM, Arrays.asList("en", "fr"), + COPY_FROM_PARAM, "_default"); + assertDesignerSettings(expSettings, response); + + filesInResp = response.files; + assertEquals(7, filesInResp.size()); + assertTrue(filesInResp.contains("lang/stopwords_fr.txt")); + + schemaVersion = response.schemaVersion; + + // add back all the default languages (using "*" wildcard -> empty list) + response = + schemaDesigner.analyze( + configSet, schemaVersion, null, null, List.of("*"), false, null, null); + + expSettings = + Map.of( + ENABLE_DYNAMIC_FIELDS_PARAM, false, + ENABLE_FIELD_GUESSING_PARAM, false, + ENABLE_NESTED_DOCS_PARAM, false, + LANGUAGES_PARAM, List.of(), + COPY_FROM_PARAM, "_default"); + assertDesignerSettings(expSettings, response); + + filesInResp = response.files; + assertEquals(43, filesInResp.size()); + assertTrue(filesInResp.contains("lang/stopwords_fr.txt")); + assertTrue(filesInResp.contains("lang/stopwords_en.txt")); + assertTrue(filesInResp.contains("lang/stopwords_it.txt")); + + schemaVersion = response.schemaVersion; + + // Get the value of a sample document + String docId = "978-0641723445"; + String fieldName = "series_t"; + + // GET /schema-designer/sample + FlexibleSolrJerseyResponse sampleResp = + schemaDesigner.getSampleValue(configSet, fieldName, idField, docId); + assertNotNull(sampleResp.unknownProperties().get(idField)); + assertNotNull(sampleResp.unknownProperties().get(fieldName)); + assertNotNull(sampleResp.unknownProperties().get("analysis")); + + // at this point the user would refine the schema by + // editing suggestions for fields and adding/removing fields / field types as needed + + // add a new field + // POST /schema-designer/add + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-field.json")); + assertNotNull(response.field); + schemaVersion = response.schemaVersion; + assertNotNull(response.fields); + + // update an existing field + // switch a single-valued field to a multivalued field, which triggers a full rebuild of the + // "temp" collection + // PUT /schema-designer/update + response = + schemaDesigner.updateSchemaObject( + configSet, schemaVersion, loadUpdateBody("schema-designer/update-author-field.json")); + assertNotNull(response.field); + schemaVersion = response.schemaVersion; + + // add a new type + // POST /schema-designer/add + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-type.json")); + final String expectedTypeName = "test_txt"; + assertEquals(expectedTypeName, response.fieldType); + schemaVersion = response.schemaVersion; + assertNotNull(response.fieldTypes); + @SuppressWarnings("unchecked") + List> fieldTypes = response.fieldTypes; + Optional> expected = + fieldTypes.stream().filter(m -> expectedTypeName.equals(m.get("name"))).findFirst(); + assertTrue( + "New field type '" + expectedTypeName + "' not found in add type response!", + expected.isPresent()); + + // POST /schema-designer/update + response = + schemaDesigner.updateSchemaObject( + configSet, schemaVersion, loadUpdateBody("schema-designer/update-type.json")); + schemaVersion = response.schemaVersion; + + // query to see how the schema decisions impact retrieval / ranking + ModifiableSolrParams queryParams = new ModifiableSolrParams(); + queryParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); + queryParams.set(CONFIG_SET_PARAM, configSet); + queryParams.set(CommonParams.Q, "*:*"); + when(mockReq.getParams()).thenReturn(queryParams); + when(mockReq.getContentStreams()).thenReturn(null); + + // GET /schema-designer/query + FlexibleSolrJerseyResponse queryResp2 = schemaDesigner.query(configSet); + assertNotNull(queryResp2.unknownProperties().get("responseHeader")); + @SuppressWarnings("unchecked") + Map queryResponse2 = + (Map) queryResp2.unknownProperties().get("response"); + assertNotNull("response object must be a map with numFound/docs", queryResponse2); + @SuppressWarnings("unchecked") + List queryDocs2 = (List) queryResponse2.get("docs"); + assertEquals(4, queryDocs2.size()); + + // publish schema to a config set that can be used by real collections + String collection = "test123"; + schemaDesigner.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, false); + + assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); + + // listCollectionsForConfig + SchemaDesignerCollectionsResponse collectionsResp2 = + schemaDesigner.listCollectionsForConfig(configSet); + List collections = collectionsResp2.collections; + assertNotNull(collections); + assertTrue(collections.contains(collection)); + + // verify temp designer objects were cleaned up during the publish operation ... + String mutableId = getMutableId(configSet); + assertFalse(cc.getZkController().getClusterState().hasCollection(mutableId)); + SolrZkClient zkClient = cc.getZkController().getZkClient(); + assertFalse(zkClient.exists("/configs/" + mutableId)); + + SolrQuery query = new SolrQuery("*:*"); + query.setRows(0); + QueryResponse qr = cluster.getSolrClient().query(collection, query); + // this proves the docs were stored in the filestore too + assertEquals(4, qr.getResults().getNumFound()); + } + + @SuppressWarnings("unchecked") + public void testFieldUpdates() throws Exception { + String configSet = "fieldUpdates"; + + // Use the prep endpoint to prepare the new schema + SchemaDesignerResponse response = schemaDesigner.prepNewSchema(configSet, null); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + int schemaVersion = response.schemaVersion; + + // add our test field that we'll test various updates to + // POST /schema-designer/add + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-field.json")); + assertNotNull(response.field); + + final String fieldName = "keywords"; + + Optional> maybeField = + response.fields.stream().filter(m -> fieldName.equals(m.get("name"))).findFirst(); + assertTrue(maybeField.isPresent()); + Map field = maybeField.get(); + assertEquals(Boolean.FALSE, field.get("indexed")); + assertEquals(Boolean.FALSE, field.get("required")); + assertEquals(Boolean.TRUE, field.get("stored")); + assertEquals(Boolean.TRUE, field.get("docValues")); + assertEquals(Boolean.TRUE, field.get("useDocValuesAsStored")); + assertEquals(Boolean.FALSE, field.get("multiValued")); + assertEquals("string", field.get("type")); + + String mutableId = getMutableId(configSet); + SchemaDesignerConfigSetHelper configSetHelper = + new SchemaDesignerConfigSetHelper(cc, SchemaDesigner.newSchemaSuggester()); + ManagedIndexSchema schema = schemaDesigner.loadLatestSchema(mutableId); + + // make it required + Map updateField = + Map.of("name", fieldName, "type", field.get("type"), "required", true); + configSetHelper.updateField(configSet, updateField, schema); + + schema = schemaDesigner.loadLatestSchema(mutableId); + SchemaField schemaField = schema.getField(fieldName); + assertTrue(schemaField.isRequired()); + + updateField = + Map.of("name", fieldName, "type", field.get("type"), "required", false, "stored", false); + configSetHelper.updateField(configSet, updateField, schema); + schema = schemaDesigner.loadLatestSchema(mutableId); + schemaField = schema.getField(fieldName); + assertFalse(schemaField.isRequired()); + assertFalse(schemaField.stored()); + + updateField = + Map.of( + "name", + fieldName, + "type", + field.get("type"), + "required", + false, + "stored", + false, + "multiValued", + true); + configSetHelper.updateField(configSet, updateField, schema); + schema = schemaDesigner.loadLatestSchema(mutableId); + schemaField = schema.getField(fieldName); + assertFalse(schemaField.isRequired()); + assertFalse(schemaField.stored()); + assertTrue(schemaField.multiValued()); + + updateField = Map.of("name", fieldName, "type", "strings", "copyDest", "_text_"); + configSetHelper.updateField(configSet, updateField, schema); + schema = schemaDesigner.loadLatestSchema(mutableId); + schemaField = schema.getField(fieldName); + assertTrue(schemaField.multiValued()); + assertEquals("strings", schemaField.getType().getTypeName()); + assertFalse(schemaField.isRequired()); + assertTrue(schemaField.stored()); + List srcFields = schema.getCopySources("_text_"); + assertEquals(List.of(fieldName), srcFields); + } + + @SuppressWarnings({"unchecked"}) + public void testSchemaDiffEndpoint() throws Exception { + String configSet = "testDiff"; + + // Use the prep endpoint to prepare the new schema + SchemaDesignerResponse response = schemaDesigner.prepNewSchema(configSet, null); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + int schemaVersion = response.schemaVersion; + + // publish schema to a config set that can be used by real collections + String collection = "diff456"; + schemaDesigner.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, false); + + assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); + + // Load the schema designer for the existing config set and make some changes to it + ModifiableSolrParams reqParams = new ModifiableSolrParams(); + reqParams.set(CONFIG_SET_PARAM, configSet); + when(mockReq.getParams()).thenReturn(reqParams); + when(mockReq.getContentStreams()).thenReturn(null); + response = schemaDesigner.analyze(configSet, null, null, null, null, true, false, null); + + // Update id field to not use docValues + @SuppressWarnings("unchecked") + List> fields = + (List>) (List) response.fields; + SimpleOrderedMap idFieldMap = + fields.stream().filter(field -> field.get("name").equals("id")).findFirst().get(); + idFieldMap.remove("copyDest"); // Don't include copyDest as it is not a property of SchemaField + SimpleOrderedMap idFieldMapUpdated = idFieldMap.clone(); + idFieldMapUpdated.setVal(idFieldMapUpdated.indexOf("docValues", 0), Boolean.FALSE); + idFieldMapUpdated.setVal(idFieldMapUpdated.indexOf("useDocValuesAsStored", 0), Boolean.FALSE); + idFieldMapUpdated.setVal(idFieldMapUpdated.indexOf("uninvertible", 0), Boolean.TRUE); + idFieldMapUpdated.setVal( + idFieldMapUpdated.indexOf("omitTermFreqAndPositions", 0), Boolean.FALSE); + + Map mapParams = idFieldMapUpdated.toSolrParams().toMap(new HashMap<>()); + mapParams.put("termVectors", Boolean.FALSE); + schemaVersion = response.schemaVersion; + + SchemaDesignerUpdateRequestBody idFieldUpdate = + SolrJacksonMapper.getObjectMapper() + .convertValue(mapParams, SchemaDesignerUpdateRequestBody.class); + response = schemaDesigner.updateSchemaObject(configSet, schemaVersion, idFieldUpdate); + + // Add a new field + schemaVersion = response.schemaVersion; + // POST /schema-designer/add + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-field.json")); + assertNotNull(response.field); + + // Add a new field type + schemaVersion = response.schemaVersion; + // POST /schema-designer/add + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-type.json")); + assertNotNull(response.fieldType); + + // Let's do a diff now + SchemaDesignerSchemaDiffResponse diffResp = schemaDesigner.getSchemaDiff(configSet); + + Map diff = diffResp.diff; + + // field asserts + assertNotNull(diff.get("fields")); + Map fieldsDiff = (Map) diff.get("fields"); + assertNotNull(fieldsDiff.get("updated")); + Map mapDiff = (Map) fieldsDiff.get("updated"); + assertEquals( + Arrays.asList( + Map.of( + "omitTermFreqAndPositions", + true, + "useDocValuesAsStored", + true, + "docValues", + true, + "uninvertible", + false), + Map.of( + "omitTermFreqAndPositions", + false, + "useDocValuesAsStored", + false, + "docValues", + false, + "uninvertible", + true)), + mapDiff.get("id")); + assertNotNull(fieldsDiff.get("added")); + Map fieldsAdded = (Map) fieldsDiff.get("added"); + assertNotNull(fieldsAdded.get("keywords")); + + // field type asserts + assertNotNull(diff.get("fieldTypes")); + Map fieldTypesDiff = (Map) diff.get("fieldTypes"); + assertNotNull(fieldTypesDiff.get("added")); + Map fieldTypesAdded = (Map) fieldTypesDiff.get("added"); + assertNotNull(fieldTypesAdded.get("test_txt")); + } + + @Test + @SuppressWarnings("unchecked") + public void testQueryReturnsErrorDetailsOnIndexingFailure() throws Exception { + String configSet = "queryIndexErrTest"; + + // Prep the schema and analyze sample docs so the temp collection and stored docs exist + schemaDesigner.prepNewSchema(configSet, null); + ContentStreamBase.StringStream stream = + new ContentStreamBase.StringStream("[{\"id\":\"doc1\",\"title\":\"test doc\"}]", JSON_MIME); + ModifiableSolrParams reqParams = new ModifiableSolrParams(); + reqParams.set(CONFIG_SET_PARAM, configSet); + when(mockReq.getParams()).thenReturn(reqParams); + when(mockReq.getContentStreams()).thenReturn(List.of(stream)); + schemaDesigner.analyze(configSet, null, null, null, null, null, null, null); + + // Build a fresh API instance whose indexedVersion cache is empty (so it always + // attempts to re-index before running the query), and which simulates indexing errors. + Map fakeErrors = new HashMap<>(); + fakeErrors.put("doc1", new RuntimeException("simulated indexing failure")); + SchemaDesigner apiWithErrors = + new SchemaDesigner( + cc, + SchemaDesigner.newSchemaSuggester(), + SchemaDesigner.newSampleDocumentsLoader(), + mockReq) { + @Override + protected Map indexSampleDocsWithRebuildOnAnalysisError( + String idField, + List docs, + String collectionName, + boolean asBatch, + String[] analysisErrorHolder) + throws IOException, SolrServerException { + return fakeErrors; + } + }; + + when(mockReq.getContentStreams()).thenReturn(null); + FlexibleSolrJerseyResponse response = apiWithErrors.query(configSet); + + Map props = response.unknownProperties(); + assertNotNull("updateError must be present in error response", props.get(UPDATE_ERROR)); + assertEquals(400, props.get("updateErrorCode")); + Map details = (Map) props.get(ERROR_DETAILS); + assertNotNull("errorDetails must be present in error response", details); + assertTrue("errorDetails must contain the failing doc id", details.containsKey("doc1")); + } + + @Test + public void testRequireSchemaVersionRejectsNegativeValues() throws Exception { + String configSet = "schemaVersionValidation"; + schemaDesigner.prepNewSchema(configSet, null); + + // null schemaVersion must be rejected + SolrException nullEx = + expectThrows( + SolrException.class, () -> schemaDesigner.addSchemaObject(configSet, null, null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, nullEx.code()); + + // negative schemaVersion must be rejected (was previously bypassing validation) + SolrException negEx = + expectThrows( + SolrException.class, () -> schemaDesigner.addSchemaObject(configSet, -1, null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, negEx.code()); + + // same contract must hold for updateSchemaObject + SolrException updateNegEx = + expectThrows( + SolrException.class, () -> schemaDesigner.updateSchemaObject(configSet, -1, null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, updateNegEx.code()); + } + + protected void assertDesignerSettings(Map expected, Map actual) { + for (String expKey : expected.keySet()) { + Object expValue = expected.get(expKey); + assertEquals( + "Value for designer setting '" + expKey + "' not match expected!", + expValue, + actual.get(expKey)); + } + } + + protected void assertDesignerSettings( + Map expected, SchemaDesignerResponse response) { + Map actual = new HashMap<>(); + actual.put(LANGUAGES_PARAM, response.languages); + actual.put(ENABLE_FIELD_GUESSING_PARAM, response.enableFieldGuessing); + actual.put(ENABLE_DYNAMIC_FIELDS_PARAM, response.enableDynamicFields); + actual.put(ENABLE_NESTED_DOCS_PARAM, response.enableNestedDocs); + actual.put(COPY_FROM_PARAM, response.copyFrom); + assertDesignerSettings(expected, actual); + } + + protected void assertDesignerSettings( + Map expected, SchemaDesignerInfoResponse response) { + Map actual = new HashMap<>(); + actual.put(LANGUAGES_PARAM, response.languages); + actual.put(ENABLE_FIELD_GUESSING_PARAM, response.enableFieldGuessing); + actual.put(ENABLE_DYNAMIC_FIELDS_PARAM, response.enableDynamicFields); + actual.put(ENABLE_NESTED_DOCS_PARAM, response.enableNestedDocs); + actual.put(COPY_FROM_PARAM, response.copyFrom); + assertDesignerSettings(expected, actual); + } + + private SchemaDesignerAddRequestBody loadAddBody(String fixturePath) throws IOException { + return SolrJacksonMapper.getObjectMapper() + .readValue(getFile(fixturePath).toFile(), SchemaDesignerAddRequestBody.class); + } + + private SchemaDesignerUpdateRequestBody loadUpdateBody(String fixturePath) throws IOException { + return SolrJacksonMapper.getObjectMapper() + .readValue(getFile(fixturePath).toFile(), SchemaDesignerUpdateRequestBody.class); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java deleted file mode 100644 index ed1aba6ceb58..000000000000 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ /dev/null @@ -1,936 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.handler.designer; - -import static org.apache.solr.common.params.CommonParams.JSON_MIME; -import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getMutableId; -import static org.apache.solr.response.RawResponseWriter.CONTENT; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Stream; -import org.apache.solr.client.solrj.request.SolrQuery; -import org.apache.solr.client.solrj.response.QueryResponse; -import org.apache.solr.cloud.SolrCloudTestCase; -import org.apache.solr.common.SolrDocumentList; -import org.apache.solr.common.SolrException; -import org.apache.solr.common.cloud.SolrZkClient; -import org.apache.solr.common.params.CommonParams; -import org.apache.solr.common.params.ModifiableSolrParams; -import org.apache.solr.common.params.SolrParams; -import org.apache.solr.common.util.ContentStream; -import org.apache.solr.common.util.ContentStreamBase; -import org.apache.solr.common.util.NamedList; -import org.apache.solr.common.util.SimpleOrderedMap; -import org.apache.solr.core.CoreContainer; -import org.apache.solr.handler.TestSampleDocumentsLoader; -import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.SolrQueryResponse; -import org.apache.solr.schema.ManagedIndexSchema; -import org.apache.solr.schema.SchemaField; -import org.apache.solr.util.ExternalPaths; -import org.junit.AfterClass; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; -import org.noggit.JSONUtil; - -public class TestSchemaDesignerAPI extends SolrCloudTestCase implements SchemaDesignerConstants { - - private CoreContainer cc; - private SchemaDesignerAPI schemaDesignerAPI; - - @BeforeClass - public static void createCluster() throws Exception { - System.setProperty("managed.schema.mutable", "true"); - configureCluster(1) - .addConfig(DEFAULT_CONFIGSET_NAME, ExternalPaths.DEFAULT_CONFIGSET) - .configure(); - } - - @AfterClass - public static void tearDownCluster() throws Exception { - if (cluster != null && cluster.getSolrClient() != null) { - cluster.deleteAllCollections(); - cluster.deleteAllConfigSets(); - } - } - - @Before - public void setupTest() { - assumeWorkingMockito(); - assertNotNull(cluster); - cc = cluster.getJettySolrRunner(0).getCoreContainer(); - assertNotNull(cc); - schemaDesignerAPI = new SchemaDesignerAPI(cc); - } - - public void testTSV() throws Exception { - String configSet = "testTSV"; - - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - - // GET /schema-designer/info - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(LANGUAGES_PARAM, "en"); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, false); - when(req.getParams()).thenReturn(reqParams); - - String tsv = "id\tcol1\tcol2\n1\tfoo\tbar\n2\tbaz\tbah\n"; - - // POST some sample TSV docs - ContentStream stream = new ContentStreamBase.StringStream(tsv, "text/csv"); - when(req.getContentStreams()).thenReturn(List.of(stream)); - - // POST /schema-designer/analyze - schemaDesignerAPI.analyze(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - assertEquals(2, rsp.getValues().get("numDocs")); - - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.cleanupTemp(req, rsp); - - String mutableId = getMutableId(configSet); - assertFalse(cc.getZkController().getClusterState().hasCollection(mutableId)); - SolrZkClient zkClient = cc.getZkController().getZkClient(); - assertFalse(zkClient.exists("/configs/" + mutableId)); - } - - @Test - @SuppressWarnings("unchecked") - public void testAddTechproductsProgressively() throws Exception { - Path docsDir = ExternalPaths.SOURCE_HOME.resolve("example/exampledocs"); - assertTrue(docsDir + " not found!", Files.isDirectory(docsDir)); - List toAdd; - try (Stream files = Files.list(docsDir)) { - toAdd = - files - .filter( - (dir) -> { - String name = dir.getFileName().toString(); - return name.endsWith(".xml") - || name.endsWith(".json") - || name.endsWith(".csv") - || name.endsWith(".jsonl"); - }) - .toList(); - assertNotNull("No test data files found in " + docsDir, toAdd); - } - - String configSet = "techproducts"; - - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - - // GET /schema-designer/info - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - reqParams.set(CONFIG_SET_PARAM, configSet); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.getInfo(req, rsp); - // response should just be the default values - Map expSettings = - Map.of( - ENABLE_DYNAMIC_FIELDS_PARAM, true, - ENABLE_FIELD_GUESSING_PARAM, true, - ENABLE_NESTED_DOCS_PARAM, false, - LANGUAGES_PARAM, List.of()); - assertDesignerSettings(expSettings, rsp.getValues()); - SolrParams rspData = rsp.getValues().toSolrParams(); - int schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - assertEquals(schemaVersion, -1); // shouldn't exist yet - - // Use the prep endpoint to prepare the new schema - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.prepNewSchema(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - for (Path next : toAdd) { - // Analyze some sample documents to refine the schema - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(LANGUAGES_PARAM, "en"); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, false); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - - // POST some sample JSON docs - ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(next); - stream.setContentType( - TestSampleDocumentsLoader.guessContentTypeFromFilename(next.getFileName().toString())); - when(req.getContentStreams()).thenReturn(List.of(stream)); - - rsp = new SolrQueryResponse(); - - // POST /schema-designer/analyze - schemaDesignerAPI.analyze(req, rsp); - - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - assertNotNull(rsp.getValues().get("fields")); - assertNotNull(rsp.getValues().get("fieldTypes")); - assertNotNull(rsp.getValues().get("docIds")); - - // capture the schema version for MVCC - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - } - - // get info (from the temp) - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); - - // GET /schema-designer/info - schemaDesignerAPI.getInfo(req, rsp); - expSettings = - Map.of( - ENABLE_DYNAMIC_FIELDS_PARAM, false, - ENABLE_FIELD_GUESSING_PARAM, true, - ENABLE_NESTED_DOCS_PARAM, false, - LANGUAGES_PARAM, List.of("en"), - COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); - - // query to see how the schema decisions impact retrieval / ranking - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(CommonParams.Q, "*:*"); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); - - // GET /schema-designer/query - schemaDesignerAPI.query(req, rsp); - assertNotNull(rsp.getResponseHeader()); - SolrDocumentList results = (SolrDocumentList) rsp.getResponse(); - assertEquals(47, results.getNumFound()); - - // publish schema to a config set that can be used by real collections - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - String collection = "techproducts"; - reqParams.set(NEW_COLLECTION_PARAM, collection); - reqParams.set(INDEX_TO_COLLECTION_PARAM, true); - reqParams.set(RELOAD_COLLECTIONS_PARAM, true); - reqParams.set(CLEANUP_TEMP_PARAM, true); - reqParams.set(DISABLE_DESIGNER_PARAM, true); - - rsp = new SolrQueryResponse(); - schemaDesignerAPI.publish(req, rsp); - assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); - - // listCollectionsForConfig - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.listCollectionsForConfig(req, rsp); - List collections = (List) rsp.getValues().get("collections"); - assertNotNull(collections); - assertTrue(collections.contains(collection)); - - // now try to create another temp, which should fail since designer is disabled for this - // configSet now - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - try { - schemaDesignerAPI.prepNewSchema(req, rsp); - fail("Prep should fail for locked schema " + configSet); - } catch (SolrException solrExc) { - assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, solrExc.code()); - } - } - - @Test - @SuppressWarnings("unchecked") - public void testSuggestFilmsXml() throws Exception { - String configSet = "films"; - - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - - Path filmsDir = ExternalPaths.SOURCE_HOME.resolve("example/films"); - assertTrue(filmsDir + " not found!", Files.isDirectory(filmsDir)); - Path filmsXml = filmsDir.resolve("films.xml"); - assertTrue("example/films/films.xml not found", Files.isRegularFile(filmsXml)); - - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, "true"); - - SolrQueryRequest req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - - // POST some sample XML docs - ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(filmsXml); - stream.setContentType("application/xml"); - when(req.getContentStreams()).thenReturn(List.of(stream)); - - SolrQueryResponse rsp = new SolrQueryResponse(); - - // POST /schema-designer/analyze - schemaDesignerAPI.analyze(req, rsp); - - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - assertNotNull(rsp.getValues().get("fields")); - assertNotNull(rsp.getValues().get("fieldTypes")); - List docIds = (List) rsp.getValues().get("docIds"); - assertNotNull(docIds); - assertEquals(100, docIds.size()); // designer limits the doc ids to top 100 - - String idField = rsp.getValues()._getStr(UNIQUE_KEY_FIELD_PARAM); - assertNotNull(idField); - } - - @Test - @SuppressWarnings("unchecked") - public void testBasicUserWorkflow() throws Exception { - String configSet = "testJson"; - - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - - // Use the prep endpoint to prepare the new schema - reqParams.set(CONFIG_SET_PARAM, configSet); - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.prepNewSchema(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - - Map expSettings = - Map.of( - ENABLE_DYNAMIC_FIELDS_PARAM, true, - ENABLE_FIELD_GUESSING_PARAM, true, - ENABLE_NESTED_DOCS_PARAM, false, - LANGUAGES_PARAM, List.of(), - COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); - - // Analyze some sample documents to refine the schema - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - - // POST some sample JSON docs - Path booksJson = ExternalPaths.SOURCE_HOME.resolve("example/exampledocs/books.json"); - ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(booksJson); - stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(List.of(stream)); - - rsp = new SolrQueryResponse(); - - // POST /schema-designer/analyze - schemaDesignerAPI.analyze(req, rsp); - - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - assertNotNull(rsp.getValues().get("fields")); - assertNotNull(rsp.getValues().get("fieldTypes")); - assertNotNull(rsp.getValues().get("docIds")); - String idField = rsp.getValues()._getStr(UNIQUE_KEY_FIELD_PARAM); - assertNotNull(idField); - assertDesignerSettings(expSettings, rsp.getValues()); - - // capture the schema version for MVCC - SolrParams rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - int schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - // load the contents of a file - Collection files = (Collection) rsp.getValues().get("files"); - assertTrue(files != null && !files.isEmpty()); - - reqParams.set(CONFIG_SET_PARAM, configSet); - String file = null; - for (String f : files) { - if ("solrconfig.xml".equals(f)) { - file = f; - break; - } - } - assertNotNull("solrconfig.xml not found in files!", file); - reqParams.set("file", file); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.getFileContents(req, rsp); - String solrconfigXml = (String) rsp.getValues().get(file); - assertNotNull(solrconfigXml); - reqParams.clear(); - - // Update solrconfig.xml - rsp = new SolrQueryResponse(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set("file", file); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - when(req.getContentStreams()) - .thenReturn(List.of(new ContentStreamBase.StringStream(solrconfigXml, "application/xml"))); - - schemaDesignerAPI.updateFileContents(req, rsp); - rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - // update solrconfig.xml with some invalid XML mess - rsp = new SolrQueryResponse(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set("file", file); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - when(req.getContentStreams()) - .thenReturn(List.of(new ContentStreamBase.StringStream("", "application/xml"))); - - // this should fail b/c the updated solrconfig.xml is invalid - schemaDesignerAPI.updateFileContents(req, rsp); - rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - assertNotNull(rspData.get("updateFileError")); - - // remove dynamic fields and change the language to "en" only - rsp = new SolrQueryResponse(); - // POST /schema-designer/analyze - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(LANGUAGES_PARAM, "en"); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, false); - reqParams.set(ENABLE_FIELD_GUESSING_PARAM, false); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.analyze(req, rsp); - - expSettings = - Map.of( - ENABLE_DYNAMIC_FIELDS_PARAM, false, - ENABLE_FIELD_GUESSING_PARAM, false, - ENABLE_NESTED_DOCS_PARAM, false, - LANGUAGES_PARAM, List.of("en"), - COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); - - List filesInResp = (List) rsp.getValues().get("files"); - assertEquals(5, filesInResp.size()); - assertTrue(filesInResp.contains("lang/stopwords_en.txt")); - - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - reqParams.clear(); - - // add the dynamic fields back and change the languages too - rsp = new SolrQueryResponse(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.add(LANGUAGES_PARAM, "en"); - reqParams.add(LANGUAGES_PARAM, "fr"); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, true); - reqParams.set(ENABLE_FIELD_GUESSING_PARAM, false); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.analyze(req, rsp); - - expSettings = - Map.of( - ENABLE_DYNAMIC_FIELDS_PARAM, true, - ENABLE_FIELD_GUESSING_PARAM, false, - ENABLE_NESTED_DOCS_PARAM, false, - LANGUAGES_PARAM, Arrays.asList("en", "fr"), - COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); - - filesInResp = (List) rsp.getValues().get("files"); - assertEquals(7, filesInResp.size()); - assertTrue(filesInResp.contains("lang/stopwords_fr.txt")); - - rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - // add back all the default languages - rsp = new SolrQueryResponse(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.add(LANGUAGES_PARAM, "*"); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, false); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.analyze(req, rsp); - - expSettings = - Map.of( - ENABLE_DYNAMIC_FIELDS_PARAM, false, - ENABLE_FIELD_GUESSING_PARAM, false, - ENABLE_NESTED_DOCS_PARAM, false, - LANGUAGES_PARAM, List.of(), - COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); - - filesInResp = (List) rsp.getValues().get("files"); - assertEquals(43, filesInResp.size()); - assertTrue(filesInResp.contains("lang/stopwords_fr.txt")); - assertTrue(filesInResp.contains("lang/stopwords_en.txt")); - assertTrue(filesInResp.contains("lang/stopwords_it.txt")); - - rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - // Get the value of a sample document - String docId = "978-0641723445"; - String fieldName = "series_t"; - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(DOC_ID_PARAM, docId); - reqParams.set(FIELD_PARAM, fieldName); - reqParams.set(UNIQUE_KEY_FIELD_PARAM, idField); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); - - // GET /schema-designer/sample - schemaDesignerAPI.getSampleValue(req, rsp); - rspData = rsp.getValues().toSolrParams(); - assertNotNull(rspData.get(idField)); - assertNotNull(rspData.get(fieldName)); - assertNotNull(rspData.get("analysis")); - - reqParams.clear(); - - // at this point the user would refine the schema by - // editing suggestions for fields and adding/removing fields / field types as needed - - // add a new field - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - stream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); - stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(List.of(stream)); - rsp = new SolrQueryResponse(); - - // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("add-field")); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - assertNotNull(rsp.getValues().get("fields")); - - // update an existing field - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - // switch a single-valued field to a multi-valued field, which triggers a full rebuild of the - // "temp" collection - stream = new ContentStreamBase.FileStream(getFile("schema-designer/update-author-field.json")); - stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(List.of(stream)); - - rsp = new SolrQueryResponse(); - - // PUT /schema-designer/update - schemaDesignerAPI.updateSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("field")); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - // add a new type - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - stream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-type.json")); - stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(List.of(stream)); - rsp = new SolrQueryResponse(); - - // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); - final String expectedTypeName = "test_txt"; - assertEquals(expectedTypeName, rsp.getValues().get("add-field-type")); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - assertNotNull(rsp.getValues().get("fieldTypes")); - List> fieldTypes = - (List>) rsp.getValues().get("fieldTypes"); - Optional> expected = - fieldTypes.stream().filter(m -> expectedTypeName.equals(m.get("name"))).findFirst(); - assertTrue( - "New field type '" + expectedTypeName + "' not found in add type response!", - expected.isPresent()); - - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - stream = new ContentStreamBase.FileStream(getFile("schema-designer/update-type.json")); - stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(List.of(stream)); - rsp = new SolrQueryResponse(); - - // POST /schema-designer/update - schemaDesignerAPI.updateSchemaObject(req, rsp); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - // query to see how the schema decisions impact retrieval / ranking - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(CommonParams.Q, "*:*"); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); - - // GET /schema-designer/query - schemaDesignerAPI.query(req, rsp); - assertNotNull(rsp.getResponseHeader()); - SolrDocumentList results = (SolrDocumentList) rsp.getResponse(); - assertEquals(4, results.size()); - - // Download ZIP - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.downloadConfig(req, rsp); - assertNotNull(rsp.getValues().get(CONTENT)); - - // publish schema to a config set that can be used by real collections - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - String collection = "test123"; - reqParams.set(NEW_COLLECTION_PARAM, collection); - reqParams.set(INDEX_TO_COLLECTION_PARAM, true); - reqParams.set(RELOAD_COLLECTIONS_PARAM, true); - reqParams.set(CLEANUP_TEMP_PARAM, true); - - rsp = new SolrQueryResponse(); - schemaDesignerAPI.publish(req, rsp); - - assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); - - // listCollectionsForConfig - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.listCollectionsForConfig(req, rsp); - List collections = (List) rsp.getValues().get("collections"); - assertNotNull(collections); - assertTrue(collections.contains(collection)); - - // verify temp designer objects were cleaned up during the publish operation ... - String mutableId = getMutableId(configSet); - assertFalse(cc.getZkController().getClusterState().hasCollection(mutableId)); - SolrZkClient zkClient = cc.getZkController().getZkClient(); - assertFalse(zkClient.exists("/configs/" + mutableId)); - - SolrQuery query = new SolrQuery("*:*"); - query.setRows(0); - QueryResponse qr = cluster.getSolrClient().query(collection, query); - // this proves the docs were stored in the filestore too - assertEquals(4, qr.getResults().getNumFound()); - } - - @SuppressWarnings("unchecked") - public void testFieldUpdates() throws Exception { - String configSet = "fieldUpdates"; - - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - - // Use the prep endpoint to prepare the new schema - reqParams.set(CONFIG_SET_PARAM, configSet); - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.prepNewSchema(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - SolrParams rspData = rsp.getValues().toSolrParams(); - int schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - // add our test field that we'll test various updates to - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - ContentStreamBase.FileStream stream = - new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); - stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(List.of(stream)); - rsp = new SolrQueryResponse(); - - // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("add-field")); - - final String fieldName = "keywords"; - - Optional> maybeField = - ((List>) rsp.getValues().get("fields")) - .stream().filter(m -> fieldName.equals(m.get("name"))).findFirst(); - assertTrue(maybeField.isPresent()); - SimpleOrderedMap field = maybeField.get(); - assertEquals(Boolean.FALSE, field.get("indexed")); - assertEquals(Boolean.FALSE, field.get("required")); - assertEquals(Boolean.TRUE, field.get("stored")); - assertEquals(Boolean.TRUE, field.get("docValues")); - assertEquals(Boolean.TRUE, field.get("useDocValuesAsStored")); - assertEquals(Boolean.FALSE, field.get("multiValued")); - assertEquals("string", field.get("type")); - - String mutableId = getMutableId(configSet); - SchemaDesignerConfigSetHelper configSetHelper = - new SchemaDesignerConfigSetHelper(cc, SchemaDesignerAPI.newSchemaSuggester()); - ManagedIndexSchema schema = schemaDesignerAPI.loadLatestSchema(mutableId); - - // make it required - Map updateField = - Map.of("name", fieldName, "type", field.get("type"), "required", true); - configSetHelper.updateField(configSet, updateField, schema); - - schema = schemaDesignerAPI.loadLatestSchema(mutableId); - SchemaField schemaField = schema.getField(fieldName); - assertTrue(schemaField.isRequired()); - - updateField = - Map.of("name", fieldName, "type", field.get("type"), "required", false, "stored", false); - configSetHelper.updateField(configSet, updateField, schema); - schema = schemaDesignerAPI.loadLatestSchema(mutableId); - schemaField = schema.getField(fieldName); - assertFalse(schemaField.isRequired()); - assertFalse(schemaField.stored()); - - updateField = - Map.of( - "name", - fieldName, - "type", - field.get("type"), - "required", - false, - "stored", - false, - "multiValued", - true); - configSetHelper.updateField(configSet, updateField, schema); - schema = schemaDesignerAPI.loadLatestSchema(mutableId); - schemaField = schema.getField(fieldName); - assertFalse(schemaField.isRequired()); - assertFalse(schemaField.stored()); - assertTrue(schemaField.multiValued()); - - updateField = Map.of("name", fieldName, "type", "strings", "copyDest", "_text_"); - configSetHelper.updateField(configSet, updateField, schema); - schema = schemaDesignerAPI.loadLatestSchema(mutableId); - schemaField = schema.getField(fieldName); - assertTrue(schemaField.multiValued()); - assertEquals("strings", schemaField.getType().getTypeName()); - assertFalse(schemaField.isRequired()); - assertTrue(schemaField.stored()); - List srcFields = schema.getCopySources("_text_"); - assertEquals(List.of(fieldName), srcFields); - } - - @SuppressWarnings({"unchecked"}) - public void testSchemaDiffEndpoint() throws Exception { - String configSet = "testDiff"; - - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - - // Use the prep endpoint to prepare the new schema - reqParams.set(CONFIG_SET_PARAM, configSet); - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.prepNewSchema(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - - // publish schema to a config set that can be used by real collections - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(rsp.getValues().get(SCHEMA_VERSION_PARAM))); - reqParams.set(CONFIG_SET_PARAM, configSet); - - String collection = "diff456"; - reqParams.set(NEW_COLLECTION_PARAM, collection); - reqParams.set(INDEX_TO_COLLECTION_PARAM, true); - reqParams.set(RELOAD_COLLECTIONS_PARAM, true); - reqParams.set(CLEANUP_TEMP_PARAM, true); - - rsp = new SolrQueryResponse(); - schemaDesignerAPI.publish(req, rsp); - - assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); - - // Load the schema designer for the existing config set and make some changes to it - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, "true"); - reqParams.set(ENABLE_FIELD_GUESSING_PARAM, "false"); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.analyze(req, rsp); - - // Update id field to not use docValues - List> fields = - (List>) rsp.getValues().get("fields"); - SimpleOrderedMap idFieldMap = - fields.stream().filter(field -> field.get("name").equals("id")).findFirst().get(); - idFieldMap.remove("copyDest"); // Don't include copyDest as it is not a property of SchemaField - SimpleOrderedMap idFieldMapUpdated = idFieldMap.clone(); - idFieldMapUpdated.setVal(idFieldMapUpdated.indexOf("docValues", 0), Boolean.FALSE); - idFieldMapUpdated.setVal(idFieldMapUpdated.indexOf("useDocValuesAsStored", 0), Boolean.FALSE); - idFieldMapUpdated.setVal(idFieldMapUpdated.indexOf("uninvertible", 0), Boolean.TRUE); - idFieldMapUpdated.setVal( - idFieldMapUpdated.indexOf("omitTermFreqAndPositions", 0), Boolean.FALSE); - - SolrParams solrParams = idFieldMapUpdated.toSolrParams(); - Map mapParams = solrParams.toMap(new HashMap<>()); - mapParams.put("termVectors", Boolean.FALSE); - reqParams.set( - SCHEMA_VERSION_PARAM, rsp.getValues().toSolrParams().getInt(SCHEMA_VERSION_PARAM)); - - ContentStreamBase.StringStream stringStream = - new ContentStreamBase.StringStream(JSONUtil.toJSON(mapParams), JSON_MIME); - when(req.getContentStreams()).thenReturn(List.of(stringStream)); - - rsp = new SolrQueryResponse(); - schemaDesignerAPI.updateSchemaObject(req, rsp); - - // Add a new field - Integer schemaVersion = rsp.getValues().toSolrParams().getInt(SCHEMA_VERSION_PARAM); - reqParams.set(SCHEMA_VERSION_PARAM, schemaVersion); - ContentStreamBase.FileStream fileStream = - new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); - fileStream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(List.of(fileStream)); - rsp = new SolrQueryResponse(); - // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("add-field")); - - // Add a new field type - schemaVersion = rsp.getValues().toSolrParams().getInt(SCHEMA_VERSION_PARAM); - reqParams.set(SCHEMA_VERSION_PARAM, schemaVersion); - fileStream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-type.json")); - fileStream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(List.of(fileStream)); - rsp = new SolrQueryResponse(); - // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("add-field-type")); - - // Let's do a diff now - rsp = new SolrQueryResponse(); - schemaDesignerAPI.getSchemaDiff(req, rsp); - - Map diff = (Map) rsp.getValues().get("diff"); - - // field asserts - assertNotNull(diff.get("fields")); - Map fieldsDiff = (Map) diff.get("fields"); - assertNotNull(fieldsDiff.get("updated")); - Map mapDiff = (Map) fieldsDiff.get("updated"); - assertEquals( - Arrays.asList( - Map.of( - "omitTermFreqAndPositions", - true, - "useDocValuesAsStored", - true, - "docValues", - true, - "uninvertible", - false), - Map.of( - "omitTermFreqAndPositions", - false, - "useDocValuesAsStored", - false, - "docValues", - false, - "uninvertible", - true)), - mapDiff.get("id")); - assertNotNull(fieldsDiff.get("added")); - Map fieldsAdded = (Map) fieldsDiff.get("added"); - assertNotNull(fieldsAdded.get("keywords")); - - // field type asserts - assertNotNull(diff.get("fieldTypes")); - Map fieldTypesDiff = (Map) diff.get("fieldTypes"); - assertNotNull(fieldTypesDiff.get("added")); - Map fieldTypesAdded = (Map) fieldTypesDiff.get("added"); - assertNotNull(fieldTypesAdded.get("test_txt")); - } - - protected void assertDesignerSettings(Map expected, NamedList actual) { - for (String expKey : expected.keySet()) { - Object expValue = expected.get(expKey); - assertEquals( - "Value for designer setting '" + expKey + "' not match expected!", - expValue, - actual.get(expKey)); - } - } -} diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java index 82231061bdfa..300a78c35812 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java @@ -19,7 +19,7 @@ import static org.apache.solr.common.util.Utils.toJavabin; import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getMutableId; +import static org.apache.solr.handler.designer.SchemaDesigner.getMutableId; import static org.apache.solr.schema.IndexSchema.NEST_PATH_FIELD_NAME; import static org.apache.solr.schema.IndexSchema.ROOT_FIELD_NAME; @@ -37,6 +37,7 @@ import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.filestore.FileStore; +import org.apache.solr.handler.configsets.DownloadConfigSet; import org.apache.solr.schema.FieldType; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; @@ -74,7 +75,7 @@ public void setupTest() { assertNotNull(cluster); cc = cluster.getJettySolrRunner(0).getCoreContainer(); assertNotNull(cc); - helper = new SchemaDesignerConfigSetHelper(cc, SchemaDesignerAPI.newSchemaSuggester()); + helper = new SchemaDesignerConfigSetHelper(cc, SchemaDesigner.newSchemaSuggester()); } @Test @@ -112,13 +113,14 @@ public void testSetupMutable() throws Exception { configSet, schema, List.of(), true, DEFAULT_CONFIGSET_NAME); assertEquals(2, schema.getSchemaZkVersion()); - byte[] zipped = helper.downloadAndZipConfigSet(mutableId); + byte[] zipped = DownloadConfigSet.zipConfigSet(cc.getConfigSetService(), mutableId); assertTrue(zipped != null && zipped.length > 0); } @Test public void testDownloadAndZip() throws IOException { - byte[] zipped = helper.downloadAndZipConfigSet(DEFAULT_CONFIGSET_NAME); + byte[] zipped = + DownloadConfigSet.zipConfigSet(cc.getConfigSetService(), DEFAULT_CONFIGSET_NAME); ZipInputStream stream = new ZipInputStream(new ByteArrayInputStream(zipped)); boolean foundSolrConfig = false; @@ -176,7 +178,7 @@ public void testEnableDisableOptions() throws Exception { assertTrue( cluster .getZkClient() - .exists(SchemaDesignerAPI.getConfigSetZkPath(mutableId, "lang/stopwords_en.txt"))); + .exists(SchemaDesigner.getConfigSetZkPath(mutableId, "lang/stopwords_en.txt"))); assertNotNull(schema.getFieldTypeByName("text_fr")); assertNotNull(schema.getFieldOrNull("*_txt_fr")); assertNull(schema.getFieldOrNull("*_txt_ga")); @@ -198,7 +200,7 @@ public void testEnableDisableOptions() throws Exception { assertTrue( cluster .getZkClient() - .exists(SchemaDesignerAPI.getConfigSetZkPath(mutableId, "lang/stopwords_en.txt"))); + .exists(SchemaDesigner.getConfigSetZkPath(mutableId, "lang/stopwords_en.txt"))); assertNotNull(schema.getFieldTypeByName("text_fr")); assertNotNull(schema.getFieldOrNull("*_txt_fr")); assertNull(schema.getFieldOrNull("*_txt_ga")); diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java new file mode 100644 index 000000000000..781b06c9a3aa --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.handler.designer; + +import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; +import org.apache.solr.client.api.model.SchemaDesignerResponse; +import org.apache.solr.client.solrj.request.SchemaDesignerApi; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.util.ExternalPaths; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Smoke tests that exercise the Schema Designer V2 API through the generated SolrJ client. Unlike + * {@link TestSchemaDesigner}, which calls the handler in-process and bypasses both the JAX-RS layer + * and JSON (de)serialization, these tests round-trip every typed request body and response over + * HTTP. They guard against regressions in the {@code @JsonProperty} / {@code @Schema} / + * {@code @JsonAnySetter} annotations and the OpenAPI-generated SolrJ wiring. This is required as + * the primary interaction mechanism to Schema Designer APIs is via the schema-designer.js JSON + * calls, not SolrJ. + */ +public class TestSchemaDesignerSolrJ extends SolrCloudTestCase { + + @BeforeClass + public static void createCluster() throws Exception { + System.setProperty("managed.schema.mutable", "true"); + configureCluster(1) + .addConfig(DEFAULT_CONFIGSET_NAME, ExternalPaths.DEFAULT_CONFIGSET) + .configure(); + } + + @AfterClass + public static void cleanup() throws Exception { + if (cluster != null && cluster.getSolrClient() != null) { + cluster.deleteAllCollections(); + cluster.deleteAllConfigSets(); + } + } + + /** + * Walks through prep → add (each of the four wrapper keys) → update → info, verifying both the + * SolrJ request wiring and the typed-response deserialization. + */ + @Test + public void testTypedBodyRoundTrip() throws Exception { + final String configSet = "solrjSmoke"; + + // POST /schema-designer/{configSet}/prep — no body, baseline that SolrJ wiring works + SchemaDesignerResponse prep = + new SchemaDesignerApi.PrepNewSchema(configSet).process(cluster.getSolrClient()); + assertEquals(configSet, prep.configSet); + int schemaVersion = prep.schemaVersion; + + // POST /add — addField — exercises kebab-case @JsonProperty("add-field") / @Schema(name=…) + var addField = new SchemaDesignerApi.AddSchemaObject(configSet); + addField.setSchemaVersion(schemaVersion); + addField.setAddField(Map.of("name", "keywords", "type", "string", "stored", true)); + SchemaDesignerResponse addFieldResp = addField.process(cluster.getSolrClient()); + assertEquals("keywords", addFieldResp.field); + schemaVersion = addFieldResp.schemaVersion; + + // POST /add — addFieldType — covers a different wrapper key + var addType = new SchemaDesignerApi.AddSchemaObject(configSet); + addType.setSchemaVersion(schemaVersion); + addType.setAddFieldType( + Map.of( + "name", + "smoke_txt", + "class", + "solr.TextField", + "analyzer", + Map.of("tokenizer", Map.of("class", "solr.StandardTokenizerFactory")))); + SchemaDesignerResponse addTypeResp = addType.process(cluster.getSolrClient()); + assertEquals("smoke_txt", addTypeResp.fieldType); + schemaVersion = addTypeResp.schemaVersion; + + // POST /add — addDynamicField + var addDyn = new SchemaDesignerApi.AddSchemaObject(configSet); + addDyn.setSchemaVersion(schemaVersion); + addDyn.setAddDynamicField(Map.of("name", "*_smoke", "type", "string")); + SchemaDesignerResponse addDynResp = addDyn.process(cluster.getSolrClient()); + assertEquals("*_smoke", addDynResp.dynamicField); + schemaVersion = addDynResp.schemaVersion; + + // POST /add — addCopyField — verifies the explicit no-op response branch in + // setSchemaObjectField (no field/type/dynamicField/fieldType is populated) + var addCopy = new SchemaDesignerApi.AddSchemaObject(configSet); + addCopy.setSchemaVersion(schemaVersion); + addCopy.setAddCopyField(Map.of("source", "keywords", "dest", "_text_")); + SchemaDesignerResponse addCopyResp = addCopy.process(cluster.getSolrClient()); + assertNull(addCopyResp.field); + assertNull(addCopyResp.fieldType); + assertNull(addCopyResp.dynamicField); + schemaVersion = addCopyResp.schemaVersion; + + // PUT /update — exercises the @JsonAnyGetter/@JsonAnySetter capture for arbitrary attrs + var update = new SchemaDesignerApi.UpdateSchemaObject(configSet); + update.setSchemaVersion(schemaVersion); + update.setName("keywords"); + update.setAdditionalProperties(Map.of("type", "string", "stored", true, "multiValued", true)); + SchemaDesignerResponse updateResp = update.process(cluster.getSolrClient()); + assertNotNull(updateResp.field); + assertEquals("field", updateResp.updateType); + + // GET /info — round-trips a typed response that extends SchemaDesignerSettingsResponse + SchemaDesignerInfoResponse info = + new SchemaDesignerApi.GetInfo(configSet).process(cluster.getSolrClient()); + assertEquals(configSet, info.configSet); + } + + /** + * Exercises the {@code InputStream} body binding on {@code updateFileContents} by sending an + * invalid {@code solrconfig.xml} and verifying the server returns the typed error fields rather + * than throwing — this also confirms the bytes actually reached the server. + */ + @Test + public void testUpdateFileContentsBodyBinding() throws Exception { + final String configSet = "solrjFileSmoke"; + + new SchemaDesignerApi.PrepNewSchema(configSet).process(cluster.getSolrClient()); + + byte[] invalidXml = "".getBytes(StandardCharsets.UTF_8); + var req = + new SchemaDesignerApi.UpdateFileContents(configSet, new ByteArrayInputStream(invalidXml)); + req.setFile("solrconfig.xml"); + SchemaDesignerResponse resp = req.process(cluster.getSolrClient()); + + assertNotNull( + "server should report a validation error for the invalid solrconfig.xml", + resp.updateFileError); + assertEquals("", resp.fileContent); + } +} diff --git a/solr/cross-dc-manager/build.gradle b/solr/cross-dc-manager/build.gradle index 4ce538f67f7f..14ed70a5dbc3 100644 --- a/solr/cross-dc-manager/build.gradle +++ b/solr/cross-dc-manager/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation libs.opentelemetry.sdk.metrics implementation libs.eclipse.jetty.server implementation libs.eclipse.jetty.ee10.servlet + implementation libs.google.guava implementation libs.jakarta.servlet.api implementation libs.slf4j.api runtimeOnly libs.google.protobuf.javautils diff --git a/solr/cross-dc-manager/src/java/org/apache/solr/crossdc/manager/consumer/KafkaCrossDcConsumer.java b/solr/cross-dc-manager/src/java/org/apache/solr/crossdc/manager/consumer/KafkaCrossDcConsumer.java index c3b5bdb3a707..d368151c6afa 100644 --- a/solr/cross-dc-manager/src/java/org/apache/solr/crossdc/manager/consumer/KafkaCrossDcConsumer.java +++ b/solr/cross-dc-manager/src/java/org/apache/solr/crossdc/manager/consumer/KafkaCrossDcConsumer.java @@ -75,6 +75,8 @@ public class KafkaCrossDcConsumer extends Consumer.CrossDcConsumer { private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + public static final String PROP_TOPIC_DEBUG = "solr.crossdc.consumer.topic.debug"; + private final KafkaConsumer> kafkaConsumer; private final AdminClient adminClient; private final CountDownLatch startLatch; @@ -101,6 +103,8 @@ public class KafkaCrossDcConsumer extends Consumer.CrossDcConsumer { private volatile boolean running = false; + private boolean topicDebug = Boolean.parseBoolean(System.getProperty(PROP_TOPIC_DEBUG, "false")); + /** * Supplier for creating and managing a working CloudSolrClient instance. This class ensures that * the CloudSolrClient instance doesn't try to use its {@link @@ -175,6 +179,7 @@ public KafkaCrossDcConsumer( conf.get(CrossDcConf.COLLAPSE_UPDATES), CrossDcConf.CollapseUpdates.PARTIAL); this.maxCollapseRecords = conf.getInt(KafkaCrossDcConf.MAX_COLLAPSE_RECORDS); this.startLatch = startLatch; + final Properties kafkaConsumerProps = new Properties(); kafkaConsumerProps.put( @@ -375,6 +380,9 @@ boolean pollAndProcessRequests() { ConsumerRecord> lastRecord = null; for (TopicPartition partition : records.partitions()) { + if (log.isTraceEnabled()) { + log.trace("Checking partition {}", partition.partition()); + } List>> partitionRecords = records.records(partition); @@ -396,19 +404,31 @@ boolean pollAndProcessRequests() { metrics.incrementInputMsgCounter(); lastRecord = requestRecord; - MirroredSolrRequest req = requestRecord.value(); - SolrRequest solrReq = req.getSolrRequest(); - MirroredSolrRequest.Type type = req.getType(); + final MirroredSolrRequest req = requestRecord.value(); + final SolrRequest solrReq = req.getSolrRequest(); + final MirroredSolrRequest.Type type = req.getType(); if (type != MirroredSolrRequest.Type.UPDATE) { String action = solrReq.getParams().get("action", "unknown"); metrics.incrementInputReqCounter(type.name(), action); } - ModifiableSolrParams params = new ModifiableSolrParams(solrReq.getParams()); + final ModifiableSolrParams params = new ModifiableSolrParams(solrReq.getParams()); if (log.isTraceEnabled()) { log.trace("-- picked type={}, params={}", req.getType(), params); } + if (topicDebug) { + solrReq.addHeader("topic.debug", "true"); + solrReq.addHeader("record.topic", requestRecord.topic()); + solrReq.addHeader("record.partition", String.valueOf(requestRecord.partition())); + solrReq.addHeader("record.offset", String.valueOf(requestRecord.offset())); + solrReq.addHeader("record.timestamp", String.valueOf(requestRecord.timestamp())); + solrReq.addHeader("record.key", requestRecord.key()); + solrReq.addHeader("workUnit.nextOffset", String.valueOf(workUnit.nextOffset)); + solrReq.addHeader("workUnit.partition", String.valueOf(workUnit.partition)); + solrReq.addHeader("workUnit.topic", workUnit.topic); + solrReq.addHeader("workUnit.items", String.valueOf(workUnit.workItems.size())); + } // determine if it's an UPDATE with deletes, or if the existing batch has deletes boolean hasDeletes = false; @@ -450,6 +470,7 @@ boolean pollAndProcessRequests() { if (updateReqBatch == null) { // just initialize updateReqBatch = new UpdateRequest(); + updateReqBatch.addHeaders(solrReq.getHeaders()); } else { if (collapseUpdates == CrossDcConf.CollapseUpdates.NONE) { throw new RuntimeException("Can't collapse requests."); @@ -490,6 +511,7 @@ boolean pollAndProcessRequests() { if (updateReqBatch != null) { sendBatch(updateReqBatch, MirroredSolrRequest.Type.UPDATE, lastRecord, workUnit); + updateReqBatch = null; } try { partitionManager.checkForOffsetUpdates(partition); diff --git a/solr/cross-dc-manager/src/java/org/apache/solr/crossdc/manager/consumer/PartitionManager.java b/solr/cross-dc-manager/src/java/org/apache/solr/crossdc/manager/consumer/PartitionManager.java index c1004528ab69..c93740f25ea0 100644 --- a/solr/cross-dc-manager/src/java/org/apache/solr/crossdc/manager/consumer/PartitionManager.java +++ b/solr/cross-dc-manager/src/java/org/apache/solr/crossdc/manager/consumer/PartitionManager.java @@ -16,6 +16,7 @@ */ package org.apache.solr.crossdc.manager.consumer; +import com.google.common.annotations.VisibleForTesting; import java.lang.invoke.MethodHandles; import java.util.ArrayDeque; import java.util.HashSet; @@ -44,13 +45,16 @@ static class PartitionWork { final Queue partitionQueue = new ArrayDeque<>(); } - static class WorkUnit { - final TopicPartition partition; - Set> workItems = new HashSet<>(); + @VisibleForTesting + public static class WorkUnit { + final int partition; + final String topic; + final Set> workItems = new HashSet<>(); long nextOffset; - public WorkUnit(TopicPartition partition) { - this.partition = partition; + WorkUnit(TopicPartition partition) { + this.partition = partition.partition(); + this.topic = partition.topic(); } } diff --git a/solr/cross-dc-manager/src/test/org/apache/solr/crossdc/manager/SolrAndKafkaIntegrationTest.java b/solr/cross-dc-manager/src/test/org/apache/solr/crossdc/manager/SolrAndKafkaIntegrationTest.java index e837be5fec37..73b6bd8abd5a 100644 --- a/solr/cross-dc-manager/src/test/org/apache/solr/crossdc/manager/SolrAndKafkaIntegrationTest.java +++ b/solr/cross-dc-manager/src/test/org/apache/solr/crossdc/manager/SolrAndKafkaIntegrationTest.java @@ -16,8 +16,12 @@ */ package org.apache.solr.crossdc.manager; +import static org.apache.solr.crossdc.common.CrossDcConf.COLLAPSE_UPDATES; +import static org.apache.solr.crossdc.common.KafkaCrossDcConf.BATCH_SIZE_BYTES; +import static org.apache.solr.crossdc.common.KafkaCrossDcConf.BOOTSTRAP_SERVERS; import static org.apache.solr.crossdc.common.KafkaCrossDcConf.DEFAULT_MAX_REQUEST_SIZE; import static org.apache.solr.crossdc.common.KafkaCrossDcConf.INDEX_UNMIRRORABLE_DOCS; +import static org.apache.solr.crossdc.common.KafkaCrossDcConf.TOPIC_NAME; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakLingering; @@ -28,14 +32,18 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; +import java.util.Set; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.stream.IntStream; import org.apache.commons.io.IOUtils; +import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerRecord; @@ -45,6 +53,7 @@ import org.apache.solr.SolrIgnoredThreadsFilter; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.SolrResponse; import org.apache.solr.client.solrj.impl.CloudSolrClient; import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; import org.apache.solr.client.solrj.request.CollectionAdminRequest; @@ -56,14 +65,19 @@ import org.apache.solr.cloud.MiniSolrCloudCluster; import org.apache.solr.cloud.SolrCloudTestCase; import org.apache.solr.common.SolrInputDocument; +import org.apache.solr.common.params.CommonParams; import org.apache.solr.common.util.ExecutorUtil; import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.ObjectReleaseTracker; import org.apache.solr.common.util.SolrNamedThreadFactory; +import org.apache.solr.common.util.Utils; import org.apache.solr.crossdc.common.KafkaCrossDcConf; import org.apache.solr.crossdc.common.MirroredSolrRequest; import org.apache.solr.crossdc.common.MirroredSolrRequestSerializer; import org.apache.solr.crossdc.manager.consumer.Consumer; +import org.apache.solr.crossdc.manager.consumer.ConsumerMetrics; +import org.apache.solr.crossdc.manager.consumer.KafkaCrossDcConsumer; +import org.apache.solr.crossdc.manager.consumer.PartitionManager; import org.apache.solr.util.SolrKafkaTestsIgnoredThreadsFilter; import org.junit.After; import org.junit.Before; @@ -90,11 +104,64 @@ public class SolrAndKafkaIntegrationTest extends SolrCloudTestCase { private static final int NUM_BROKERS = 1; public EmbeddedKafkaCluster kafkaCluster; + private static class ConsumerBatch { + final String kafkaTopic; + final int partitionId; + final MirroredSolrRequest.Type type; + final String collection; + final Map headers; + final Set addIds = new HashSet<>(); + final String json; + + public ConsumerBatch(final MirroredSolrRequest.Type type, final SolrRequest solrRequest) { + this.kafkaTopic = solrRequest.getHeaders().get("record.topic"); + this.partitionId = Integer.parseInt(solrRequest.getHeaders().get("record.partition")); + this.type = type; + this.collection = solrRequest.getCollection(); + this.headers = solrRequest.getHeaders(); + if (solrRequest instanceof UpdateRequest) { + UpdateRequest updateReq = (UpdateRequest) solrRequest; + json = + Utils.toJSONString( + Map.of("params", updateReq.getParams(), "add", updateReq.getDocuments())); + updateReq.getDocuments().forEach(doc -> addIds.add(doc.getFieldValue("id").toString())); + } else { + json = + Utils.toJSONString( + Map.of("params", solrRequest.getParams(), "class", solrRequest.getClass())); + } + } + + @Override + public String toString() { + return "ConsumerBatch{" + + "kafkaTopic='" + + kafkaTopic + + '\'' + + ", partitionId=" + + partitionId + + ", type=" + + type + + ", collection='" + + collection + + '\'' + + ", headers=" + + headers + + '\'' + + ", json='" + + json + + '\'' + + '}'; + } + } + protected volatile MiniSolrCloudCluster solrCluster1; protected volatile MiniSolrCloudCluster solrCluster2; protected volatile Consumer consumer; + private List consumerBatches; + private static final String TOPIC = "topic1"; private static final String COLLECTION = "collection1"; @@ -112,7 +179,28 @@ public void beforeSolrAndKafkaIntegrationTest() throws Exception { Thread.setDefaultUncaughtExceptionHandler( (t, e) -> log.error("Uncaught exception in thread {}", t, e)); System.setProperty("otel.metrics.exporter", "prometheus"); - consumer = new Consumer(); + System.setProperty(KafkaCrossDcConsumer.PROP_TOPIC_DEBUG, "true"); + consumerBatches = new ArrayList<>(); + consumer = + new Consumer() { + @Override + protected CrossDcConsumer getCrossDcConsumer( + final KafkaCrossDcConf conf, + final ConsumerMetrics metrics, + final CountDownLatch startLatch) { + return new KafkaCrossDcConsumer(conf, metrics, startLatch) { + @Override + public void sendBatch( + final SolrRequest solrReqBatch, + final MirroredSolrRequest.Type type, + final ConsumerRecord> lastRecord, + final PartitionManager.WorkUnit workUnit) { + consumerBatches.add(new ConsumerBatch(type, solrReqBatch)); + super.sendBatch(solrReqBatch, type, lastRecord, workUnit); + } + }; + } + }; Properties config = new Properties(); kafkaCluster = @@ -124,13 +212,15 @@ public String bootstrapServers() { }; kafkaCluster.start(); - kafkaCluster.createTopic(TOPIC, 10, 1); + // create many partitions to test for re-ordered reads + kafkaCluster.createTopic(TOPIC, 3, 1); // ensure small batches to test multi-partition ordering - System.setProperty("batchSizeBytes", "128"); - System.setProperty("solr.crossdc.topicName", TOPIC); - System.setProperty("solr.crossdc.bootstrapServers", kafkaCluster.bootstrapServers()); + System.setProperty(BATCH_SIZE_BYTES, "100"); + System.setProperty(TOPIC_NAME, TOPIC); + System.setProperty(BOOTSTRAP_SERVERS, kafkaCluster.bootstrapServers()); System.setProperty(INDEX_UNMIRRORABLE_DOCS, "false"); + System.setProperty(COLLAPSE_UPDATES, "none"); solrCluster1 = configureCluster(1).addConfig("conf", getFile("configs/cloud-minimal/conf")).configure(); @@ -238,10 +328,62 @@ public void testProducerToCloud() throws Exception { "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; @Test - @Ignore("SOLR-18077") + public void testPartitioning() throws Exception { + CollectionAdminRequest.Create create = + CollectionAdminRequest.createCollection(ALT_COLLECTION, "conf", 1, 1); + create.process(solrCluster1.getSolrClient()); + create.process(solrCluster2.getSolrClient()); + solrCluster1.waitForActiveCollection(ALT_COLLECTION, 1, 1); + solrCluster2.waitForActiveCollection(ALT_COLLECTION, 1, 1); + + CloudSolrClient client = solrCluster1.getSolrClient(); + int NUM_DOCS = 200; + for (int i = 0; i < NUM_DOCS; i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "id-" + i); + doc.addField("id_i", i); + doc.addField("text", "some test with a relatively long field. " + LOREM_IPSUM); + doc.addField("collection_t", COLLECTION); + + client.add(COLLECTION, doc); + + doc = new SolrInputDocument(); + doc.addField("id", "id-" + i); + doc.addField("id_i", i); + doc.addField("text", "some test with a relatively long field. " + LOREM_IPSUM); + doc.addField("collection_t", ALT_COLLECTION); + + client.add(ALT_COLLECTION, doc); + } + client.commit(COLLECTION); + client.commit(ALT_COLLECTION); + // check that updates to different collections were always sent to the same partition + Map partitionsPerCol = new HashMap<>(); + Map> docsPerCol = new HashMap<>(); + for (ConsumerBatch batch : consumerBatches) { + String collection = + partitionsPerCol.computeIfAbsent(batch.partitionId, k -> batch.collection); + docsPerCol.computeIfAbsent(collection, col -> new HashSet<>()).addAll(batch.addIds); + assertEquals( + "request in partition " + + batch.partitionId + + " has wrong collection " + + batch.collection + + ": " + + batch + + "\npartitions: " + + partitionsPerCol, + collection, + batch.collection); + } + docsPerCol.forEach( + (col, ids) -> assertEquals("incorrect count in collection " + col, NUM_DOCS, ids.size())); + } + + @Test public void testStrictOrdering() throws Exception { CloudSolrClient client = solrCluster1.getSolrClient(); - int NUM_DOCS = 5000; + int NUM_DOCS = 1000; // delay deletes by this many docs int DELTA = 100; for (int i = 0; i < NUM_DOCS; i++) { @@ -454,11 +596,12 @@ private void assertClusterEventuallyHasDocs( boolean foundUpdates = false; for (int i = 0; i < 100; i++) { client.commit(collection); - results = client.query(collection, new SolrQuery(query)); + results = + client.query(collection, new SolrQuery(CommonParams.Q, query, CommonParams.FL, "*")); if (results.getResults().getNumFound() == expectedNumDocs) { foundUpdates = true; } else { - Thread.sleep(200); + Thread.sleep(300); } } diff --git a/solr/modules/cuvs/src/java/org/apache/solr/cuvs/GpuMetricsService.java b/solr/modules/cuvs/src/java/org/apache/solr/cuvs/GpuMetricsService.java index 9156a55841c4..02268a373688 100644 --- a/solr/modules/cuvs/src/java/org/apache/solr/cuvs/GpuMetricsService.java +++ b/solr/modules/cuvs/src/java/org/apache/solr/cuvs/GpuMetricsService.java @@ -77,6 +77,17 @@ public static synchronized GpuMetricsService getInstance() { public void initialize(CoreContainer coreContainer) { if (initialized.compareAndSet(false, true)) { + // Ensure CUDA runtime is loaded before any cuVS provider access + try { + System.loadLibrary("cudart"); + } catch (UnsatisfiedLinkError e) { + String msg = e.getMessage(); + if (msg != null && msg.contains("already loaded in another classloader")) { + // cudart is available, just loaded elsewhere - safe to continue + } else { + log.warn("Could not load CUDA runtime library (libcudart not available) ", e); + } + } this.metricManager = coreContainer.getMetricManager(); startBackgroundService(); log.info("GPU metrics service initialized"); diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/coreadmin-api.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/coreadmin-api.adoc index 6cd1d566ac28..349ff1e47c41 100644 --- a/solr/solr-ref-guide/modules/configuration-guide/pages/coreadmin-api.adoc +++ b/solr/solr-ref-guide/modules/configuration-guide/pages/coreadmin-api.adoc @@ -780,10 +780,10 @@ This command is used as part of SolrCloud's xref:deployment-guide:shard-manageme When used against a core in a user-managed cluster without `split.key` parameter, this action will split the source index and distribute its documents alternately so that each split piece contains an equal number of documents. If the `split.key` parameter is specified then only documents having the same route key will be split from the source index. -[[coreadmin-upgradecoreindex]] -== UPGRADECOREINDEX +[[coreadmin-upgradeindex]] +== UPGRADEINDEX -The `UPGRADECOREINDEX` action upgrades an existing core's index in-place after a Solr major-version upgrade by reindexing documents from older-format segments. +The `UPGRADEINDEX` action upgrades an existing core's index in-place after a Solr major-version upgrade by reindexing documents from older-format segments. If a core is upgraded by this action, it ensures index compatibility with the next Solr major version (upon a future Solr upgrade) without having to re-create the index from source. This action is expensive and can take a while to complete on large indexes. Consider running with `async` option in such cases. @@ -797,7 +797,7 @@ Fields that are neither stored nor docValues-backed will lose their data, unless It is recommended to test on a copy and have a backup before running on production data. -=== UPGRADECOREINDEX Parameters +=== UPGRADEINDEX Parameters `core`:: + @@ -828,7 +828,7 @@ Use <> with the provided `requestid` to p The update processor chain to use for reindexing. If omitted, Solr uses the chain configured for the `/update` handler, or the default update chain for the core (in that order). -=== UPGRADECOREINDEX Response +=== UPGRADEINDEX Response On success, the response includes: @@ -845,20 +845,20 @@ The number of segments successfully processed. One of `UPGRADE_SUCCESSFUL` or `NO_UPGRADE_NEEDED`. On failure, an exception is thrown with error details. -=== UPGRADECOREINDEX Examples +=== UPGRADEINDEX Examples *Synchronous:* [source,bash] ---- -http://localhost:8983/solr/admin/cores?action=UPGRADECOREINDEX&core=techproducts +http://localhost:8983/solr/admin/cores?action=UPGRADEINDEX&core=techproducts ---- *Asynchronous (recommended for large cores):* [source,bash] ---- -http://localhost:8983/solr/admin/cores?action=UPGRADECOREINDEX&core=techproducts&async=upgrade_1 +http://localhost:8983/solr/admin/cores?action=UPGRADEINDEX&core=techproducts&async=upgrade_1 ---- Then poll status: diff --git a/solr/solr-ref-guide/modules/query-guide/pages/stream-decorator-reference.adoc b/solr/solr-ref-guide/modules/query-guide/pages/stream-decorator-reference.adoc index 03740f8f1d9f..7f14117ef1dd 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/stream-decorator-reference.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/stream-decorator-reference.adoc @@ -1448,7 +1448,7 @@ For faster aggregation over low to moderate cardinality fields, the `facet` func * `StreamExpression` (Mandatory) * `over`: (Mandatory) A list of fields to group by. * `metrics`: (Mandatory) The list of metrics to compute. -Currently supported metrics are `sum(col)`, `avg(col)`, `min(col)`, `max(col)`, `count(*)`. +Currently supported metrics are `sum(col)`, `avg(col)`, `min(col)`, `max(col)`, `count(*)`, `missing(col)`. === rollup Syntax @@ -1465,7 +1465,8 @@ rollup( max(a_f), avg(a_i), avg(a_f), - count(*) + count(*), + missing(a_i) ) ---- diff --git a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/Lang.java b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/Lang.java index 927fb1eef5d8..2c0e3a4de7a9 100644 --- a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/Lang.java +++ b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/Lang.java @@ -325,6 +325,7 @@ import org.apache.solr.client.solrj.io.stream.metrics.MaxMetric; import org.apache.solr.client.solrj.io.stream.metrics.MeanMetric; import org.apache.solr.client.solrj.io.stream.metrics.MinMetric; +import org.apache.solr.client.solrj.io.stream.metrics.MissingMetric; import org.apache.solr.client.solrj.io.stream.metrics.PercentileMetric; import org.apache.solr.client.solrj.io.stream.metrics.StdMetric; import org.apache.solr.client.solrj.io.stream.metrics.SumMetric; @@ -406,6 +407,7 @@ public static void register(StreamFactory streamFactory) { .withFunctionName("std", StdMetric.class) .withFunctionName("count", CountMetric.class) .withFunctionName("countDist", CountDistinctMetric.class) + .withFunctionName("missing", MissingMetric.class) // tuple manipulation operations .withFunctionName("replace", ReplaceOperation.class) diff --git a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/ParallelMetricsRollup.java b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/ParallelMetricsRollup.java index 752581b2f8fc..584932c1e016 100644 --- a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/ParallelMetricsRollup.java +++ b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/ParallelMetricsRollup.java @@ -26,6 +26,7 @@ import org.apache.solr.client.solrj.io.stream.metrics.MeanMetric; import org.apache.solr.client.solrj.io.stream.metrics.Metric; import org.apache.solr.client.solrj.io.stream.metrics.MinMetric; +import org.apache.solr.client.solrj.io.stream.metrics.MissingMetric; import org.apache.solr.client.solrj.io.stream.metrics.SumMetric; import org.apache.solr.client.solrj.io.stream.metrics.WeightedSumMetric; @@ -133,6 +134,10 @@ default Optional getRollupMetrics(Metric[] metrics) { // can't properly rollup mean metrics w/o a count (reqd by WeightedSumMetric) return Optional.empty(); } + } else if (next instanceof MissingMetric) { + // sum of missing counts + nextRollup = new SumMetric(next.getIdentifier()); + nextRollup.outputLong = next.outputLong; } else if (next instanceof CountDistinctMetric) { // rollup of count distinct is the max across the tiers nextRollup = new MaxMetric(next.getIdentifier()); diff --git a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/metrics/MissingMetric.java b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/metrics/MissingMetric.java new file mode 100644 index 000000000000..8f60dfc5f3b6 --- /dev/null +++ b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/stream/metrics/MissingMetric.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.solrj.io.stream.metrics; + +import java.io.IOException; +import java.util.Locale; +import org.apache.solr.client.solrj.io.Tuple; +import org.apache.solr.client.solrj.io.stream.expr.StreamExpression; +import org.apache.solr.client.solrj.io.stream.expr.StreamExpressionParameter; +import org.apache.solr.client.solrj.io.stream.expr.StreamFactory; + +public class MissingMetric extends Metric { + private String columnName; + private long count; + + public MissingMetric(String columnName) { + init("missing", columnName); + } + + public MissingMetric(StreamExpression expression, StreamFactory factory) throws IOException { + String functionName = expression.getFunctionName(); + String columnName = factory.getValueOperand(expression, 0); + + if (null == columnName) { + throw new IOException( + String.format( + Locale.ROOT, + "Invalid expression %s - expected %s(columnName)", + expression, + functionName)); + } + if (1 != expression.getParameters().size()) { + throw new IOException( + String.format(Locale.ROOT, "Invalid expression %s - unknown operands found", expression)); + } + + init(functionName, columnName); + } + + private void init(String functionName, String columnName) { + this.columnName = columnName; + this.outputLong = true; + setFunctionName(functionName); + setIdentifier(functionName, "(", columnName, ")"); + } + + @Override + public String[] getColumns() { + return new String[] {columnName}; + } + + @Override + public void update(Tuple tuple) { + if (tuple.get(columnName) == null) { + ++count; + } + } + + @Override + public Long getValue() { + return count; + } + + @Override + public Metric newInstance() { + return new MissingMetric(columnName); + } + + @Override + public StreamExpressionParameter toExpression(StreamFactory factory) throws IOException { + return new StreamExpression(getFunctionName()).withParameter(columnName); + } +} diff --git a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/TestLang.java b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/TestLang.java index 3a6f58580efa..9afb1fabc3fd 100644 --- a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/TestLang.java +++ b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/TestLang.java @@ -351,7 +351,8 @@ public class TestLang extends SolrTestCase { "std", "drill", "input", - "countDist" + "countDist", + "missing" }; @Test diff --git a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/StreamingTest.java b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/StreamingTest.java index a0b72d8d2a10..601a4fe6f676 100644 --- a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/StreamingTest.java +++ b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/stream/StreamingTest.java @@ -46,6 +46,7 @@ import org.apache.solr.client.solrj.io.stream.metrics.MeanMetric; import org.apache.solr.client.solrj.io.stream.metrics.Metric; import org.apache.solr.client.solrj.io.stream.metrics.MinMetric; +import org.apache.solr.client.solrj.io.stream.metrics.MissingMetric; import org.apache.solr.client.solrj.io.stream.metrics.SumMetric; import org.apache.solr.client.solrj.request.CollectionAdminRequest; import org.apache.solr.client.solrj.request.UpdateRequest; @@ -145,16 +146,16 @@ public static void configureCluster() throws Exception { // Update request shared by many of the tests private final UpdateRequest helloDocsUpdateRequest = new UpdateRequest() - .add(id, "0", "a_s", "hello0", "a_i", "0", "a_f", "1") + .add(id, "0", "a_s", "hello0", "a_i", "0", "a_f", "1", "b_f", "1.5") .add(id, "2", "a_s", "hello0", "a_i", "2", "a_f", "2") .add(id, "3", "a_s", "hello3", "a_i", "3", "a_f", "3") - .add(id, "4", "a_s", "hello4", "a_i", "4", "a_f", "4") + .add(id, "4", "a_s", "hello4", "a_i", "4", "a_f", "4", "b_f", "4.5") .add(id, "1", "a_s", "hello0", "a_i", "1", "a_f", "5") - .add(id, "5", "a_s", "hello3", "a_i", "10", "a_f", "6") - .add(id, "6", "a_s", "hello4", "a_i", "11", "a_f", "7") + .add(id, "5", "a_s", "hello3", "a_i", "10", "a_f", "6", "b_f", "6.5") + .add(id, "6", "a_s", "hello4", "a_i", "11", "a_f", "7", "b_f", "7.5") .add(id, "7", "a_s", "hello3", "a_i", "12", "a_f", "8") .add(id, "8", "a_s", "hello3", "a_i", "13", "a_f", "9") - .add(id, "9", "a_s", "hello0", "a_i", "14", "a_f", "10"); + .add(id, "9", "a_s", "hello0", "a_i", "14", "a_f", "10", "b_f", "10.5"); @Before public void clearCollection() throws Exception { @@ -1646,7 +1647,7 @@ public void testRollupStream() throws Exception { streamContext.setSolrClientCache(solrClientCache); try { - SolrParams sParamsA = params("q", "*:*", "fl", "a_s,a_i,a_f", "sort", "a_s asc"); + SolrParams sParamsA = params("q", "*:*", "fl", "a_s,a_i,a_f,b_f", "sort", "a_s asc"); CloudSolrStream stream = new CloudSolrStream(zkHost, COLLECTIONORALIAS, sParamsA); Bucket[] buckets = {new Bucket("a_s")}; @@ -1660,7 +1661,8 @@ public void testRollupStream() throws Exception { new MaxMetric("a_f"), new MeanMetric("a_i"), new MeanMetric("a_f"), - new CountMetric() + new CountMetric(), + new MissingMetric("b_f") }; RollupStream rollupStream = new RollupStream(stream, buckets, metrics); @@ -1682,6 +1684,7 @@ public void testRollupStream() throws Exception { Double avgi = tuple.getDouble("avg(a_i)"); Double avgf = tuple.getDouble("avg(a_f)"); Double count = tuple.getDouble("count(*)"); + Double missingBf = tuple.getDouble("missing(b_f)"); assertEquals("hello0", bucket); assertEquals(17, sumi, 0.001); @@ -1693,6 +1696,7 @@ public void testRollupStream() throws Exception { assertEquals(4.25, avgi, 0.001); assertEquals(4.5, avgf, 0.001); assertEquals(4, count, 0.001); + assertEquals(2, missingBf, 0.001); tuple = tuples.get(1); bucket = tuple.getString("a_s"); @@ -1705,6 +1709,7 @@ public void testRollupStream() throws Exception { avgi = tuple.getDouble("avg(a_i)"); avgf = tuple.getDouble("avg(a_f)"); count = tuple.getDouble("count(*)"); + missingBf = tuple.getDouble("missing(b_f)"); assertEquals("hello3", bucket); assertEquals(38, sumi, 0.001); @@ -1716,6 +1721,7 @@ public void testRollupStream() throws Exception { assertEquals(9.5, avgi, 0.001); assertEquals(6.5, avgf, 0.001); assertEquals(4, count, 0.001); + assertEquals(3, missingBf, 0.001); tuple = tuples.get(2); bucket = tuple.getString("a_s"); @@ -1728,6 +1734,7 @@ public void testRollupStream() throws Exception { avgi = tuple.getDouble("avg(a_i)"); avgf = tuple.getDouble("avg(a_f)"); count = tuple.getDouble("count(*)"); + missingBf = tuple.getDouble("missing(b_f)"); assertEquals("hello4", bucket); assertEquals(15, sumi.longValue()); @@ -1739,6 +1746,7 @@ public void testRollupStream() throws Exception { assertEquals(7.5, avgi, 0.01); assertEquals(5.5, avgf, 0.01); assertEquals(2, count, 0.01); + assertEquals(0, missingBf, 0.01); // Test will null metrics rollupStream = new RollupStream(stream, buckets, metrics); @@ -1763,7 +1771,7 @@ public void testRollupStream() throws Exception { .add(id, "12", "a_s", null, "a_i", "14", "a_f", "10") .commit(cluster.getSolrClient(), COLLECTIONORALIAS); - sParamsA = params("q", "*:*", "fl", "a_s,a_i,a_f", "sort", "a_s asc", "qt", "/export"); + sParamsA = params("q", "*:*", "fl", "a_s,a_i,a_f,b_f", "sort", "a_s asc", "qt", "/export"); stream = new CloudSolrStream(zkHost, COLLECTIONORALIAS, sParamsA); Bucket[] buckets1 = {new Bucket("a_s")}; @@ -1776,7 +1784,8 @@ public void testRollupStream() throws Exception { new MaxMetric("a_f"), new MeanMetric("a_i"), new MeanMetric("a_f"), - new CountMetric() + new CountMetric(), + new MissingMetric("b_f") }; rollupStream = new RollupStream(stream, buckets1, metrics1); @@ -1796,6 +1805,7 @@ public void testRollupStream() throws Exception { avgi = tuple.getDouble("avg(a_i)"); avgf = tuple.getDouble("avg(a_f)"); count = tuple.getDouble("count(*)"); + missingBf = tuple.getDouble("missing(b_f)"); assertEquals(14, sumi, 0.01); assertEquals(10, sumf, 0.01); @@ -1806,6 +1816,7 @@ public void testRollupStream() throws Exception { assertEquals(14, avgi, 0.01); assertEquals(10, avgf, 0.01); assertEquals(1, count, 0.01); + assertEquals(1, missingBf, 0.01); } finally { solrClientCache.close(); } @@ -1956,12 +1967,51 @@ public void testRollupWithNoParallel() throws Exception { "expr", "rollup(search(" + COLLECTIONORALIAS - + ",q=\"*:*\",fl=\"a_s,a_i,a_f\",sort=\"a_s desc\",partitionKeys=\"a_s\", qt=\"/export\"),over=\"a_s\")\n"); + + ",q=\"*:*\",fl=\"a_s,a_i,a_f,b_f\",sort=\"a_s asc\",partitionKeys=\"a_s\", qt=\"/export\"),over=\"a_s\",sum(a_i),sum(a_f),min(a_i),min(a_f),max(a_i),max(a_f),avg(a_i),avg(a_f),count(*),missing(b_f))\n"); SolrStream solrStream = new SolrStream(shardUrls.get(0), solrParams); streamContext = new StreamContext(); solrStream.setStreamContext(streamContext); tuples = getTuples(solrStream); assertEquals(3, tuples.size()); + + Tuple exprTuple = tuples.get(0); + assertEquals("hello0", exprTuple.getString("a_s")); + assertEquals(17, exprTuple.getDouble("sum(a_i)"), 0.001); + assertEquals(18, exprTuple.getDouble("sum(a_f)"), 0.001); + assertEquals(0, exprTuple.getDouble("min(a_i)"), 0.001); + assertEquals(1, exprTuple.getDouble("min(a_f)"), 0.001); + assertEquals(14, exprTuple.getDouble("max(a_i)"), 0.001); + assertEquals(10, exprTuple.getDouble("max(a_f)"), 0.001); + assertEquals(4.25, exprTuple.getDouble("avg(a_i)"), 0.001); + assertEquals(4.5, exprTuple.getDouble("avg(a_f)"), 0.001); + assertEquals(4, exprTuple.getDouble("count(*)"), 0.001); + assertEquals(2, exprTuple.getDouble("missing(b_f)"), 0.001); + + exprTuple = tuples.get(1); + assertEquals("hello3", exprTuple.getString("a_s")); + assertEquals(38, exprTuple.getDouble("sum(a_i)"), 0.001); + assertEquals(26, exprTuple.getDouble("sum(a_f)"), 0.001); + assertEquals(3, exprTuple.getDouble("min(a_i)"), 0.001); + assertEquals(3, exprTuple.getDouble("min(a_f)"), 0.001); + assertEquals(13, exprTuple.getDouble("max(a_i)"), 0.001); + assertEquals(9, exprTuple.getDouble("max(a_f)"), 0.001); + assertEquals(9.5, exprTuple.getDouble("avg(a_i)"), 0.001); + assertEquals(6.5, exprTuple.getDouble("avg(a_f)"), 0.001); + assertEquals(4, exprTuple.getDouble("count(*)"), 0.001); + assertEquals(3, exprTuple.getDouble("missing(b_f)"), 0.001); + + exprTuple = tuples.get(2); + assertEquals("hello4", exprTuple.getString("a_s")); + assertEquals(15, exprTuple.getDouble("sum(a_i)"), 0.001); + assertEquals(11, exprTuple.getDouble("sum(a_f)"), 0.001); + assertEquals(4, exprTuple.getDouble("min(a_i)"), 0.001); + assertEquals(4, exprTuple.getDouble("min(a_f)"), 0.001); + assertEquals(11, exprTuple.getDouble("max(a_i)"), 0.001); + assertEquals(7, exprTuple.getDouble("max(a_f)"), 0.001); + assertEquals(7.5, exprTuple.getDouble("avg(a_i)"), 0.001); + assertEquals(5.5, exprTuple.getDouble("avg(a_f)"), 0.001); + assertEquals(2, exprTuple.getDouble("count(*)"), 0.001); + assertEquals(0, exprTuple.getDouble("missing(b_f)"), 0.001); } finally { solrClientCache.close(); } @@ -1982,7 +2032,7 @@ public void testParallelRollupStream() throws Exception { "q", "*:*", "fl", - "a_s,a_i,a_f", + "a_s,a_i,a_f,b_f", "sort", "a_s asc", "partitionKeys", @@ -2002,7 +2052,8 @@ public void testParallelRollupStream() throws Exception { new MaxMetric("a_f"), new MeanMetric("a_i"), new MeanMetric("a_f"), - new CountMetric() + new CountMetric(), + new MissingMetric("b_f") }; RollupStream rollupStream = new RollupStream(stream, buckets, metrics); @@ -2027,6 +2078,7 @@ public void testParallelRollupStream() throws Exception { Double avgi = tuple.getDouble("avg(a_i)"); Double avgf = tuple.getDouble("avg(a_f)"); Double count = tuple.getDouble("count(*)"); + Double missingBf = tuple.getDouble("missing(b_f)"); assertEquals("hello0", bucket); assertEquals(17, sumi, 0.001); @@ -2038,6 +2090,7 @@ public void testParallelRollupStream() throws Exception { assertEquals(4.25, avgi, 0.001); assertEquals(4.5, avgf, 0.001); assertEquals(4, count, 0.001); + assertEquals(2, missingBf, 0.001); tuple = tuples.get(1); bucket = tuple.getString("a_s"); @@ -2050,6 +2103,7 @@ public void testParallelRollupStream() throws Exception { avgi = tuple.getDouble("avg(a_i)"); avgf = tuple.getDouble("avg(a_f)"); count = tuple.getDouble("count(*)"); + missingBf = tuple.getDouble("missing(b_f)"); assertEquals("hello3", bucket); assertEquals(38, sumi, 0.001); @@ -2061,6 +2115,7 @@ public void testParallelRollupStream() throws Exception { assertEquals(9.5, avgi, 0.001); assertEquals(6.5, avgf, 0.001); assertEquals(4, count, 0.001); + assertEquals(3, missingBf, 0.001); tuple = tuples.get(2); bucket = tuple.getString("a_s"); @@ -2073,6 +2128,7 @@ public void testParallelRollupStream() throws Exception { avgi = tuple.getDouble("avg(a_i)"); avgf = tuple.getDouble("avg(a_f)"); count = tuple.getDouble("count(*)"); + missingBf = tuple.getDouble("missing(b_f)"); assertEquals("hello4", bucket); assertEquals(15, sumi.longValue()); @@ -2084,6 +2140,7 @@ public void testParallelRollupStream() throws Exception { assertEquals(7.5, avgi, 0.001); assertEquals(5.5, avgf, 0.001); assertEquals(2, count, 0.001); + assertEquals(0, missingBf, 0.001); } finally { solrClientCache.close(); } diff --git a/solr/solrj/src/java/org/apache/solr/common/params/CoreAdminParams.java b/solr/solrj/src/java/org/apache/solr/common/params/CoreAdminParams.java index ad41867cc31b..0401de380f1b 100644 --- a/solr/solrj/src/java/org/apache/solr/common/params/CoreAdminParams.java +++ b/solr/solrj/src/java/org/apache/solr/common/params/CoreAdminParams.java @@ -179,7 +179,7 @@ public enum CoreAdminAction { CREATESNAPSHOT, DELETESNAPSHOT, LISTSNAPSHOTS, - UPGRADECOREINDEX; + UPGRADEINDEX; public final boolean isRead; diff --git a/solr/test-framework/src/test-files/solr/collection1/conf/solrconfig.xml b/solr/test-framework/src/test-files/solr/collection1/conf/solrconfig.xml index a91253e1b0b9..4884c5905e54 100644 --- a/solr/test-framework/src/test-files/solr/collection1/conf/solrconfig.xml +++ b/solr/test-framework/src/test-files/solr/collection1/conf/solrconfig.xml @@ -39,10 +39,6 @@ solr.StandardDirectoryFactory, the default, is filesystem based. solr.RAMDirectoryFactory is memory based and not persistent. --> - 1000000 - 2000000 - 3000000 - 4000000 diff --git a/solr/ui/build.gradle.kts b/solr/ui/build.gradle.kts index 080a7643a0f8..ca6a48898660 100644 --- a/solr/ui/build.gradle.kts +++ b/solr/ui/build.gradle.kts @@ -67,49 +67,51 @@ kotlin { sourceSets { // Shared multiplatform dependencies - val commonMain by getting { - dependencies { - implementation(project.dependencies.platform(project(":platform"))) - implementation(libs.compose.runtime) - implementation(libs.compose.foundation) - implementation(libs.compose.material3) - implementation(libs.compose.ui) - implementation(libs.compose.components.resources) - implementation(libs.compose.uiToolingPreview) - - implementation(libs.kotlinx.serialization.core) - implementation(libs.kotlinx.serialization.json) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.datetime) - - implementation(libs.decompose.decompose) - implementation(libs.essenty.lifecycle) - implementation(libs.decompose.extensions.compose) - implementation(libs.mvikotlin.extensions.coroutines) - implementation(libs.mvikotlin.mvikotlin) - implementation(libs.mvikotlin.main) - implementation(libs.mvikotlin.logging) - - implementation(project.dependencies.platform(libs.ktor.bom)) - implementation(libs.ktor.client.auth) - implementation(libs.ktor.client.core) - implementation(libs.ktor.client.cio) - implementation(libs.ktor.client.contentNegotiation) - implementation(libs.ktor.client.serialization.json) - implementation(libs.squareup.okio) - - implementation(libs.oshai.logging) - implementation(libs.slf4j.api) - } + commonMain.dependencies { + implementation(project.dependencies.platform(project(":platform"))) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui) + implementation(libs.compose.components.resources) + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.lifecycle.viewModelNav3) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.material3.adaptive.asProvider()) + implementation(libs.androidx.material3.adaptive.nav3) + + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + + implementation(libs.decompose.decompose) + implementation(libs.essenty.lifecycle) + implementation(libs.decompose.extensions.compose) + implementation(libs.mvikotlin.extensions.coroutines) + implementation(libs.mvikotlin.mvikotlin) + implementation(libs.mvikotlin.main) + implementation(libs.mvikotlin.logging) + + implementation(project.dependencies.platform(libs.ktor.bom)) + implementation(libs.ktor.client.auth) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.contentNegotiation) + implementation(libs.ktor.client.serialization.json) + implementation(libs.squareup.okio) + + implementation(libs.oshai.logging) + implementation(libs.slf4j.api) } - val commonTest by getting { - dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.compose.uiTest) - implementation(libs.ktor.client.mock) - } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.compose.uiTest) + implementation(libs.ktor.client.mock) } val desktopMain by getting { @@ -122,6 +124,10 @@ kotlin { } } } + + compilerOptions { + freeCompilerArgs.add("-Xexplicit-backing-fields") + } } configurations { diff --git a/solr/ui/gradle.lockfile b/solr/ui/gradle.lockfile index f9381b46e275..521d5efe58d0 100644 --- a/solr/ui/gradle.lockfile +++ b/solr/ui/gradle.lockfile @@ -4,10 +4,10 @@ androidx.annotation:annotation-jvm:1.9.1=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath androidx.annotation:annotation-wasm-js:1.9.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath androidx.annotation:annotation:1.9.1=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata -androidx.arch.core:core-common:2.2.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata -androidx.collection:collection-jvm:1.5.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath -androidx.collection:collection-wasm-js:1.5.0=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath -androidx.collection:collection:1.5.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.arch.core:core-common:2.2.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +androidx.collection:collection-wasm-js:1.5.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.collection:collection:1.5.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata androidx.compose.runtime:runtime-annotation-jvm:1.10.5=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath androidx.compose.runtime:runtime-annotation-jvm:1.9.0=composeHotReloadDevTools androidx.compose.runtime:runtime-annotation-wasm-js:1.10.5=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath @@ -27,40 +27,46 @@ androidx.compose.runtime:runtime:1.9.0=composeHotReloadDevTools androidx.graphics:graphics-shapes-desktop:1.1.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath androidx.graphics:graphics-shapes-wasm-js:1.1.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath androidx.graphics:graphics-shapes:1.1.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.lifecycle:lifecycle-common-jvm:2.10.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath androidx.lifecycle:lifecycle-common-jvm:2.9.2=composeHotReloadDevTools -androidx.lifecycle:lifecycle-common-jvm:2.9.4=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath -androidx.lifecycle:lifecycle-common-wasm-js:2.9.4=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.lifecycle:lifecycle-common-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata androidx.lifecycle:lifecycle-common:2.9.2=composeHotReloadDevTools -androidx.lifecycle:lifecycle-common:2.9.4=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata -androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.4=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath -androidx.lifecycle:lifecycle-runtime-compose-wasm-js:2.9.4=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath -androidx.lifecycle:lifecycle-runtime-compose:2.9.4=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.lifecycle:lifecycle-runtime-compose-desktop:2.10.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.lifecycle:lifecycle-runtime-desktop:2.10.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime-desktop:2.9.2=composeHotReloadDevTools -androidx.lifecycle:lifecycle-runtime-desktop:2.9.4=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath -androidx.lifecycle:lifecycle-runtime-wasm-js:2.9.4=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata androidx.lifecycle:lifecycle-runtime:2.9.2=composeHotReloadDevTools -androidx.lifecycle:lifecycle-runtime:2.9.4=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.lifecycle:lifecycle-viewmodel-desktop:2.10.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-desktop:2.9.2=composeHotReloadDevTools -androidx.lifecycle:lifecycle-viewmodel-desktop:2.9.4=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop:2.10.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop:2.9.2=composeHotReloadDevTools -androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop:2.9.4=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath -androidx.lifecycle:lifecycle-viewmodel-savedstate-wasm-js:2.9.4=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.2=composeHotReloadDevTools -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata -androidx.lifecycle:lifecycle-viewmodel-wasm-js:2.9.4=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata androidx.lifecycle:lifecycle-viewmodel:2.9.2=composeHotReloadDevTools -androidx.lifecycle:lifecycle-viewmodel:2.9.4=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata -androidx.navigationevent:navigationevent-desktop:1.0.2=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath -androidx.navigationevent:navigationevent-wasm-js:1.0.2=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath -androidx.navigationevent:navigationevent:1.0.2=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata -androidx.savedstate:savedstate-compose-desktop:1.3.3=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath -androidx.savedstate:savedstate-compose-wasm-js:1.3.3=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath -androidx.savedstate:savedstate-compose:1.3.3=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.navigation3:navigation3-runtime-desktop:1.1.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +androidx.navigation3:navigation3-runtime-wasm-js:1.1.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.navigation3:navigation3-runtime:1.1.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.navigationevent:navigationevent-desktop:1.0.2=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +androidx.navigationevent:navigationevent-wasm-js:1.0.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.navigationevent:navigationevent:1.0.2=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.savedstate:savedstate-compose-desktop:1.4.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +androidx.savedstate:savedstate-compose-wasm-js:1.4.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.savedstate:savedstate-compose:1.4.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata androidx.savedstate:savedstate-desktop:1.3.1=composeHotReloadDevTools -androidx.savedstate:savedstate-desktop:1.3.3=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath -androidx.savedstate:savedstate-wasm-js:1.3.3=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.savedstate:savedstate-desktop:1.4.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +androidx.savedstate:savedstate-wasm-js:1.4.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath androidx.savedstate:savedstate:1.3.1=composeHotReloadDevTools -androidx.savedstate:savedstate:1.3.3=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.savedstate:savedstate:1.4.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +androidx.window:window-core-jvm:1.5.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +androidx.window:window-core-wasm-js:1.5.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +androidx.window:window-core:1.5.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata com.arkivanov.decompose:decompose-jvm:3.4.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath com.arkivanov.decompose:decompose-wasm-js:3.4.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath com.arkivanov.decompose:decompose:3.4.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata @@ -180,23 +186,35 @@ org.checkerframework:checker-compat-qual:2.5.5=composeHotReloadDevDesktopTestRun org.fusesource.jansi:jansi:2.4.2=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath org.hamcrest:hamcrest-core:1.3=composeHotReloadDevDesktopTestRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath org.javassist:javassist:3.30.2-GA=composeHotReloadAgent -org.jetbrains.androidx.lifecycle:lifecycle-common-wasm-js:2.9.6=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-common-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-common:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.4=composeHotReloadDevTools -org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop:2.10.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.4=composeHotReloadDevTools -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.6=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-wasm-js:2.9.6=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.4=composeHotReloadDevTools -org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata -org.jetbrains.androidx.lifecycle:lifecycle-runtime-wasm-js:2.9.6=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.4=composeHotReloadDevTools -org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate-wasm-js:2.9.6=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop:2.10.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3-desktop:2.10.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4=composeHotReloadDevTools -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-wasm-js:2.9.6=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-wasm-js:2.10.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.10.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.4=composeHotReloadDevTools -org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.androidx.navigation3:navigation3-ui-desktop:1.1.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.androidx.navigation3:navigation3-ui-wasm-js:1.1.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.navigation3:navigation3-ui:1.1.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.androidx.navigationevent:navigationevent-compose-desktop:1.0.1=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.androidx.navigationevent:navigationevent-compose-wasm-js:1.0.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.navigationevent:navigationevent-compose:1.0.1=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata org.jetbrains.androidx.savedstate:savedstate-compose-desktop:1.3.4=composeHotReloadDevTools org.jetbrains.androidx.savedstate:savedstate-compose-desktop:1.3.6=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath org.jetbrains.androidx.savedstate:savedstate-compose-wasm-js:1.3.6=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath @@ -205,6 +223,9 @@ org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6=allDevSourceSetsCompi org.jetbrains.androidx.savedstate:savedstate-wasm-js:1.3.6=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath org.jetbrains.androidx.savedstate:savedstate:1.3.4=composeHotReloadDevTools org.jetbrains.androidx.savedstate:savedstate:1.3.6=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.androidx.window:window-core-desktop:1.5.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.androidx.window:window-core-wasm-js:1.5.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.androidx.window:window-core:1.5.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata org.jetbrains.compose.animation:animation-core-desktop:1.10.3=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath org.jetbrains.compose.animation:animation-core-desktop:1.9.0=composeHotReloadDevTools org.jetbrains.compose.animation:animation-core-wasm-js:1.10.3=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath @@ -251,6 +272,18 @@ org.jetbrains.compose.hot-reload:hot-reload-orchestration:1.0.0=composeHotReload org.jetbrains.compose.hot-reload:hot-reload-runtime-api-jvm:1.0.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevTools,desktopDevCompileClasspath,desktopDevRuntimeClasspath org.jetbrains.compose.hot-reload:hot-reload-runtime-api:1.0.0=allDevSourceSetsCompileDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevTools,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath org.jetbrains.compose.hot-reload:hot-reload-runtime-jvm:1.0.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadRuntime +org.jetbrains.compose.material3.adaptive:adaptive-desktop:1.3.0-alpha07=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.compose.material3.adaptive:adaptive-layout-desktop:1.3.0-alpha07=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.compose.material3.adaptive:adaptive-layout-wasm-js:1.3.0-alpha07=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.compose.material3.adaptive:adaptive-layout:1.3.0-alpha07=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.compose.material3.adaptive:adaptive-navigation-desktop:1.3.0-alpha07=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.compose.material3.adaptive:adaptive-navigation-wasm-js:1.3.0-alpha07=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.compose.material3.adaptive:adaptive-navigation3-desktop:1.3.0-alpha07=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.compose.material3.adaptive:adaptive-navigation3-wasm-js:1.3.0-alpha07=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.compose.material3.adaptive:adaptive-navigation3:1.3.0-alpha07=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha07=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.compose.material3.adaptive:adaptive-wasm-js:1.3.0-alpha07=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.compose.material3.adaptive:adaptive:1.3.0-alpha07=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata org.jetbrains.compose.material3:material3-desktop:1.10.0-alpha05=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath org.jetbrains.compose.material3:material3-desktop:1.8.2=composeHotReloadDevTools org.jetbrains.compose.material3:material3-wasm-js:1.10.0-alpha05=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath diff --git a/solr/ui/src/commonMain/composeResources/values/strings.xml b/solr/ui/src/commonMain/composeResources/values/strings.xml index b3efd3119bb0..57c9927b34ea 100644 --- a/solr/ui/src/commonMain/composeResources/values/strings.xml +++ b/solr/ui/src/commonMain/composeResources/values/strings.xml @@ -18,8 +18,12 @@ + Cancel Connect + Create Configset + Edit solrconfig.xml Go Back + Import Configset Logout Sign In with Credentials Sign In with %1$s @@ -67,8 +71,10 @@ http://127.0.0.1:8983/ - Welcome to Solr Admin UI + Create Configset + Import Configset Sign In to Solr + Welcome to Solr Admin UI Authenticating... @@ -89,8 +95,10 @@ Zookeeper - Username + Configset Name Password + Select Configset File + Username diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/cluster/integration/DefaultClusterComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/cluster/integration/DefaultClusterComponent.kt index 4058cafb5608..02f230f59010 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/cluster/integration/DefaultClusterComponent.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/cluster/integration/DefaultClusterComponent.kt @@ -17,35 +17,41 @@ package org.apache.solr.ui.components.cluster.integration +import com.arkivanov.decompose.router.slot.ChildSlot +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.value.Value import org.apache.solr.ui.components.cluster.ClusterComponent import org.apache.solr.ui.components.cluster.ClusterComponent.Child import org.apache.solr.ui.components.cluster.ClusterComponent.ClusterTab import org.apache.solr.ui.components.navigation.TabNavigationComponent -import org.apache.solr.ui.components.navigation.integration.DefaultTabNavigationComponent import org.apache.solr.ui.utils.AppComponentContext class DefaultClusterComponent( componentContext: AppComponentContext, - tabNavigation: TabNavigationComponent, ) : ClusterComponent, AppComponentContext by componentContext, - TabNavigationComponent by tabNavigation { + TabNavigationComponent { - constructor( - componentContext: AppComponentContext, - ) : this( - componentContext = componentContext, - tabNavigation = DefaultTabNavigationComponent( - componentContext = componentContext, - initialTab = ClusterTab.Zookeeper, - tabSerializer = ClusterTab.serializer(), - childFactory = { configuration, childContext -> - when (configuration.tab) { - ClusterTab.Zookeeper -> Child.Zookeeper - ClusterTab.Nodes -> Child.Nodes - ClusterTab.Cores -> Child.Cores - } - }, - ), + private val navigation = SlotNavigation() + + override val tabSlot: Value> = childSlot( + source = navigation, + serializer = ClusterTab.serializer(), + handleBackButton = true, + childFactory = { configuration, childContext -> + when (configuration) { + ClusterTab.Zookeeper -> Child.Zookeeper + ClusterTab.Nodes -> Child.Nodes + ClusterTab.Cores -> Child.Cores + } + }, ) + + init { + navigation.activate(configuration = ClusterTab.Zookeeper) + } + + override fun onNavigate(tab: ClusterTab) = navigation.activate(configuration = tab) } diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/ConfigsetsComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/ConfigsetsComponent.kt deleted file mode 100644 index 19580da55675..000000000000 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/ConfigsetsComponent.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.ui.components.configsets - -import kotlinx.coroutines.flow.StateFlow -import org.apache.solr.ui.components.configsets.ConfigsetsComponent.Child -import org.apache.solr.ui.components.configsets.overview.ConfigsetsOverviewComponent -import org.apache.solr.ui.components.navigation.TabNavigationComponent -import org.apache.solr.ui.domain.Configset -import org.apache.solr.ui.views.navigation.configsets.ConfigsetsTab - -/** - * The configsets component provides the main entry point for managing Solr's configets. - */ -interface ConfigsetsComponent : TabNavigationComponent { - - /** - * All possible navigation targets (children) within the Configsets section. - */ - sealed interface Child { - data class Overview(val component: ConfigsetsOverviewComponent) : Child - - /** - * TODO Remove once other sections are added - */ - data class Placeholder(val tabName: String) : Child - } - - /** - * Model that holds the data of the [ConfigsetsComponent]. - * - * @property configsets The configsets names available. - * @property selectedConfigset The current configset name to display. Leave empty if no - * selection is made. - */ - data class Model( - val configsets: List = emptyList(), - val selectedConfigset: String = "", - ) - - /** Hot, observable stream of [Model] for Compose/UI. */ - val model: StateFlow - - /** Select the active configset by name. */ - fun onSelectConfigset(name: String) -} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/configsets/ConfigsetsTab.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/data/CreateConfigset.kt similarity index 81% rename from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/configsets/ConfigsetsTab.kt rename to solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/data/CreateConfigset.kt index 3a68a7cd8dfb..a7b06bf5a3ee 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/configsets/ConfigsetsTab.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/data/CreateConfigset.kt @@ -14,17 +14,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.ui.views.navigation.configsets + +package org.apache.solr.ui.components.configsets.data import kotlinx.serialization.Serializable @Serializable -enum class ConfigsetsTab { - Overview, - Files, - Schema, - UpdateConfig, - IndexQuery, - Handlers, - SearchComponents, -} +data class CreateConfigset( + val name: String, + val baseConfigSet: String? = null, +) diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/data/HttpConfigsetsRepository.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/data/HttpConfigsetsRepository.kt new file mode 100644 index 000000000000..24f7933aef86 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/data/HttpConfigsetsRepository.kt @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.data + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import org.apache.solr.ui.components.configsets.repository.ConfigsetsRepository +import org.apache.solr.ui.domain.Configset +import org.apache.solr.ui.domain.PickedFile + +class HttpConfigsetsRepository(private val httpClient: HttpClient) : ConfigsetsRepository { + override suspend fun createConfigset(name: String, baseConfigset: String?): Result { + val response = httpClient.post("api/configsets") { + setBody( + CreateConfigset( + name = name, + baseConfigSet = baseConfigset, + ), + ) + } + return when { + response.status.isSuccess() -> + // Success result will not contain any information, + // therefore, create the configset from input + Result.success(Configset(name)) + + else -> Result.failure(Exception("Unknown Error")) + } + } + + override suspend fun importConfigset(name: String, file: PickedFile): Result { + val response = httpClient.put("api/configsets/$name") { + contentType(ContentType.parse("application/zip")) + setBody(file.bytes) + } + return when { + response.status.isSuccess() -> Result.success(Configset(name = name)) + else -> Result.failure(Exception("Unknown Error")) + } + } + + override suspend fun loadConfigsets(): Result> { + val response = httpClient.get("api/configsets") + return when { + response.status.isSuccess() -> { + val data = response.body() + Result.success(data.configSets.map { Configset(name = it) }) + } + + else -> Result.failure(Exception("Unknown Error")) + // TODO Add proper error handling + } + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/ConfigsetsComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/ConfigsetsComponent.kt new file mode 100644 index 000000000000..a7d2c7bec227 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/ConfigsetsComponent.kt @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.di + +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.ImportConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.components.configsets.repository.ConfigsetsRepository +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsOverviewViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsRouteViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsViewModel +import org.apache.solr.ui.components.configsets.viewmodel.CreateConfigsetViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ImportConfigsetViewModel +import org.apache.solr.ui.components.files.domain.SelectFileUseCase + +/** + * The configsets component keeps record of the currently available configsets, and a selected + * configset that may be used for additional operations. + */ +interface ConfigsetsComponent { + + /** + * Dependencies provided by the application. + */ + val configsetsRepository: ConfigsetsRepository + + /** + * Use case responsible for loading the available configsets. + */ + val loadConfigsetsUseCase: LoadConfigsetsUseCase + + /** + * Factory method to create a [ConfigsetsRouteViewModel] instance. + */ + fun createConfigsetsRouteViewModel(): ConfigsetsRouteViewModel + + /** + * Factory method to create a [ConfigsetsViewModel] instance. + */ + fun createConfigsetsViewModel(): ConfigsetsViewModel + + fun createConfigsetsOverviewComponent(): ConfigsetsOverviewComponent +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/ConfigsetsOverviewComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/ConfigsetsOverviewComponent.kt new file mode 100644 index 000000000000..876045310bae --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/ConfigsetsOverviewComponent.kt @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.di + +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.ImportConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.components.configsets.repository.ConfigsetsRepository +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsOverviewViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsRouteViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsViewModel +import org.apache.solr.ui.components.configsets.viewmodel.CreateConfigsetViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ImportConfigsetViewModel +import org.apache.solr.ui.components.files.domain.SelectFileUseCase + +/** + * The configsets component keeps record of the currently available configsets, and a selected + * configset that may be used for additional operations. + */ +interface ConfigsetsOverviewComponent { + + /** + * Dependencies provided by the application. + */ + val configsetsRepository: ConfigsetsRepository + + /** + * Use case responsible for creating a new configset. + */ + val createConfigsetUseCase: CreateConfigsetUseCase + + /** + * Use case responsible for importing a configset from a file. + */ + val importConfigsetUseCase: ImportConfigsetUseCase + + /** + * Use case responsible for loading the available configsets. + */ + val loadConfigsetsUseCase: LoadConfigsetsUseCase + + /** + * Use case responsible for selecting a configset file. + */ + val selectFileUseCase: SelectFileUseCase + + /** + * Factory method to create a [ConfigsetsViewModel] instance. + */ + fun createConfigsetsViewModel(): ConfigsetsViewModel + + /** + * Factory method to create a [CreateConfigsetViewModel] instance. + */ + fun createCreateConfigsetViewModel(): CreateConfigsetViewModel + + /** + * Factory method to create a [ImportConfigsetViewModel] instance. + */ + fun createImportConfigsetViewModel(): ImportConfigsetViewModel + + /** + * Factory method to create a [ConfigsetsOverviewViewModel] instance. + */ + fun createConfigsetsOverviewViewModel(): ConfigsetsOverviewViewModel +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/DefaultConfigsetsComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/DefaultConfigsetsComponent.kt new file mode 100644 index 000000000000..f7382ec4e781 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/DefaultConfigsetsComponent.kt @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.di + +import io.ktor.client.HttpClient +import org.apache.solr.ui.components.configsets.data.HttpConfigsetsRepository +import org.apache.solr.ui.components.configsets.domain.DefaultLoadConfigsetsUseCase +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.components.configsets.repository.ConfigsetsRepository +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsRouteViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsViewModel +import org.apache.solr.ui.utils.AppDispatchers +import org.apache.solr.ui.utils.platformDispatchers + +/** + * Default implementation of [ConfigsetsComponent]. + * + * This implementation is using HTTP for configsets operations. + * + * @param httpClient The pre-configured HTTP client to use for user registration operations. + */ +class DefaultConfigsetsComponent( + httpClient: HttpClient, + private val dispatchers: AppDispatchers = platformDispatchers(), +) : ConfigsetsComponent { + + override val configsetsRepository: ConfigsetsRepository by lazy { + HttpConfigsetsRepository(httpClient) + } + + override val loadConfigsetsUseCase: LoadConfigsetsUseCase by lazy { + DefaultLoadConfigsetsUseCase(configsetsRepository) + } + + override fun createConfigsetsRouteViewModel(): ConfigsetsRouteViewModel = ConfigsetsRouteViewModel() + + override fun createConfigsetsViewModel(): ConfigsetsViewModel = ConfigsetsViewModel(loadConfigsetsUseCase, dispatchers) + + override fun createConfigsetsOverviewComponent(): ConfigsetsOverviewComponent = DefaultConfigsetsOverviewComponent(configsetsRepository) +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/DefaultConfigsetsOverviewComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/DefaultConfigsetsOverviewComponent.kt new file mode 100644 index 000000000000..dd269723325c --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/di/DefaultConfigsetsOverviewComponent.kt @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.di + +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.DefaultCreateConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.DefaultImportConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.DefaultLoadConfigsetsUseCase +import org.apache.solr.ui.components.configsets.domain.ImportConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.components.configsets.repository.ConfigsetsRepository +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsOverviewViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsViewModel +import org.apache.solr.ui.components.configsets.viewmodel.CreateConfigsetViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ImportConfigsetViewModel +import org.apache.solr.ui.components.files.domain.DefaultSelectFileUseCase +import org.apache.solr.ui.components.files.domain.SelectFileUseCase +import org.apache.solr.ui.utils.AppDispatchers +import org.apache.solr.ui.utils.platformDispatchers + +/** + * Default implementation of [ConfigsetsOverviewComponent]. + */ +class DefaultConfigsetsOverviewComponent( + override val configsetsRepository: ConfigsetsRepository, + private val dispatchers: AppDispatchers = platformDispatchers(), +) : ConfigsetsOverviewComponent { + + override val createConfigsetUseCase: CreateConfigsetUseCase by lazy { + DefaultCreateConfigsetUseCase(configsetsRepository) + } + + override val importConfigsetUseCase: ImportConfigsetUseCase by lazy { + DefaultImportConfigsetUseCase(configsetsRepository) + } + + // TODO Consider implementing special SelectFileUseCase for configsets import cases + override val selectFileUseCase: SelectFileUseCase by lazy { DefaultSelectFileUseCase() } + + override val loadConfigsetsUseCase: LoadConfigsetsUseCase by lazy { + DefaultLoadConfigsetsUseCase(configsetsRepository) + } + + override fun createConfigsetsViewModel(): ConfigsetsViewModel = ConfigsetsViewModel(loadConfigsetsUseCase, dispatchers) + + override fun createCreateConfigsetViewModel(): CreateConfigsetViewModel = CreateConfigsetViewModel(createConfigsetUseCase, loadConfigsetsUseCase, dispatchers) + + override fun createImportConfigsetViewModel(): ImportConfigsetViewModel = ImportConfigsetViewModel(importConfigsetUseCase, selectFileUseCase, dispatchers) + + override fun createConfigsetsOverviewViewModel(): ConfigsetsOverviewViewModel = ConfigsetsOverviewViewModel() +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/CreateConfigsetEvent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/CreateConfigsetEvent.kt new file mode 100644 index 000000000000..02b56f69b8c5 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/CreateConfigsetEvent.kt @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.domain + +import org.apache.solr.ui.domain.Configset + +sealed interface CreateConfigsetEvent { + + /** + * Event that is omitted when a configset was successfully created. + * + * @property configset The configset that has been created. + */ + data class ConfigsetCreated(val configset: Configset) : CreateConfigsetEvent + + /** + * Event that is omitted when the creation process failed with an error. + * + * @property error The error that was thrown during the creation process. + */ + data class ConfigsetCreationFailed(val error: Exception) : CreateConfigsetEvent + + /** + * Event that is omitted when the creation process is aborted. + */ + data object ConfigsetCreationAborted : CreateConfigsetEvent + + /** + * Event that is omitted when the creation process input form is toggled. It can be either + * text input or file input. + * + * @property useFileInput Whether the file input form is used (true) or the text field form + * instead (false). + */ + data class ConfigsetCreateToggleInputForm(val useFileInput: Boolean) : CreateConfigsetEvent +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/CreateConfigsetUseCase.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/CreateConfigsetUseCase.kt new file mode 100644 index 000000000000..2c04c6161fe8 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/CreateConfigsetUseCase.kt @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.domain + +import org.apache.solr.ui.domain.Configset + +/** + * Use case for creating a new configset. + */ +interface CreateConfigsetUseCase { + /** + * Default invocation for creating a new configset with user inputs. + * + * @param configsetName The configset name to use. + * @param baseConfigset The configset to use as base for the new configset. + * + * @return A Result containing the Configset of the newly created configset or an error if + * creation failed. + */ + suspend operator fun invoke( + configsetName: String, + baseConfigset: String? = null, + ): CreateConfigsetResult +} + +sealed interface CreateConfigsetResult { + data class Success(val configset: Configset) : CreateConfigsetResult + + data class ValidationFailure(val error: Error) : CreateConfigsetResult + + data class UnexpectedFailure(val cause: Throwable) : CreateConfigsetResult + + enum class Error { + /** + * Error for indicating that the configset name contains invalid characters. + */ + ConfigsetNameContainsInvalidCharacters, + + /** + * Error for indicating that the configset name is too long. + */ + ConfigsetNameTooLong, + + /** + * Error for indicating that the configset with the given name already exists. + */ + DuplicateConfigset, + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/DefaultCreateConfigsetUseCase.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/DefaultCreateConfigsetUseCase.kt new file mode 100644 index 000000000000..472996f2fbf1 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/DefaultCreateConfigsetUseCase.kt @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.domain + +import org.apache.solr.ui.components.configsets.repository.ConfigsetsRepository +import org.apache.solr.ui.utils.MAX_CONFIGSET_NAME_LENGTH +import org.apache.solr.ui.utils.configsetNameRegex + +internal class DefaultCreateConfigsetUseCase( + private val repository: ConfigsetsRepository, +) : CreateConfigsetUseCase { + override suspend fun invoke( + configsetName: String, + baseConfigset: String?, + ): CreateConfigsetResult { + if (!configsetName.matches(configsetNameRegex)) { + return CreateConfigsetResult.ValidationFailure( + error = CreateConfigsetResult.Error.ConfigsetNameContainsInvalidCharacters, + ) + } + + if (configsetName.length > MAX_CONFIGSET_NAME_LENGTH) { + return CreateConfigsetResult.ValidationFailure( + error = CreateConfigsetResult.Error.ConfigsetNameTooLong, + ) + } + + // TODO v2 API requires baseConfigSet to be set, + // remove fallback to _default once it is truly optional + repository.createConfigset(configsetName, baseConfigset ?: "_default") + .onSuccess { + return CreateConfigsetResult.Success(configset = it) + } + .onFailure { return CreateConfigsetResult.UnexpectedFailure(cause = it) } + return CreateConfigsetResult.UnexpectedFailure(cause = Error("Unknown error")) + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/DefaultImportConfigsetUseCase.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/DefaultImportConfigsetUseCase.kt new file mode 100644 index 000000000000..82745c4d0980 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/DefaultImportConfigsetUseCase.kt @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.domain + +import org.apache.solr.ui.components.configsets.repository.ConfigsetsRepository +import org.apache.solr.ui.domain.Configset +import org.apache.solr.ui.domain.PickedFile + +internal class DefaultImportConfigsetUseCase( + private val repository: ConfigsetsRepository, +) : ImportConfigsetUseCase { + override suspend fun invoke(configsetName: String, file: PickedFile): ImportConfigsetResult { + // TODO Validate input data + repository.importConfigset(configsetName, file).onSuccess { + return ImportConfigsetResult.Success(configset = it) + }.onFailure { + // TODO Handle failure correctly + return ImportConfigsetResult.UnexpectedFailure(cause = it) + } + return ImportConfigsetResult.UnexpectedFailure(Error("Unknown error")) + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/integration/Mappers.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/DefaultLoadConfigsetsUseCase.kt similarity index 63% rename from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/integration/Mappers.kt rename to solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/DefaultLoadConfigsetsUseCase.kt index 00a428faa326..cabac8319a5b 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/integration/Mappers.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/DefaultLoadConfigsetsUseCase.kt @@ -15,16 +15,13 @@ * limitations under the License. */ -package org.apache.solr.ui.components.configsets.integration +package org.apache.solr.ui.components.configsets.domain -import org.apache.solr.ui.components.configsets.ConfigsetsComponent -import org.apache.solr.ui.components.configsets.store.ConfigsetsStore +import org.apache.solr.ui.components.configsets.repository.ConfigsetsRepository import org.apache.solr.ui.domain.Configset -internal val configsetsStateToModel: (ConfigsetsStore.State) -> ConfigsetsComponent.Model = { - ConfigsetsComponent.Model( - configsets = it.configSets.configSets.sorted() - .map { s -> Configset(s) }, - selectedConfigset = it.selectedConfigset ?: "", - ) +internal class DefaultLoadConfigsetsUseCase( + private val repository: ConfigsetsRepository, +) : LoadConfigsetsUseCase { + override suspend fun invoke(): Result> = repository.loadConfigsets() } diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/ImportConfigsetUseCase.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/ImportConfigsetUseCase.kt new file mode 100644 index 000000000000..1e10e675ca47 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/ImportConfigsetUseCase.kt @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.domain + +import org.apache.solr.ui.domain.Configset +import org.apache.solr.ui.domain.PickedFile + +/** + * Use case for importing a configset from a file. + */ +interface ImportConfigsetUseCase { + /** + * Default invocation for importing a configset from a simple zip file. + * + * @param configsetName The name of the configset to use. + * @param file The configset file to import. + * + * @return A Result containing the Configset of the created configset or an error if the import + * failed. + */ + suspend operator fun invoke(configsetName: String, file: PickedFile): ImportConfigsetResult +} + +sealed interface ImportConfigsetResult { + data class Success(val configset: Configset) : ImportConfigsetResult + + data class ValidationFailure(val error: Error) : ImportConfigsetResult + + data class UnexpectedFailure(val cause: Throwable) : ImportConfigsetResult + + enum class Error { + /** + * Error for indicating an invalid configset name. + */ + InvalidConfigsetName, + + /** + * Error for indicating that the configset with the given name already exists. + */ + DuplicateConfigset, + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/LoadConfigsetsUseCase.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/LoadConfigsetsUseCase.kt new file mode 100644 index 000000000000..a45efa476f5a --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/domain/LoadConfigsetsUseCase.kt @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.domain + +import org.apache.solr.ui.domain.Configset +import org.apache.solr.ui.domain.PickedFile + +/** + * Use case for loading the available configsets. + */ +interface LoadConfigsetsUseCase { + /** + * Default invocation for loading the available configsets. + * + * @return A Result containing the list of available Configsets. + */ + suspend operator fun invoke(): Result> +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/integration/DefaultConfigsetsComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/integration/DefaultConfigsetsComponent.kt deleted file mode 100644 index b3ff89b435fa..000000000000 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/integration/DefaultConfigsetsComponent.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.ui.components.configsets.integration - -import com.arkivanov.decompose.childContext -import com.arkivanov.mvikotlin.core.instancekeeper.getStore -import com.arkivanov.mvikotlin.core.store.StoreFactory -import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow -import io.ktor.client.HttpClient -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import org.apache.solr.ui.components.configsets.ConfigsetsComponent -import org.apache.solr.ui.components.configsets.ConfigsetsComponent.Child -import org.apache.solr.ui.components.configsets.ConfigsetsComponent.Child.Overview -import org.apache.solr.ui.components.configsets.ConfigsetsComponent.Child.Placeholder -import org.apache.solr.ui.components.configsets.overview.integration.DefaultConfigsetsOverviewComponent -import org.apache.solr.ui.components.configsets.store.ConfigsetsStore.Intent -import org.apache.solr.ui.components.configsets.store.ConfigsetsStoreProvider -import org.apache.solr.ui.components.navigation.TabNavigationComponent -import org.apache.solr.ui.components.navigation.TabNavigationComponent.Configuration -import org.apache.solr.ui.components.navigation.integration.DefaultTabNavigationComponent -import org.apache.solr.ui.utils.AppComponentContext -import org.apache.solr.ui.utils.coroutineScope -import org.apache.solr.ui.utils.map -import org.apache.solr.ui.views.navigation.configsets.ConfigsetsTab - -/** - * Default implementation of the [ConfigsetsComponent]. - */ -class DefaultConfigsetsComponent internal constructor( - componentContext: AppComponentContext, - tabNavigation: TabNavigationComponent, - storeFactory: StoreFactory, - httpClient: HttpClient, -) : ConfigsetsComponent, - AppComponentContext by componentContext, - TabNavigationComponent by tabNavigation { - - constructor( - componentContext: AppComponentContext, - storeFactory: StoreFactory, - httpClient: HttpClient, - ) : this ( - componentContext = componentContext, - storeFactory = storeFactory, - httpClient = httpClient, - tabNavigation = DefaultTabNavigationComponent( - componentContext = componentContext.childContext("ConfigsetsTabs"), - initialTab = ConfigsetsTab.Overview, - tabSerializer = ConfigsetsTab.serializer(), - childFactory = { configuration, childContext -> - configsetsChildFactory(storeFactory, httpClient, configuration, childContext) - }, - ), - ) - - private val mainScope = coroutineScope(SupervisorJob() + mainContext) - private val ioScope = coroutineScope(SupervisorJob() + ioContext) - - private val store = instanceKeeper.getStore { - ConfigsetsStoreProvider( - storeFactory = storeFactory, - client = HttpConfigsetsStoreClient(httpClient), - mainContext = mainScope.coroutineContext, - ioContext = ioScope.coroutineContext, - ).provide() - } - - @OptIn(ExperimentalCoroutinesApi::class) - override val model = store.stateFlow.map(mainScope, configsetsStateToModel) - - override fun onSelectConfigset(name: String) { - store.accept(Intent.SelectConfigSet(configSetName = name)) - } -} - -fun configsetsChildFactory( - storeFactory: StoreFactory, - httpClient: HttpClient, - configuration: Configuration, - childContext: AppComponentContext, -): Child = when (configuration.tab) { - ConfigsetsTab.Overview -> Overview( - DefaultConfigsetsOverviewComponent( - componentContext = childContext, - storeFactory = storeFactory, - httpClient = httpClient, - ), - ) - - else -> Placeholder(tabName = configuration.tab.name) -} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/integration/HttpConfigsetsStoreClient.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/integration/HttpConfigsetsStoreClient.kt deleted file mode 100644 index cd8a7eaa726d..000000000000 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/integration/HttpConfigsetsStoreClient.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.ui.components.configsets.integration - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.http.isSuccess -import org.apache.solr.ui.components.configsets.data.ListConfigsets -import org.apache.solr.ui.components.configsets.store.ConfigsetsStoreProvider - -/** - * Client implementation of the [ConfigsetsStoreProvider.Client] that makes use - * of a preconfigured HTTP client for accessing the Solr API. - * - * @property httpClient HTTP client to use for accessing the API. The client has to be - * configured with a default request that includes the host, port and schema. The client - * should also include the necessary authentication data if authentication / authorization - * is enabled. - */ -class HttpConfigsetsStoreClient( - private val httpClient: HttpClient, -) : ConfigsetsStoreProvider.Client { - override suspend fun fetchConfigSets(): Result { - val response = httpClient.get("api/configsets") - return when { - response.status.isSuccess() -> Result.success(response.body()) - else -> Result.failure(Exception("Unknown Error")) - // TODO Add proper error handling - } - } -} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/repository/ConfigsetsRepository.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/repository/ConfigsetsRepository.kt new file mode 100644 index 000000000000..f1b89627dd20 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/repository/ConfigsetsRepository.kt @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.repository + +import org.apache.solr.ui.components.configsets.data.CreateConfigset +import org.apache.solr.ui.domain.Configset +import org.apache.solr.ui.domain.PickedFile + +interface ConfigsetsRepository { + /** + * Create a new configset. + * + * @param name The name to use for the new configset. + * @param baseConfigset The configset to use as base. + * @result Result with the created configset. + */ + suspend fun createConfigset(name: String, baseConfigset: String? = null): Result + + /** + * Import a configset from a zip file. + * + * @param name The name to use for the configset that is being imported. + * @param file The file to upload for the import. + * @result Result with the created configset. + */ + suspend fun importConfigset(name: String, file: PickedFile): Result + + /** + * Load the available configsets. + */ + suspend fun loadConfigsets(): Result> +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/store/ConfigsetsStore.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/store/ConfigsetsStore.kt deleted file mode 100644 index 7dbbb9f69dda..000000000000 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/store/ConfigsetsStore.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.solr.ui.components.configsets.store - -import com.arkivanov.mvikotlin.core.store.Store -import org.apache.solr.ui.components.configsets.data.ListConfigsets -import org.apache.solr.ui.components.configsets.store.ConfigsetsStore.Intent -import org.apache.solr.ui.components.configsets.store.ConfigsetsStore.State -import org.apache.solr.ui.views.navigation.configsets.ConfigsetsTab - -internal interface ConfigsetsStore : Store { - - sealed interface Intent { - /** - * Intent for selecting configset. - */ - data class SelectConfigSet(val configSetName: String) : Intent - } - - data class State( - val selectedConfigset: String? = null, - val configSets: ListConfigsets = ListConfigsets(), - ) -} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/store/ConfigsetsStoreProvider.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/store/ConfigsetsStoreProvider.kt deleted file mode 100644 index e3c73e4bcfd3..000000000000 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/store/ConfigsetsStoreProvider.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.ui.components.configsets.store - -import com.arkivanov.mvikotlin.core.store.Reducer -import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper -import com.arkivanov.mvikotlin.core.store.Store -import com.arkivanov.mvikotlin.core.store.StoreFactory -import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.apache.solr.ui.components.configsets.data.ListConfigsets -import org.apache.solr.ui.components.configsets.store.ConfigsetsStore.Intent -import org.apache.solr.ui.components.configsets.store.ConfigsetsStore.State -import org.apache.solr.ui.views.navigation.configsets.ConfigsetsTab - -/** - * Store provider that [provide]s instances of [ConfigsetsStore]. - * - * @property storeFactory Store factory to use for creating the store. - * @property client Client implementation to use for resolving [Intent]s and [Action]s. - * @property ioContext Coroutine context used for IO activity. - */ -internal class ConfigsetsStoreProvider( - private val storeFactory: StoreFactory, - private val client: Client, - private val mainContext: CoroutineContext, - private val ioContext: CoroutineContext, -) { - - fun provide(): ConfigsetsStore = object : - ConfigsetsStore, - Store by storeFactory.create( - name = "ConfigsetsStore", - initialState = State(), - bootstrapper = SimpleBootstrapper(Action.FetchInitialConfigsets), - executorFactory = ::ExecutorImpl, - reducer = ReducerImpl, - ) {} - - private sealed interface Action { - /** - * Action used for initiating the initial fetch of configsets data. - */ - data object FetchInitialConfigsets : Action - } - - private sealed interface Message { - data class ConfigSetsUpdated(val configsets: ListConfigsets) : Message - data class SelectedConfigSetChanged(val configsetName: String) : Message - } - - private inner class ExecutorImpl : CoroutineExecutor(mainContext) { - override fun executeAction(action: Action) = when (action) { - Action.FetchInitialConfigsets -> { - fetchConfigSets() - } - } - - override fun executeIntent(intent: Intent) = when (intent) { - is Intent.SelectConfigSet -> dispatch(Message.SelectedConfigSetChanged(intent.configSetName)) - } - - private fun fetchConfigSets() { - scope.launch { - withContext(ioContext) { - client.fetchConfigSets() - }.onSuccess { sets -> - dispatch(Message.ConfigSetsUpdated(sets)) - } - } - } - } - - /** - * Reducer implementation that consumes [Message]s and updates the store's [State]. - */ - private object ReducerImpl : Reducer { - override fun State.reduce(msg: Message): State = when (msg) { - is Message.ConfigSetsUpdated -> copy(configSets = msg.configsets) - is Message.SelectedConfigSetChanged -> copy(selectedConfigset = msg.configsetName) - } - } - - /** - * Client interface for fetching configsets information. - */ - interface Client { - /** To fetch a list of configsets. */ - suspend fun fetchConfigSets(): Result - } -} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsOverviewViewModel.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsOverviewViewModel.kt new file mode 100644 index 000000000000..e0bf9a8884df --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsOverviewViewModel.kt @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.viewmodel + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.ViewModel +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsOverviewEntry.ConfigsetsOverviewDialog.CreateConfigsetDialog +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsOverviewEntry.ConfigsetsOverviewDialog.ImportConfigsetDialog + +class ConfigsetsOverviewViewModel : ViewModel() { + + val backStack: SnapshotStateList = + mutableStateListOf(ConfigsetsOverviewEntry.Main) + + /** + * Initiates the creation of a new configset. + */ + fun openCreateConfigsetDialog() = backStack.apply { + clearDialogs() + add(CreateConfigsetDialog) + } + + /** + * Initiates the import of a configset. + */ + fun openImportConfigsetDialog() = backStack.apply { + clearDialogs() + add(ImportConfigsetDialog) + } + + /** + * Closes any opened dialog. + */ + fun closeDialog() { + backStack.clearDialogs() + } + + /** + * Edit solrconfig.xml for the configset with the given [name]. + * + * @param name the name of the configset to edit. + */ + fun editSolrConfig(name: String) { + TODO() + } + + private fun SnapshotStateList.clearDialogs() = removeAll { entry -> entry is ConfigsetsOverviewEntry.ConfigsetsOverviewDialog } +} + +@Serializable +sealed interface ConfigsetsOverviewEntry : NavKey { + + @Serializable + data object Main : ConfigsetsOverviewEntry + + @Serializable + sealed interface ConfigsetsOverviewDialog : ConfigsetsOverviewEntry { + + @Serializable + data object CreateConfigsetDialog : ConfigsetsOverviewDialog + + @Serializable + data object ImportConfigsetDialog : ConfigsetsOverviewDialog + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsRouteViewModel.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsRouteViewModel.kt new file mode 100644 index 000000000000..2d0cf9ea1775 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsRouteViewModel.kt @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.viewmodel + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable + +// TODO Make savedStateHandle mandatory +class ConfigsetsRouteViewModel( + savedStateHandle: SavedStateHandle? = null, +) : ViewModel() { + + private val initialTab = savedStateHandle + ?.get("tab") + ?.let { ConfigsetsTab.valueOf(it) } + ?: ConfigsetsTab.Overview + + val backStack: SnapshotStateList = mutableStateListOf( + when (initialTab) { + ConfigsetsTab.Overview -> ConfigsetsScene.Overview + ConfigsetsTab.Files -> ConfigsetsScene.Files + ConfigsetsTab.Schema -> ConfigsetsScene.Schema + ConfigsetsTab.UpdateConfig -> ConfigsetsScene.UpdateConfig + ConfigsetsTab.IndexQuery -> ConfigsetsScene.IndexQuery + ConfigsetsTab.Handlers -> ConfigsetsScene.Handlers + ConfigsetsTab.SearchComponents -> ConfigsetsScene.SearchComponents + }, + ) + + /** + * The dialog that is currently open, if any. + */ + val uiState: StateFlow + field = MutableStateFlow(ConfigsetsSceneUiState(selectedTab = initialTab)) + + /** + * Switches to the configset [tab] that was provided. + * + * @param tab The tab to select. + */ + fun selectTab(tab: ConfigsetsTab) = uiState.update { it.copy(selectedTab = tab) } +} + +data class ConfigsetsSceneUiState( + val selectedTab: ConfigsetsTab = ConfigsetsTab.Overview, +) + +@Serializable +sealed interface ConfigsetsScene : NavKey { + + @Serializable + data object Overview : ConfigsetsScene + + @Serializable + data object Files : ConfigsetsScene + + @Serializable + data object Schema : ConfigsetsScene + + @Serializable + data object UpdateConfig : ConfigsetsScene + + @Serializable + data object IndexQuery : ConfigsetsScene + + @Serializable + data object Handlers : ConfigsetsScene + + @Serializable + data object SearchComponents : ConfigsetsScene +} + +@Serializable +enum class ConfigsetsTab { + Overview, + Files, + Schema, + UpdateConfig, + IndexQuery, + Handlers, + SearchComponents, +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsStateHolder.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsStateHolder.kt new file mode 100644 index 000000000000..0f17f5b26d42 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsStateHolder.kt @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.viewmodel + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.domain.Configset +import org.apache.solr.ui.utils.AppDispatchers + +class ConfigsetsStateHolder( + private val scope: CoroutineScope, + private val loadConfigsetsUseCase: LoadConfigsetsUseCase, + private val dispatchers: AppDispatchers, +) { + + /** + * State of the configset create form. + */ + val uiState: StateFlow + field = MutableStateFlow(ConfigsetsUiState()) + + init { + loadConfigsets() + } + + /** + * Selects the given [configset]. + * + * @param configset The configset to select. + */ + fun selectConfigset(configset: String) = uiState.update { + it.copy(selectedConfigset = configset) + } + + /** + * Clears the currently selected configset, if any. + */ + fun clearSelectedConfigset() = uiState.update { it.copy(selectedConfigset = null) } + + /** + * Reloads the configsets. + */ + fun reloadConfigsets() { + loadConfigsets(clearOnFailure = true) + } + + private fun loadConfigsets(clearOnFailure: Boolean = false) = scope.launch { + withContext(dispatchers.io) { + loadConfigsetsUseCase() + }.onSuccess { configsets -> + uiState.update { it.copy(configsets = configsets.sortedBy(Configset::name)) } + if (configsets.none { it.name == uiState.value.selectedConfigset }) { + // Unselect current configset + clearSelectedConfigset() + } + }.onFailure { + // TODO Notify user about loading issue + if (clearOnFailure) { + uiState.update { + it.copy( + configsets = emptyList(), + selectedConfigset = null, + ) + } + } + } + } +} + +data class ConfigsetsUiState( + val configsets: List = emptyList(), + val selectedConfigset: String? = null, +) diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsViewModel.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsViewModel.kt new file mode 100644 index 000000000000..0bb1b9cbd8c4 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsViewModel.kt @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.utils.AppDispatchers + +class ConfigsetsViewModel( + loadConfigsetsUseCase: LoadConfigsetsUseCase, + dispatchers: AppDispatchers, +) : ViewModel() { + + private val configsetsState = ConfigsetsStateHolder( + scope = viewModelScope, + loadConfigsetsUseCase = loadConfigsetsUseCase, + dispatchers = dispatchers, + ) + + /** + * UI State of the configsets. + */ + val uiState = configsetsState.uiState + + /** + * Selects the given [configset]. + * + * @param configset The configset to select. + */ + fun selectConfigset(configset: String) = configsetsState.selectConfigset(configset) + + /** + * Clears the currently selected configset, if any. + */ + fun clearSelectedConfigset() = configsetsState.clearSelectedConfigset() + + /** + * Reloads the configsets. + */ + fun reloadConfigsets() = configsetsState.reloadConfigsets() +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/CreateConfigsetViewModel.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/CreateConfigsetViewModel.kt new file mode 100644 index 000000000000..e2a6fcdff696 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/CreateConfigsetViewModel.kt @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetEvent +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetResult +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.utils.AppDispatchers + +class CreateConfigsetViewModel( + private val createConfigsetUseCase: CreateConfigsetUseCase, + loadConfigsetsUseCase: LoadConfigsetsUseCase, + private val dispatchers: AppDispatchers, +) : ViewModel() { + + private val configsetsState = ConfigsetsStateHolder( + scope = viewModelScope, + loadConfigsetsUseCase = loadConfigsetsUseCase, + dispatchers = dispatchers, + ) + + /** + * State of the configset create form. + */ + val uiState: StateFlow + field = MutableStateFlow(CreateConfigsetFormUiState()) + + /** + * Configset state that holds the currently selected base configset. + */ + val configsetsUiState = configsetsState.uiState + + /** + * Events emitted by the viewmodel. + */ + val events: SharedFlow + field = MutableSharedFlow(extraBufferCapacity = 1) + + fun changeConfigsetName(configsetName: String) = uiState.update { + it.copy(configsetName = configsetName) + } + + fun changeBaseConfigset(baseConfigset: String) = configsetsState.selectConfigset(baseConfigset) + + fun createConfigset() { + uiState.update { it.copy(isLoading = true) } + // TODO Validate input data or let use case validate data + + viewModelScope.launch { + val result = withContext(context = dispatchers.io) { + createConfigsetUseCase( + configsetName = uiState.value.configsetName, + baseConfigset = configsetsState.uiState.value.selectedConfigset, + ) + } + + when (result) { + is CreateConfigsetResult.Success -> + events.emit(CreateConfigsetEvent.ConfigsetCreated(result.configset)) + + is CreateConfigsetResult.ValidationFailure -> + uiState.update { it.copy(configsetNameError = result.error) } + + is CreateConfigsetResult.UnexpectedFailure -> TODO() + } + } + } + + fun clearBaseConfigset() = configsetsState.clearSelectedConfigset() + + fun toggleInput() = viewModelScope.launch { + events.emit(CreateConfigsetEvent.ConfigsetCreateToggleInputForm(useFileInput = true)) + } + + fun abortCreation() = viewModelScope.launch { + events.emit(CreateConfigsetEvent.ConfigsetCreationAborted) + } +} + +data class CreateConfigsetFormUiState( + val configsetName: String = "", + val configsetNameError: CreateConfigsetResult.Error? = null, + val isLoading: Boolean = false, +) diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ImportConfigsetViewModel.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ImportConfigsetViewModel.kt new file mode 100644 index 000000000000..63cd8f9c24d6 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ImportConfigsetViewModel.kt @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetEvent +import org.apache.solr.ui.components.configsets.domain.ImportConfigsetResult +import org.apache.solr.ui.components.configsets.domain.ImportConfigsetUseCase +import org.apache.solr.ui.components.files.domain.FileSelectorEvent +import org.apache.solr.ui.components.files.domain.SelectFileUseCase +import org.apache.solr.ui.components.files.viewmodel.FileSelectorStateHolder +import org.apache.solr.ui.domain.PickedFile +import org.apache.solr.ui.utils.AppDispatchers + +class ImportConfigsetViewModel( + private val importConfigsetUseCase: ImportConfigsetUseCase, + selectFileUseCase: SelectFileUseCase, + private val dispatchers: AppDispatchers, +) : ViewModel() { + + private val fileSelectorState = FileSelectorStateHolder( + scope = viewModelScope, + selectFileUseCase = selectFileUseCase, + ) + + /** + * State of the configset import form. + */ + val uiState: StateFlow + field = MutableStateFlow(ImportConfigsetUiState()) + + /** + * State of the file selector. + */ + val fileSelectorUiState = fileSelectorState.uiState + + /** + * Events emitted by the viewmodel. This events flow uses the same events as + * [CreateConfigsetViewModel] for simplicity. + */ + val events: SharedFlow + field = MutableSharedFlow(extraBufferCapacity = 1) + + init { + viewModelScope.launch { + fileSelectorState.events.collect { event -> + when (event) { + is FileSelectorEvent.FileSelected -> if (!uiState.value.configsetNameChanged) { + setFileNameAsConfigset(event.file) + } + } + } + } + } + + fun changeConfigsetName(name: String) = uiState.update { + it.copy(configsetName = name, configsetNameChanged = true) + } + + fun selectFile() = fileSelectorState.selectFile(SUPPORTED_CONFIGSET_IMPORT_FILE_EXTENSIONS) + + fun clearFile() = fileSelectorState.clearFile() + + fun importConfigset() { + val file = fileSelectorState.validateAndGetFile() ?: return + + uiState.update { it.copy(isLoading = true) } + // TODO Validate input data or let use case validate data + + viewModelScope.launch { + val result = withContext(context = dispatchers.io) { + importConfigsetUseCase(uiState.value.configsetName, file) + } + + when (result) { + is ImportConfigsetResult.Success -> + events.emit(CreateConfigsetEvent.ConfigsetCreated(result.configset)) + + is ImportConfigsetResult.ValidationFailure -> uiState.update { + it.copy( + configsetNameError = when (val error = result.error) { + ImportConfigsetResult.Error.InvalidConfigsetName, + ImportConfigsetResult.Error.DuplicateConfigset, + -> error + }, + ) + } + + is ImportConfigsetResult.UnexpectedFailure -> TODO() + } + } + } + + fun toggleInput() = viewModelScope.launch { + events.emit(CreateConfigsetEvent.ConfigsetCreateToggleInputForm(useFileInput = false)) + } + + fun abortImport() = viewModelScope.launch { + events.emit(CreateConfigsetEvent.ConfigsetCreationAborted) + } + + /** + * Update the configset name to use the file name as configset name, removing the + */ + private fun setFileNameAsConfigset(file: PickedFile) { + uiState.update { + it.copy( + configsetName = file.name + .removeSuffix(".${file.extension ?: "zip"}") + .removeSuffix("_configset"), + ) + } + } +} + +/** + * A list of the supported file extensions for configsets that can be imported. + * + * Currently only zip files are supported. + */ +private val SUPPORTED_CONFIGSET_IMPORT_FILE_EXTENSIONS = listOf("zip") + +data class ImportConfigsetUiState( + val configsetName: String = "", + val configsetNameError: ImportConfigsetResult.Error? = null, + val configsetNameChanged: Boolean = false, + val isLoading: Boolean = false, +) diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/domain/DefaultSelectFileUseCase.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/domain/DefaultSelectFileUseCase.kt new file mode 100644 index 000000000000..8f6d19a48c75 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/domain/DefaultSelectFileUseCase.kt @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.files.domain + +import org.apache.solr.ui.utils.pickFile + +internal class DefaultSelectFileUseCase : SelectFileUseCase { + override suspend fun invoke( + extensions: List, + maxSize: Int?, + ): SelectFileResult { + // Launch in main scope as it is tightly coupled with user-interaction + val pickedFile = pickFile(extensions = extensions) + // TODO Add additional validation + return pickedFile?.let { + if (maxSize != null && it.bytes.size > maxSize) { + SelectFileResult.ValidationFailure(SelectFileResult.Error.FileTooLarge) + } + SelectFileResult.Success(it) + } ?: SelectFileResult.Aborted + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/domain/FileSelectorEvent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/domain/FileSelectorEvent.kt new file mode 100644 index 000000000000..bf85256f10e6 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/domain/FileSelectorEvent.kt @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.files.domain + +import org.apache.solr.ui.domain.PickedFile + +sealed interface FileSelectorEvent { + /** + * Event that is emitted whenever a file is selected. + */ + data class FileSelected(val file: PickedFile) : FileSelectorEvent +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/domain/SelectFileUseCase.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/domain/SelectFileUseCase.kt new file mode 100644 index 000000000000..6d3e6f3d4f7d --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/domain/SelectFileUseCase.kt @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.files.domain + +import org.apache.solr.ui.domain.PickedFile + +interface SelectFileUseCase { + /** + * Default invocation for selecting a file. + * + * @param extensions The allowed file extensions to filter. + * + * @return A Result containing the file that was selected. + */ + suspend operator fun invoke( + extensions: List, + maxSize: Int? = null, + ): SelectFileResult +} + +sealed interface SelectFileResult { + data class Success(val file: PickedFile) : SelectFileResult + + data object Aborted : SelectFileResult + + data class ValidationFailure(val error: Error) : SelectFileResult + + data class UnexpectedFailure(val cause: Throwable) : SelectFileResult + + enum class Error { + + /** + * Error for indicating that no file has been selected. + */ + FileNotSelected, + FileTooLarge, + UnsupportedFormat, + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/viewmodel/FileSelectorStateHolder.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/viewmodel/FileSelectorStateHolder.kt new file mode 100644 index 000000000000..ec9aef8cfd99 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/files/viewmodel/FileSelectorStateHolder.kt @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.files.viewmodel + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.apache.solr.ui.components.files.domain.FileSelectorEvent +import org.apache.solr.ui.components.files.domain.SelectFileResult +import org.apache.solr.ui.components.files.domain.SelectFileUseCase +import org.apache.solr.ui.domain.PickedFile + +class FileSelectorStateHolder( + private val scope: CoroutineScope, + private val selectFileUseCase: SelectFileUseCase, +) { + + /** + * State of the configset create form. + */ + val uiState: StateFlow + field = MutableStateFlow(FileSelectorUiState()) + + /** + * Events emitted by the state holer. + */ + val events: SharedFlow + field = MutableSharedFlow(extraBufferCapacity = 1) + + fun selectFile(extensions: List) { + uiState.update { it.copy(fileError = null) } + scope.launch { + when (val result = selectFileUseCase(extensions)) { + is SelectFileResult.Aborted -> Unit + + // Ignore + is SelectFileResult.Success -> { + uiState.update { + it.copy(file = result.file, fileError = null) + } + events.emit(FileSelectorEvent.FileSelected(result.file)) + } + + is SelectFileResult.ValidationFailure -> + uiState.update { it.copy(fileError = result.error) } + + is SelectFileResult.UnexpectedFailure -> { + // TODO Handle general error + } + } + } + } + + fun clearFile() { + uiState.update { it.copy(file = null) } + } + + fun validateAndGetFile(): PickedFile? { + var isValid = true + if (uiState.value.file == null) { + uiState.update { it.copy(fileError = SelectFileResult.Error.FileNotSelected) } + isValid = false + } + if (isValid) return uiState.value.file + return null + } +} + +data class FileSelectorUiState( + val file: PickedFile? = null, + val fileError: SelectFileResult.Error? = null, +) diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/main/MainComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/main/MainComponent.kt index 56e1ad292840..de40d86b5865 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/main/MainComponent.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/main/MainComponent.kt @@ -20,13 +20,11 @@ package org.apache.solr.ui.components.main import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.value.Value import org.apache.solr.ui.components.cluster.ClusterComponent -import org.apache.solr.ui.components.configsets.ConfigsetsComponent +import org.apache.solr.ui.components.configsets.di.ConfigsetsComponent import org.apache.solr.ui.components.environment.EnvironmentComponent import org.apache.solr.ui.components.logging.LoggingComponent import org.apache.solr.ui.components.navigation.NavigationComponent -import org.apache.solr.ui.components.navigation.TabNavigationComponent import org.apache.solr.ui.views.navigation.MainMenu -import org.apache.solr.ui.views.navigation.configsets.ConfigsetsTab /** * Main component of the application that is used as base for users with access. diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/main/integration/DefaultMainComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/main/integration/DefaultMainComponent.kt index 4de94a395e50..1a354267c438 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/main/integration/DefaultMainComponent.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/main/integration/DefaultMainComponent.kt @@ -27,8 +27,8 @@ import io.ktor.client.HttpClient import kotlinx.serialization.Serializable import org.apache.solr.ui.components.cluster.ClusterComponent import org.apache.solr.ui.components.cluster.integration.DefaultClusterComponent -import org.apache.solr.ui.components.configsets.ConfigsetsComponent -import org.apache.solr.ui.components.configsets.integration.DefaultConfigsetsComponent +import org.apache.solr.ui.components.configsets.di.ConfigsetsComponent +import org.apache.solr.ui.components.configsets.di.DefaultConfigsetsComponent import org.apache.solr.ui.components.environment.EnvironmentComponent import org.apache.solr.ui.components.environment.integration.DefaultEnvironmentComponent import org.apache.solr.ui.components.logging.LoggingComponent @@ -44,7 +44,7 @@ class DefaultMainComponent internal constructor( storeFactory: StoreFactory, destination: String? = null, private val clusterComponent: (AppComponentContext) -> ClusterComponent, - private val configsetsComponent: (AppComponentContext) -> ConfigsetsComponent, + private val configsetsComponent: () -> ConfigsetsComponent, private val environmentComponent: (AppComponentContext) -> EnvironmentComponent, private val loggingComponent: (AppComponentContext) -> LoggingComponent, private val output: (Output) -> Unit, @@ -77,13 +77,7 @@ class DefaultMainComponent internal constructor( componentContext = childContext, ) }, - configsetsComponent = { childContext -> - DefaultConfigsetsComponent( - componentContext = childContext, - storeFactory = storeFactory, - httpClient = httpClient, - ) - }, + configsetsComponent = { DefaultConfigsetsComponent(httpClient = httpClient) }, environmentComponent = { childContext -> DefaultEnvironmentComponent( componentContext = childContext, @@ -116,7 +110,7 @@ class DefaultMainComponent internal constructor( "configsets" -> Configuration.Configsets "environment" -> Configuration.Environment "logging" -> Configuration.Logging - else -> Configuration.Environment + else -> Configuration.Configsets }, ) @@ -138,7 +132,7 @@ class DefaultMainComponent internal constructor( // Configuration.Security -> // NavigationComponent.Child.Security(securityComponent(componentContext)) - Configuration.Configsets -> Child.Configsets(configsetsComponent(componentContext)) + Configuration.Configsets -> Child.Configsets(configsetsComponent()) // TODO Uncomment once Collections available // Configuration.Collections -> diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/navigation/TabNavigationComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/navigation/TabNavigationComponent.kt index 9b07b5295eb1..7c5b7d4f57b0 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/navigation/TabNavigationComponent.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/navigation/TabNavigationComponent.kt @@ -21,12 +21,9 @@ import com.arkivanov.decompose.router.slot.ChildSlot import com.arkivanov.decompose.value.Value import kotlinx.serialization.Serializable -interface TabNavigationComponent, C : Any> { +interface TabNavigationComponent { - val tabSlot: Value, C>> + val tabSlot: Value> fun onNavigate(tab: T) - - @Serializable - data class Configuration>(val tab: T) } diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/navigation/integration/DefaultTabNavigationComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/navigation/integration/DefaultTabNavigationComponent.kt deleted file mode 100644 index e7fffe2076e9..000000000000 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/navigation/integration/DefaultTabNavigationComponent.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.ui.components.navigation.integration - -import com.arkivanov.decompose.router.slot.ChildSlot -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.arkivanov.decompose.router.slot.activate -import com.arkivanov.decompose.router.slot.childSlot -import com.arkivanov.decompose.value.Value -import com.arkivanov.essenty.lifecycle.doOnCreate -import com.arkivanov.essenty.lifecycle.doOnResume -import kotlinx.serialization.KSerializer -import org.apache.solr.ui.components.navigation.TabNavigationComponent -import org.apache.solr.ui.components.navigation.TabNavigationComponent.Configuration -import org.apache.solr.ui.utils.AppComponentContext - -class DefaultTabNavigationComponent, C : Any>( - componentContext: AppComponentContext, - initialTab: T, - tabSerializer: KSerializer, - childFactory: (Configuration, AppComponentContext) -> C, -) : TabNavigationComponent, - AppComponentContext by componentContext { - - private val navigation = SlotNavigation>() - - override val tabSlot: Value, C>> = childSlot( - source = navigation, - serializer = Configuration.serializer(tabSerializer), - handleBackButton = true, - childFactory = childFactory, - ) - - init { - navigation.activate(configuration = Configuration(initialTab)) - } - - override fun onNavigate(tab: T) = navigation.activate(configuration = Configuration(tab)) -} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/Configset.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/Configset.kt index 921f3ca9c165..e706cdb0426f 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/Configset.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/Configset.kt @@ -17,6 +17,8 @@ package org.apache.solr.ui.domain +import kotlinx.serialization.Serializable + /** * Configset entity that represents a basic configset. This data class does only hold the basic * information of a configset. @@ -26,6 +28,7 @@ package org.apache.solr.ui.domain * * @property name The name and unique identifier of the configset. */ +@Serializable data class Configset( val name: String = "", ) diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/PickedFile.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/PickedFile.kt new file mode 100644 index 000000000000..cdae58963bcc --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/PickedFile.kt @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.domain + +data class PickedFile( + val name: String, + val bytes: ByteArray, + val extension: String? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as PickedFile + + if (name != other.name) return false + if (!bytes.contentEquals(other.bytes)) return false + if (extension != other.extension) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + bytes.contentHashCode() + result = 31 * result + (extension?.hashCode() ?: 0) + return result + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/AppDispatchers.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/AppDispatchers.kt new file mode 100644 index 000000000000..2c81218ba81a --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/AppDispatchers.kt @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.utils + +import kotlinx.coroutines.CoroutineDispatcher + +/** + * App dispatchers used for coroutines. + */ +interface AppDispatchers { + + /** + * Coroutine dispatcher used for IO operations. + */ + val io: CoroutineDispatcher +} + +/** + * Factory function to provide platform-specific dispatchers. + */ +expect fun platformDispatchers(): AppDispatchers diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/FileUtils.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/FileUtils.kt new file mode 100644 index 000000000000..889c375f55e9 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/FileUtils.kt @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.utils + +import org.apache.solr.ui.domain.PickedFile + +/** + * @param extensions e.g. listOf("zip", "json"). Empty = any. + */ +expect suspend fun pickFile(extensions: List = emptyList()): PickedFile? diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/HttpClientUtils.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/HttpClientUtils.kt index c7883b9f717b..ca991937d4a7 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/HttpClientUtils.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/HttpClientUtils.kt @@ -26,7 +26,9 @@ import io.ktor.client.plugins.auth.providers.basic import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest +import io.ktor.http.ContentType import io.ktor.http.Url +import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import org.apache.solr.ui.domain.AuthOption @@ -41,6 +43,7 @@ fun getDefaultClient( ) = HttpClient { defaultRequest { url(url.toString()) + contentType(ContentType.Application.Json) } install(ContentNegotiation) { diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/Validators.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/Validators.kt new file mode 100644 index 000000000000..e3959d977bbd --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/Validators.kt @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.utils + +/** + * Regex that configset names must match in order to be valid. + * + * Note that this regex is only used in the UI and does not represent the actual allowed + * name regex for configsets, as this has not yet been defined. + */ +internal val configsetNameRegex = "[a-zA-Z0-9._-]+".toRegex() + +/** + * The maximum length a configset name may have. + * + * Note that this is a maximum length used in the UI only and does not represent the actual allowed + * length for configset names. + */ +internal const val MAX_CONFIGSET_NAME_LENGTH = 256 diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/components/SolrOutlinedTextField.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/components/SolrOutlinedTextField.kt new file mode 100644 index 000000000000..9f22b99d9448 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/components/SolrOutlinedTextField.kt @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.views.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextFieldColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation + +@Composable +fun SolrOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource? = null, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors().copy( + unfocusedContainerColor = MaterialTheme.colorScheme.background, + focusedContainerColor = MaterialTheme.colorScheme.background, + ), +) = OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, +) diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsContent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsContent.kt index 24faf766cc7b..88449d9be04c 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsContent.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsContent.kt @@ -17,21 +17,23 @@ package org.apache.solr.ui.views.configsets import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import org.apache.solr.ui.components.configsets.ConfigsetsComponent -import org.apache.solr.ui.components.configsets.ConfigsetsComponent.Child +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import org.apache.solr.ui.components.configsets.di.ConfigsetsComponent +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsScene +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsTab import org.apache.solr.ui.generated.resources.Res import org.apache.solr.ui.generated.resources.configsets_index_query import org.apache.solr.ui.generated.resources.configsets_request_handlers @@ -41,11 +43,10 @@ import org.apache.solr.ui.generated.resources.files import org.apache.solr.ui.generated.resources.overview import org.apache.solr.ui.generated.resources.schema import org.apache.solr.ui.views.navigation.NavigationTabs -import org.apache.solr.ui.views.navigation.configsets.ConfigsetsTab @OptIn(ExperimentalLayoutApi::class) @Composable -fun ConfigsetsContent( +fun ConfigsetsScene( component: ConfigsetsComponent, modifier: Modifier = Modifier, ) = FlowRow( @@ -53,35 +54,33 @@ fun ConfigsetsContent( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - val model by component.model.collectAsState() - val slot by component.tabSlot.subscribeAsState() - val currentChild = slot.child + val viewModel = viewModel { component.createConfigsetsRouteViewModel() } + val model by viewModel.uiState.collectAsState() + + val sharedConfigsetsViewModel = viewModel { component.createConfigsetsViewModel() } Column(Modifier.fillMaxSize()) { NavigationTabs( - component = component, - entries = ConfigsetsTab.entries, + tabs = ConfigsetsTab.entries, + selectedTab = model.selectedTab, + onSelectTab = viewModel::selectTab, mapper = ::tabLabelRes, modifier = Modifier.padding(1.dp), ) - ConfigsetsDropdown( - selectedConfigSet = model.selectedConfigset, - selectConfigset = component::onSelectConfigset, - availableConfigsets = model.configsets, - ) - Box( - Modifier - .fillMaxSize() - .padding(16.dp), - ) { - currentChild?.let { - when (val child = it.instance) { - is Child.Overview -> ConfigsetsOverviewContent(component = child.component) - is Child.Placeholder -> Text(text = child.tabName) + NavDisplay( + backStack = viewModel.backStack, + entryDecorators = listOf(rememberViewModelStoreNavEntryDecorator()), + entryProvider = entryProvider { + entry { + ConfigsetsOverviewContent( + component = component.createConfigsetsOverviewComponent(), + configsetsViewModel = sharedConfigsetsViewModel, + modifier = Modifier.fillMaxSize().padding(16.dp), + ) } - } - } + }, + ) } } diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdown.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdown.kt index 7ab6d51eae8f..4f4a032e3862 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdown.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdown.kt @@ -26,12 +26,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsViewModel import org.apache.solr.ui.domain.Configset import org.apache.solr.ui.generated.resources.Res import org.apache.solr.ui.generated.resources.cd_clear_field @@ -44,14 +46,32 @@ import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConfigsetsDropdown( - selectedConfigSet: String, + viewModel: ConfigsetsViewModel, + modifier: Modifier = Modifier, + enableReset: Boolean = false, +) { + val model by viewModel.uiState.collectAsState() + + ConfigsetsDropdown( + configsets = model.configsets, + selectedConfigset = model.selectedConfigset, + selectConfigset = viewModel::selectConfigset, + modifier = modifier, + enableReset = enableReset, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigsetsDropdown( + selectedConfigset: String?, + configsets: List, selectConfigset: (String) -> Unit, - availableConfigsets: List, modifier: Modifier = Modifier, enableReset: Boolean = false, ) { var expanded by remember { mutableStateOf(false) } - val enabled = availableConfigsets.isNotEmpty() + val enabled = configsets.isNotEmpty() ExposedDropdownMenuBox( expanded = expanded, @@ -59,14 +79,14 @@ fun ConfigsetsDropdown( modifier = modifier, ) { OutlinedTextField( - value = selectedConfigSet, + value = selectedConfigset ?: "", onValueChange = {}, readOnly = true, enabled = enabled, singleLine = true, label = { Text(stringResource(Res.string.nav_configsets)) }, placeholder = { - if (availableConfigsets.isEmpty()) { + if (configsets.isEmpty()) { Text( modifier = Modifier.testTag("no_configsets_placeholder"), text = stringResource(Res.string.no_configsets), @@ -74,7 +94,7 @@ fun ConfigsetsDropdown( } }, trailingIcon = { - if (enableReset && selectedConfigSet.isNotEmpty()) { + if (enableReset && !selectedConfigset.isNullOrEmpty()) { IconButton(onClick = { selectConfigset("") }) { Icon( painter = painterResource(Res.drawable.close), @@ -97,7 +117,7 @@ fun ConfigsetsDropdown( expanded = expanded, onDismissRequest = { expanded = false }, ) { - availableConfigsets.forEach { configset -> + configsets.forEach { configset -> DropdownMenuItem( modifier = Modifier.testTag(tag = configset.name), text = { Text(configset.name) }, diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsOverviewContent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsOverviewContent.kt index 623beda8bda7..d0ecf4a2729d 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsOverviewContent.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsOverviewContent.kt @@ -16,12 +16,148 @@ */ package org.apache.solr.ui.views.configsets +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.apache.solr.ui.components.configsets.overview.ConfigsetsOverviewComponent +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.DialogSceneStrategy +import androidx.navigation3.scene.DialogSceneStrategy.Companion.dialog +import androidx.navigation3.scene.SinglePaneSceneStrategy +import androidx.navigation3.ui.NavDisplay +import kotlin.collections.removeLastOrNull +import org.apache.solr.ui.components.configsets.di.ConfigsetsOverviewComponent +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetEvent +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsOverviewEntry +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsOverviewEntry.ConfigsetsOverviewDialog.CreateConfigsetDialog +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsOverviewEntry.ConfigsetsOverviewDialog.ImportConfigsetDialog +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsViewModel +import org.apache.solr.ui.domain.Configset +import org.apache.solr.ui.generated.resources.Res +import org.apache.solr.ui.generated.resources.action_create_configset +import org.apache.solr.ui.generated.resources.action_edit_solrconfig +import org.apache.solr.ui.generated.resources.add +import org.apache.solr.ui.generated.resources.edit +import org.apache.solr.ui.views.components.SolrTextButton +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource @Composable -fun ConfigsetsOverviewContent(component: ConfigsetsOverviewComponent, modifier: Modifier = Modifier) { - Text("Overview section") +fun ConfigsetsOverviewContent( + component: ConfigsetsOverviewComponent, + configsetsViewModel: ConfigsetsViewModel, + modifier: Modifier = Modifier, +) = Column(modifier) { + val viewModel = viewModel { component.createConfigsetsOverviewViewModel() } + + val configsetsModel by configsetsViewModel.uiState.collectAsState() + + val onConfigsetCreated: (Configset) -> Unit = { + viewModel.closeDialog() + configsetsViewModel.reloadConfigsets() + configsetsViewModel.selectConfigset(it.name) + } + + val createConfigsetEventCollector: (CreateConfigsetEvent) -> Unit = { event -> + when (event) { + is CreateConfigsetEvent.ConfigsetCreated -> onConfigsetCreated(event.configset) + + is CreateConfigsetEvent.ConfigsetCreationAborted -> viewModel.closeDialog() + + is CreateConfigsetEvent.ConfigsetCreateToggleInputForm -> + if (event.useFileInput) { + viewModel.openImportConfigsetDialog() + } else { + viewModel.openCreateConfigsetDialog() + } + + else -> Unit + } + } + + NavDisplay( + modifier = Modifier.horizontalScroll(rememberScrollState()), + backStack = viewModel.backStack, + sceneStrategies = listOf(DialogSceneStrategy(), SinglePaneSceneStrategy()), + onBack = { + if (viewModel.backStack.last() is ConfigsetsOverviewEntry.ConfigsetsOverviewDialog) { + viewModel.backStack.removeLastOrNull() + } + }, + entryDecorators = listOf(rememberViewModelStoreNavEntryDecorator()), + entryProvider = entryProvider { + entry { + OverviewContent( + viewModel = configsetsViewModel, + selectedConfigset = configsetsModel.selectedConfigset, + onOpenCreateConfigsetDialog = viewModel::openCreateConfigsetDialog, + onEditSolrConfig = viewModel::editSolrConfig, + ) + } + entry(metadata = dialog()) { + val createViewModel = viewModel { component.createCreateConfigsetViewModel() } + + LaunchedEffect(createViewModel) { + createViewModel.events.collect(collector = createConfigsetEventCollector) + } + + CreateConfigsetDialog(viewModel = createViewModel) + } + entry(metadata = dialog()) { + val importViewModel = viewModel { component.createImportConfigsetViewModel() } + + LaunchedEffect(importViewModel) { + importViewModel.events.collect(collector = createConfigsetEventCollector) + } + + ImportConfigsetDialog(viewModel = importViewModel) + } + }, + ) +} + +@Composable +private fun OverviewContent( + viewModel: ConfigsetsViewModel, + onOpenCreateConfigsetDialog: () -> Unit, + onEditSolrConfig: (String) -> Unit, + modifier: Modifier = Modifier, + selectedConfigset: String? = null, +) = Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, +) { + ConfigsetsDropdown( + viewModel = viewModel, + modifier = Modifier.widthIn(min = 128.dp, max = 256.dp), + ) + + SolrTextButton(onClick = onOpenCreateConfigsetDialog) { + Icon(painter = painterResource(Res.drawable.add), contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(Res.string.action_create_configset)) + } + if (!selectedConfigset.isNullOrBlank()) { + SolrTextButton(onClick = { onEditSolrConfig(selectedConfigset) }) { + Icon(painter = painterResource(Res.drawable.edit), contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(Res.string.action_edit_solrconfig)) + } + } } diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/CreateConfigsetDialog.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/CreateConfigsetDialog.kt new file mode 100644 index 000000000000..f6a6426baa64 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/CreateConfigsetDialog.kt @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.views.configsets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import org.apache.solr.ui.components.configsets.viewmodel.CreateConfigsetViewModel +import org.apache.solr.ui.generated.resources.Res +import org.apache.solr.ui.generated.resources.action_cancel +import org.apache.solr.ui.generated.resources.action_create_configset +import org.apache.solr.ui.generated.resources.action_import_configset +import org.apache.solr.ui.generated.resources.label_configset_name +import org.apache.solr.ui.generated.resources.title_create_configset +import org.apache.solr.ui.views.components.SolrButton +import org.apache.solr.ui.views.components.SolrCard +import org.apache.solr.ui.views.components.SolrTextButton +import org.jetbrains.compose.resources.stringResource + +@Composable +fun CreateConfigsetDialog( + viewModel: CreateConfigsetViewModel, + modifier: Modifier = Modifier, +) = Dialog(onDismissRequest = viewModel::abortCreation) { + val state by viewModel.uiState.collectAsState() + val configsetsState by viewModel.configsetsUiState.collectAsState() + + SolrCard( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(Res.string.title_create_configset), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + modifier = Modifier.weight(1f) + .height(64.dp) + .testTag("create_configset_name_field"), + value = state.configsetName, + onValueChange = viewModel::changeConfigsetName, + label = { Text(stringResource(Res.string.label_configset_name)) }, + singleLine = true, + ) + ConfigsetsDropdown( + modifier = Modifier.weight(1f), + selectedConfigset = configsetsState.selectedConfigset, + selectConfigset = viewModel::changeBaseConfigset, + configsets = configsetsState.configsets, + enableReset = true, + ) + } + + Row( + modifier = Modifier.padding(top = 16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Dialog actions + SolrTextButton(onClick = viewModel::toggleInput) { + Text(stringResource(Res.string.action_import_configset)) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SolrTextButton(onClick = viewModel::abortCreation) { + Text(stringResource(Res.string.action_cancel)) + } + SolrButton( + modifier = Modifier.testTag("configset_create_button"), + onClick = viewModel::createConfigset, + enabled = state.configsetName.isNotBlank(), + ) { + Text(stringResource(Res.string.action_create_configset)) + } + } + } + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ImportConfigsetDialog.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ImportConfigsetDialog.kt new file mode 100644 index 000000000000..696a073889c4 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/configsets/ImportConfigsetDialog.kt @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.views.configsets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import org.apache.solr.ui.components.configsets.viewmodel.ImportConfigsetViewModel +import org.apache.solr.ui.generated.resources.Res +import org.apache.solr.ui.generated.resources.action_cancel +import org.apache.solr.ui.generated.resources.action_create_configset +import org.apache.solr.ui.generated.resources.action_import_configset +import org.apache.solr.ui.generated.resources.label_configset_name +import org.apache.solr.ui.generated.resources.label_select_configset_file +import org.apache.solr.ui.generated.resources.title_import_configset +import org.apache.solr.ui.views.components.SolrButton +import org.apache.solr.ui.views.components.SolrCard +import org.apache.solr.ui.views.components.SolrTextButton +import org.apache.solr.ui.views.files.FileSelector +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ImportConfigsetDialog( + viewModel: ImportConfigsetViewModel, + modifier: Modifier = Modifier, +) = Dialog(onDismissRequest = viewModel::abortImport) { + val model by viewModel.uiState.collectAsState() + val fileSelectorModel by viewModel.fileSelectorUiState.collectAsState() + + SolrCard( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(Res.string.title_import_configset), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FileSelector( + modifier = Modifier.weight(1f).testTag("import_configset_file_field"), + file = fileSelectorModel.file, + onSelectFile = viewModel::selectFile, + onClearSelection = viewModel::clearFile, + label = stringResource(Res.string.label_select_configset_file), + selectFileText = stringResource(Res.string.label_select_configset_file), + ) + OutlinedTextField( + modifier = Modifier.weight(1f).testTag("import_configset_name_field"), + value = model.configsetName, + onValueChange = viewModel::changeConfigsetName, + label = { Text(stringResource(Res.string.label_configset_name)) }, + singleLine = true, + ) + } + + val canImportConfigset = fileSelectorModel.file != null && + model.configsetName.isNotBlank() && + !model.isLoading + + Row( + modifier = Modifier.padding(top = 16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Dialog actions + SolrTextButton(onClick = viewModel::toggleInput) { + Text(stringResource(Res.string.action_create_configset)) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SolrTextButton(onClick = viewModel::abortImport) { + Text(stringResource(Res.string.action_cancel)) + } + SolrButton( + modifier = Modifier.testTag("configset_import_button"), + onClick = viewModel::importConfigset, + enabled = canImportConfigset, + ) { + Text(stringResource(Res.string.action_import_configset)) + } + } + } + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/files/FileSelector.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/files/FileSelector.kt new file mode 100644 index 000000000000..4f18f09cfca6 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/files/FileSelector.kt @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.views.files + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import org.apache.solr.ui.domain.PickedFile +import org.apache.solr.ui.generated.resources.Res +import org.apache.solr.ui.generated.resources.cd_clear_field +import org.apache.solr.ui.generated.resources.close +import org.apache.solr.ui.generated.resources.upload +import org.apache.solr.ui.views.components.SolrTextButton +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun FileSelector( + file: PickedFile?, + onSelectFile: () -> Unit, + onClearSelection: () -> Unit, + label: String, + selectFileText: String, + modifier: Modifier = Modifier, +) { + if (file != null) { + OutlinedTextField( + modifier = modifier, + value = file.name, + label = { Text(label) }, + onValueChange = {}, + readOnly = true, + singleLine = true, + leadingIcon = { FileTypeIcon(file.extension ?: "") }, + trailingIcon = { + IconButton( + modifier = Modifier.testTag("file_selector_clear_button"), + onClick = onClearSelection, + ) { + Icon( + painter = painterResource(Res.drawable.close), + contentDescription = stringResource(Res.string.cd_clear_field), + ) + } + }, + ) + } else { + SolrTextButton(modifier = modifier, onClick = onSelectFile) { + Icon(painter = painterResource(Res.drawable.upload), contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = selectFileText) + } + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/files/FileTypeIcon.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/files/FileTypeIcon.kt new file mode 100644 index 000000000000..f394702842f9 --- /dev/null +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/files/FileTypeIcon.kt @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.views.files + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.apache.solr.ui.generated.resources.Res +import org.apache.solr.ui.generated.resources.draft +import org.apache.solr.ui.generated.resources.folder_zip +import org.jetbrains.compose.resources.painterResource + +@Composable +fun FileTypeIcon( + fileType: String, + modifier: Modifier = Modifier, +) = Icon( + modifier = modifier, + painter = painterResource( + when (fileType) { + "zip", "rar", "gz", "7z" -> Res.drawable.folder_zip + else -> Res.drawable.draft + }, + ), + contentDescription = null, +) diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/main/MainContent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/main/MainContent.kt index 3fe2ccc8765f..b6f88e3b7cea 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/main/MainContent.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/main/MainContent.kt @@ -33,7 +33,7 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.apache.solr.ui.components.main.MainComponent import org.apache.solr.ui.components.main.integration.asMainMenu import org.apache.solr.ui.views.cluster.ClusterContent -import org.apache.solr.ui.views.configsets.ConfigsetsContent +import org.apache.solr.ui.views.configsets.ConfigsetsScene import org.apache.solr.ui.views.environment.EnvironmentContent import org.apache.solr.ui.views.logging.LoggingContent import org.apache.solr.ui.views.navigation.NavigationSideBar @@ -71,7 +71,7 @@ fun MainContent( .verticalScroll(scrollState), ) - is MainComponent.Child.Configsets -> ConfigsetsContent( + is MainComponent.Child.Configsets -> ConfigsetsScene( component = child.component, modifier = Modifier.fillMaxWidth() .verticalScroll(scrollState), diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/NavigationTabs.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/NavigationTabs.kt index 798e17949394..18ab7a76e49e 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/NavigationTabs.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/NavigationTabs.kt @@ -17,7 +17,7 @@ package org.apache.solr.ui.views.navigation -import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,10 +49,10 @@ fun , C : Any> NavigationTabs( ) { val slot by component.tabSlot.subscribeAsState() - val currentTab = slot.child?.configuration?.tab + val currentTab = slot.child?.configuration val currentTabIndex = currentTab?.ordinal ?: 0 - ScrollableTabRow( + PrimaryScrollableTabRow( modifier = modifier, selectedTabIndex = currentTabIndex, edgePadding = 16.dp, @@ -74,3 +74,36 @@ fun , C : Any> NavigationTabs( } } } + +@Composable +fun > NavigationTabs( + tabs: EnumEntries, + selectedTab: T, + onSelectTab: (T) -> Unit, + mapper: (T) -> StringResource, + modifier: Modifier = Modifier, +) { + val currentTabIndex = selectedTab.ordinal ?: 0 + + PrimaryScrollableTabRow( + modifier = modifier, + selectedTabIndex = currentTabIndex, + edgePadding = 16.dp, + ) { + tabs.forEach { tab -> + val selected = selectedTab == tab + + Tab( + selected = selected, + onClick = { onSelectTab(tab) }, + text = { + Text( + text = stringResource(mapper(tab)), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/start/StartContent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/start/StartContent.kt index 8c593171a43b..eeb2c87d7350 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/start/StartContent.kt +++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/start/StartContent.kt @@ -48,6 +48,7 @@ import org.apache.solr.ui.utils.defaultSolrUrl import org.apache.solr.ui.views.components.SolrButton import org.apache.solr.ui.views.components.SolrCard import org.apache.solr.ui.views.components.SolrLinearProgressIndicator +import org.apache.solr.ui.views.components.SolrOutlinedTextField import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -96,7 +97,7 @@ fun StartContent( style = MaterialTheme.typography.bodyMedium, ) - OutlinedTextField( + SolrOutlinedTextField( modifier = Modifier.fillMaxWidth().testTag("solr_url_input"), value = model.url, singleLine = true, diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/TestDispatchers.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/TestDispatchers.kt new file mode 100644 index 000000000000..6cef4697c773 --- /dev/null +++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/TestDispatchers.kt @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui + +import kotlinx.coroutines.CoroutineDispatcher +import org.apache.solr.ui.utils.AppDispatchers + +class TestDispatchers(override val io: CoroutineDispatcher) : AppDispatchers diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/configsets/DefaultConfigsetsComponentIntegrationTest.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/configsets/DefaultConfigsetsComponentIntegrationTest.kt deleted file mode 100644 index c6581f4d020e..000000000000 --- a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/configsets/DefaultConfigsetsComponentIntegrationTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.ui.components.configsets - -import com.arkivanov.essenty.lifecycle.LifecycleRegistry -import com.arkivanov.essenty.lifecycle.resume -import com.arkivanov.mvikotlin.core.store.StoreFactory -import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockRequestHandleScope -import io.ktor.client.engine.mock.MockRequestHandler -import io.ktor.client.engine.mock.respond -import io.ktor.client.request.HttpRequestData -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import org.apache.solr.ui.TestAppComponentContext -import org.apache.solr.ui.components.configsets.data.ListConfigsets -import org.apache.solr.ui.components.configsets.integration.DefaultConfigsetsComponent -import org.apache.solr.ui.components.start.integration.DefaultStartComponent -import org.apache.solr.ui.createMockEngine -import org.apache.solr.ui.domain.Configset -import org.apache.solr.ui.testHttpClient -import org.apache.solr.ui.utils.AppComponentContext - -@OptIn(ExperimentalCoroutinesApi::class) -class DefaultConfigsetsComponentIntegrationTest { - - private val configset1 = "_default" - private val configset2 = "techproducts" - private val configset3 = "getting_started" - private val configSets = listOf(configset1, configset2, configset3) - - /** - * Request handler that returns a list of configsets - */ - private val configsetsResponseHandler: MockRequestHandler = { scope: MockRequestHandleScope, data: HttpRequestData -> - val content = ListConfigsets(configSets) - scope.respond( - content = Json.encodeToString(value = content), - status = HttpStatusCode.OK, - headers = headersOf( - name = HttpHeaders.ContentType, - value = ContentType.Application.Json.toString(), - ), - ) - } - - @Test - fun `GIVEN configsets WHEN initialized THEN configsets fetched`() = runTest { - val engine = createMockEngine(configsetsResponseHandler) - val component = createComponent(httpClient = testHttpClient(engine)) - advanceUntilIdle() - - component.model.value.configsets.forEach { configset -> - assertContains( - iterable = configSets, - element = configset.name, - ) - } - } - - @Test - fun `GIVEN configsets WHEN configsets fetched THEN configsets sorted`() = runTest { - val engine = createMockEngine(configsetsResponseHandler) - val component = createComponent(httpClient = testHttpClient(engine)) - advanceUntilIdle() - - assertContentEquals( - expected = configSets.sorted(), - actual = component.model.value.configsets.map(Configset::name), - ) - } - - @Test - fun `GIVEN configsets WHEN configset selected THEN selectedConfigset updated`() = runTest { - val engine = createMockEngine(configsetsResponseHandler) - val component = createComponent(httpClient = testHttpClient(engine)) - advanceUntilIdle() - - component.onSelectConfigset(name = configset2) - advanceUntilIdle() - - assertEquals( - expected = configset2, - actual = component.model.value.selectedConfigset, - ) - } - - /** - * Helper function for creating an instance of the [DefaultStartComponent]. - */ - private fun TestScope.createComponent( - componentContext: AppComponentContext = TestAppComponentContext(scheduler = testScheduler), - storeFactory: StoreFactory = DefaultStoreFactory(), - httpClient: HttpClient = HttpClient(), - ): ConfigsetsComponent { - val lifecycle = LifecycleRegistry() - - val component = DefaultConfigsetsComponent( - componentContext = componentContext, - storeFactory = storeFactory, - httpClient = httpClient, - ) - - lifecycle.resume() - return component - } -} diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsStateHolderTest.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsStateHolderTest.kt new file mode 100644 index 000000000000..190fe949f1d7 --- /dev/null +++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/configsets/viewmodel/ConfigsetsStateHolderTest.kt @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.components.configsets.viewmodel + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.apache.solr.ui.TestDispatchers +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.domain.Configset + +@OptIn(ExperimentalCoroutinesApi::class) +class ConfigsetsStateHolderTest { + + private val configset1 = "_default" + private val configset2 = "techproducts" + private val configset3 = "getting_started" + private val configsets = listOf(configset1, configset2, configset3) + + @Test + fun `GIVEN configsets WHEN initialized THEN configsets fetched`() = runTest { + val stateHolder = ConfigsetsStateHolder( + scope = this, + loadConfigsetsUseCase = getLoadConfigsetsUseCase(configsets), + dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler = testScheduler)), + ) + advanceUntilIdle() + + stateHolder.uiState.value.configsets.forEach { configset -> + assertContains( + iterable = configsets, + element = configset.name, + ) + } + } + + @Test + fun `GIVEN configsets WHEN configsets fetched THEN configsets sorted`() = runTest { + val stateHolder = ConfigsetsStateHolder( + scope = this, + loadConfigsetsUseCase = getLoadConfigsetsUseCase(configsets), + dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler = testScheduler)), + ) + advanceUntilIdle() + + assertContentEquals( + expected = configsets.sorted(), + actual = stateHolder.uiState.value.configsets.map(Configset::name), + ) + } + + @Test + fun `GIVEN configsets WHEN configset selected THEN selectedConfigset updated`() = runTest { + val stateHolder = ConfigsetsStateHolder( + scope = this, + loadConfigsetsUseCase = getLoadConfigsetsUseCase(configsets), + dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler = testScheduler)), + ) + advanceUntilIdle() + + stateHolder.selectConfigset(configset = configset2) + advanceUntilIdle() + + assertEquals( + expected = configset2, + actual = stateHolder.uiState.value.selectedConfigset, + ) + } + + private fun getLoadConfigsetsUseCase(configsets: List) = object : LoadConfigsetsUseCase { + override suspend fun invoke(): Result> = Result.success(configsets.map { Configset(name = it) }) + } +} diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/navigation/DefaultTabNavigationComponentIntegrationTest.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/navigation/DefaultTabNavigationComponentIntegrationTest.kt deleted file mode 100644 index 43c0a46b6ce6..000000000000 --- a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/navigation/DefaultTabNavigationComponentIntegrationTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.ui.components.navigation - -import com.arkivanov.decompose.router.slot.child -import com.arkivanov.essenty.lifecycle.Lifecycle -import com.arkivanov.essenty.lifecycle.LifecycleRegistry -import com.arkivanov.essenty.lifecycle.create -import com.arkivanov.essenty.lifecycle.resume -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.test.fail -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.serializer -import org.apache.solr.ui.TestAppComponentContext -import org.apache.solr.ui.components.navigation.TabNavigationComponent.Configuration -import org.apache.solr.ui.components.navigation.integration.DefaultTabNavigationComponent -import org.apache.solr.ui.components.start.integration.DefaultStartComponent -import org.apache.solr.ui.utils.AppComponentContext - -@OptIn(ExperimentalCoroutinesApi::class) -class DefaultTabNavigationComponentIntegrationTest { - - @Serializable - private enum class TestNavigationTab { - Tab1, - Tab2, - } - - private data class TabChild( - val configuration: Configuration, - val context: AppComponentContext? = null, - ) - - @Test - fun `GIVEN initial tab WHEN initialized THEN childFactory called with initial tab`() = runTest { - val initialTab = TestNavigationTab.Tab1 - var called = false - val component = createComponent( - initialTab = initialTab, - tabSerializer = TestNavigationTab.serializer(), - childFactory = { configuration, context -> - called = true - TabChild(configuration, context) - }, - ) - - advanceUntilIdle() - assertNotNull(actual = component.tabSlot.child?.instance) - assertEquals( - expected = TestNavigationTab.Tab1, - actual = component.tabSlot.child?.configuration?.tab, - ) - assertTrue(actual = called, message = "child factory never called") - } - - @Test - fun `GIVEN a selection WHEN navigate to other tab THEN other tab selected`() = runTest { - val expectedTab = TestNavigationTab.Tab2 - val component = createComponent( - initialTab = TestNavigationTab.Tab1, - tabSerializer = TestNavigationTab.serializer(), - childFactory = { configuration, context -> TabChild(configuration, context) }, - ) - advanceUntilIdle() - - component.onNavigate(expectedTab) - advanceUntilIdle() - - assertEquals( - expected = expectedTab, - actual = component.tabSlot.child?.configuration?.tab, - ) - } - - @Test - fun `GIVEN a selection WHEN navigate to same tab THEN childFactory not called again`() = runTest { - val initialTab = TestNavigationTab.Tab1 - var called = false - val component = createComponent( - initialTab = initialTab, - tabSerializer = TestNavigationTab.serializer(), - childFactory = { configuration, context -> - if (!called) { - called = true - } else { - fail("Should not be called twice") - } - TabChild(configuration, context) - }, - ) - - component.onNavigate(initialTab) - advanceUntilIdle() - } - - /** - * Helper function for creating an instance of the [DefaultStartComponent]. - */ - private fun , C : Any> TestScope.createComponent( - componentContext: AppComponentContext = TestAppComponentContext(scheduler = testScheduler), - lifecycle: LifecycleRegistry = LifecycleRegistry(), - initialTab: T, - tabSerializer: KSerializer, - childFactory: (Configuration, AppComponentContext) -> C, - ): TabNavigationComponent { - val component = DefaultTabNavigationComponent( - componentContext = componentContext, - tabSerializer = tabSerializer, - initialTab = initialTab, - childFactory = childFactory, - ) - - lifecycle.resume() - return component - } -} diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsContentTest.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsContentTest.kt deleted file mode 100644 index 821f08a5ad20..000000000000 --- a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsContentTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.solr.ui.views.configsets - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest -import com.arkivanov.decompose.Child -import com.arkivanov.decompose.router.slot.ChildSlot -import com.arkivanov.decompose.value.MutableValue -import com.arkivanov.decompose.value.Value -import kotlin.test.Ignore -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import org.apache.solr.ui.components.configsets.ConfigsetsComponent -import org.apache.solr.ui.components.configsets.ConfigsetsComponent.Model -import org.apache.solr.ui.components.configsets.overview.ConfigsetsOverviewComponent -import org.apache.solr.ui.components.navigation.TabNavigationComponent.Configuration -import org.apache.solr.ui.domain.Configset -import org.apache.solr.ui.views.navigation.configsets.ConfigsetsTab - -@OptIn(ExperimentalTestApi::class) -class ConfigsetsContentTest { - - @Test - @Ignore // See why the placeholder text is not shown - fun `GIVEN no configsets THEN no_configsets_placeholder is shown`() = runComposeUiTest { - val component = TestConfigsetsComponent() - - setContent { ConfigsetsContent(component = component) } - - // Placeholder text from the TextField - onNodeWithTag(testTag = "no_configsets_placeholder").assertExists() - } - - @Test - fun `GIVEN configsets WHEN a configset selected THEN onSelectConfigset called with configset`() = runComposeUiTest { - val selectedConfigset = "gettingstarted" - val expectedConfigsetSelection = "techproducts" - val component = TestConfigsetsComponent( - model = Model( - configsets = listOf(selectedConfigset, expectedConfigsetSelection) - .map { Configset(it) }, - selectedConfigset = selectedConfigset, - ), - ) - - setContent { ConfigsetsContent(component = component) } - - // Expand menu and select expected configset - onNodeWithTag(testTag = "configsets_dropdown").performClick() - onNodeWithTag(testTag = expectedConfigsetSelection).performClick() - - waitForIdle() - assertEquals( - expected = expectedConfigsetSelection, - actual = component.onSelectConfigset, - ) - } -} - -class TestConfigsetsComponent( - model: Model = Model(), -) : ConfigsetsComponent { - - var onSelectConfigset: String? = model.selectedConfigset - override val model: StateFlow = MutableStateFlow(model) - - private val overviewChild = - ConfigsetsComponent.Child.Overview(object : ConfigsetsOverviewComponent {}) - - override val tabSlot: Value, ConfigsetsComponent.Child>> = MutableValue( - ChildSlot( - Child.Created( - configuration = Configuration(tab = ConfigsetsTab.Overview), - instance = overviewChild, - ), - ), - ) - - override fun onNavigate(tab: ConfigsetsTab) { - // Tested in TabNavigationTest (no need to test here) - } - - override fun onSelectConfigset(name: String) { - onSelectConfigset = name - } -} diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdownMenuTest.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdownTest.kt similarity index 84% rename from solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdownMenuTest.kt rename to solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdownTest.kt index 81e1746e3adb..ca275943c22d 100644 --- a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdownMenuTest.kt +++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ConfigsetsDropdownTest.kt @@ -24,19 +24,18 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runComposeUiTest import kotlin.test.Test -import org.apache.solr.ui.components.configsets.ConfigsetsComponent.Model import org.apache.solr.ui.domain.Configset @OptIn(ExperimentalTestApi::class) -class ConfigsetsDropdownMenuTest { +class ConfigsetsDropdownTest { @Test fun `GIVEN empty availableConfigsets WHEN dropdown clicked THEN not expanded`() = runComposeUiTest { setContent { ConfigsetsDropdown( selectConfigset = {}, - availableConfigsets = emptyList(), - selectedConfigSet = "", + configsets = emptyList(), + selectedConfigset = "", ) } @@ -50,8 +49,8 @@ class ConfigsetsDropdownMenuTest { setContent { ConfigsetsDropdown( selectConfigset = {}, - availableConfigsets = emptyList(), - selectedConfigSet = "", + configsets = emptyList(), + selectedConfigset = "", ) } @@ -62,15 +61,15 @@ class ConfigsetsDropdownMenuTest { @Test fun `GIVEN configsets WHEN clicking dropdown THEN dropdown expands`() = runComposeUiTest { val selectedConfigset = "gettingstarted" - val component = TestConfigsetsComponent( - model = Model( + + setContent { + ConfigsetsDropdown( + selectConfigset = {}, configsets = listOf(selectedConfigset, "techproducts") .map { Configset(it) }, selectedConfigset = selectedConfigset, - ), - ) - - setContent { ConfigsetsContent(component = component) } + ) + } onNodeWithTag(testTag = "configsets_dropdown").performClick() onNodeWithTag(testTag = "configsets_exposed_dropdown_menu").isDisplayed() diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/CreateConfigsetDialogTest.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/CreateConfigsetDialogTest.kt new file mode 100644 index 000000000000..a3d7868eb9a4 --- /dev/null +++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/CreateConfigsetDialogTest.kt @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.views.configsets + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.apache.solr.ui.TestDispatchers +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetResult +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.components.configsets.viewmodel.CreateConfigsetViewModel +import org.apache.solr.ui.domain.Configset + +@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class) +class CreateConfigsetDialogTest { + @Test + fun `WHEN configset name typed THEN creation button enabled`() = runComposeUiTest { + setContent { + CreateConfigsetDialog(viewModel()) + } + + val configsetTextField = onNodeWithTag(testTag = "create_configset_name_field") + configsetTextField.performTextInput("testconfigset") + + onNodeWithTag(testTag = "configset_create_button").assertIsEnabled() + } + + @Test + fun `GIVEN configset name WHEN clearing configset name THEN creation button disabled`() = runComposeUiTest { + setContent { + CreateConfigsetDialog(viewModel()) + } + + val configsetTextField = onNodeWithTag(testTag = "create_configset_name_field") + configsetTextField.performTextInput("testconfigset") + + configsetTextField.performTextClearance() + + onNodeWithTag(testTag = "configset_create_button").assertIsNotEnabled() + } + + private fun viewModel() = CreateConfigsetViewModel( + createConfigsetUseCase = SuccessCreateConfigsetUseCase, + loadConfigsetsUseCase = SuccessLoadConfigsetsUseCase(), + dispatchers = TestDispatchers(io = UnconfinedTestDispatcher()), + ) +} + +private object SuccessCreateConfigsetUseCase : CreateConfigsetUseCase { + override suspend fun invoke( + configsetName: String, + baseConfigset: String?, + ): CreateConfigsetResult = CreateConfigsetResult.Success(Configset(name = configsetName)) +} + +private class SuccessLoadConfigsetsUseCase( + private val configsets: List = emptyList(), +) : LoadConfigsetsUseCase { + override suspend fun invoke(): Result> = Result.success(configsets) +} diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ImportConfigsetDialogTest.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ImportConfigsetDialogTest.kt new file mode 100644 index 000000000000..1f064f318760 --- /dev/null +++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/configsets/ImportConfigsetDialogTest.kt @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.views.configsets + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.apache.solr.ui.TestDispatchers +import org.apache.solr.ui.components.configsets.domain.ImportConfigsetResult +import org.apache.solr.ui.components.configsets.domain.ImportConfigsetUseCase +import org.apache.solr.ui.components.configsets.viewmodel.ImportConfigsetViewModel +import org.apache.solr.ui.components.files.domain.SelectFileResult +import org.apache.solr.ui.components.files.domain.SelectFileUseCase +import org.apache.solr.ui.domain.Configset +import org.apache.solr.ui.domain.PickedFile + +@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class) +class ImportConfigsetDialogTest { + val expectedConfigsetName = "testconfigset" + val fileName = "${expectedConfigsetName}_configset.zip" + val file = PickedFile(name = fileName, bytes = byteArrayOf(), extension = "zip") + + @Test + fun `WHEN configset file selected THEN import button enabled`() = runComposeUiTest { + setContent { + val viewModel = viewModel(file) + ImportConfigsetDialog(viewModel) + viewModel.selectFile() + } + + onNodeWithTag(testTag = "configset_import_button").assertIsEnabled() + } + + @Test + fun `WHEN configset file selected THEN configset name autofilled with modified file name`() = runComposeUiTest { + setContent { + val viewModel = viewModel(file) + ImportConfigsetDialog(viewModel) + viewModel.selectFile() + } + + onNodeWithTag(testTag = "import_configset_name_field") + .assertTextContains(expectedConfigsetName) + } + + @Test + fun `GIVEN configset file selected WHEN clearing configset file THEN import button disabled`() = runComposeUiTest { + setContent { + val viewModel = viewModel(file) + ImportConfigsetDialog(viewModel) + viewModel.selectFile() + } + + val configsetClearButton = onNodeWithTag(testTag = "file_selector_clear_button") + configsetClearButton.performClick() + + onNodeWithTag(testTag = "configset_import_button").assertIsNotEnabled() + } + + @Test + fun `GIVEN configset file selected WHEN clearing configset name THEN import button disabled`() = runComposeUiTest { + setContent { + val viewModel = viewModel(file) + ImportConfigsetDialog(viewModel) + viewModel.selectFile() + } + + val configsetTextField = onNodeWithTag(testTag = "import_configset_name_field") + configsetTextField.performTextClearance() + + onNodeWithTag(testTag = "configset_import_button").assertIsNotEnabled() + } + + private fun viewModel(file: PickedFile) = ImportConfigsetViewModel( + importConfigsetUseCase = SuccessImportConfigsetUseCase, + selectFileUseCase = SuccessSelectFileUseCase(file), + dispatchers = TestDispatchers(io = UnconfinedTestDispatcher()), + ) +} + +private object SuccessImportConfigsetUseCase : ImportConfigsetUseCase { + override suspend fun invoke( + configsetName: String, + file: PickedFile, + ): ImportConfigsetResult = ImportConfigsetResult.Success(Configset(name = configsetName)) +} + +private class SuccessSelectFileUseCase(private val file: PickedFile) : SelectFileUseCase { + override suspend fun invoke( + extensions: List, + maxSize: Int?, + ): SelectFileResult = SelectFileResult.Success(file) +} diff --git a/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/Main.kt b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/Main.kt index 3ec26906a8ac..dc59bc2886cc 100644 --- a/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/Main.kt +++ b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/Main.kt @@ -17,7 +17,6 @@ package org.apache.solr.ui -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.ui.Modifier diff --git a/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/preview/configsets/PreviewConfigsetsContent.kt b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/preview/configsets/PreviewConfigsetsContent.kt index 36cbac37eba9..4b6f07ecb5d6 100644 --- a/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/preview/configsets/PreviewConfigsetsContent.kt +++ b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/preview/configsets/PreviewConfigsetsContent.kt @@ -19,57 +19,84 @@ package org.apache.solr.ui.preview.configsets import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import com.arkivanov.decompose.Child -import com.arkivanov.decompose.router.slot.ChildSlot -import com.arkivanov.decompose.value.MutableValue -import com.arkivanov.decompose.value.Value -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import org.apache.solr.ui.components.configsets.ConfigsetsComponent -import org.apache.solr.ui.components.configsets.ConfigsetsComponent.Model -import org.apache.solr.ui.components.configsets.overview.ConfigsetsOverviewComponent -import org.apache.solr.ui.components.navigation.TabNavigationComponent.Configuration +import kotlinx.coroutines.Dispatchers +import org.apache.solr.ui.components.configsets.di.ConfigsetsComponent +import org.apache.solr.ui.components.configsets.di.ConfigsetsOverviewComponent +import org.apache.solr.ui.components.configsets.domain.CreateConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.ImportConfigsetUseCase +import org.apache.solr.ui.components.configsets.domain.LoadConfigsetsUseCase +import org.apache.solr.ui.components.configsets.repository.ConfigsetsRepository +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsOverviewViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsRouteViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ConfigsetsViewModel +import org.apache.solr.ui.components.configsets.viewmodel.CreateConfigsetViewModel +import org.apache.solr.ui.components.configsets.viewmodel.ImportConfigsetViewModel +import org.apache.solr.ui.components.files.domain.SelectFileUseCase import org.apache.solr.ui.domain.Configset import org.apache.solr.ui.preview.PreviewContainer -import org.apache.solr.ui.views.configsets.ConfigsetsContent -import org.apache.solr.ui.views.navigation.configsets.ConfigsetsTab +import org.apache.solr.ui.utils.platformDispatchers +import org.apache.solr.ui.views.configsets.ConfigsetsScene @Preview @Composable private fun PreviewConfigsetsContentEmptyConfigsets() = PreviewContainer { - ConfigsetsContent(component = SimplePreviewConfigsetsComponent()) + ConfigsetsScene(component = PreviewConfigsetsComponent()) } -@Preview -@Composable -private fun PreviewConfigsetsContentWithConfigsetSelected() = PreviewContainer { - val configset = "techproducts" - ConfigsetsContent( - component = SimplePreviewConfigsetsComponent( - model = Model( - configsets = listOf(configset, "getting_started").map { Configset(name = it) }, - selectedConfigset = configset, - ), - ), +private class PreviewConfigsetsComponent( + private val configsets: List = emptyList(), +) : ConfigsetsComponent { + + private val dispatchers = platformDispatchers() + + override val configsetsRepository: ConfigsetsRepository = error("Not used in previews") + + override val loadConfigsetsUseCase: LoadConfigsetsUseCase = object : LoadConfigsetsUseCase { + override suspend fun invoke(): Result> = Result.success(configsets) + } + + override fun createConfigsetsRouteViewModel(): ConfigsetsRouteViewModel = ConfigsetsRouteViewModel() + + override fun createConfigsetsViewModel(): ConfigsetsViewModel = ConfigsetsViewModel( + loadConfigsetsUseCase = loadConfigsetsUseCase, + dispatchers = dispatchers, ) + + override fun createConfigsetsOverviewComponent(): ConfigsetsOverviewComponent = PreviewConfigsetsOverviewComponent(configsets) } -private class SimplePreviewConfigsetsComponent(model: Model = Model()) : ConfigsetsComponent { - override val model: StateFlow = MutableStateFlow(model) +private class PreviewConfigsetsOverviewComponent(configsets: List = emptyList()) : ConfigsetsOverviewComponent { - override fun onSelectConfigset(name: String) = Unit + private val dispatchers = platformDispatchers() - override val tabSlot: Value, ConfigsetsComponent.Child>> - get() = MutableValue( - initialValue = ChildSlot( - Child.Created( - configuration = Configuration(tab = ConfigsetsTab.Overview), - instance = ConfigsetsComponent.Child.Overview(PreviewConfigsetsOverviewComponent), - ), - ), - ) + override val configsetsRepository: ConfigsetsRepository = error("Not used in previews") - override fun onNavigate(tab: ConfigsetsTab) = Unit -} + override val createConfigsetUseCase: CreateConfigsetUseCase = error("Not used in previews") + + override val importConfigsetUseCase: ImportConfigsetUseCase = error("Not used in previews") -private object PreviewConfigsetsOverviewComponent : ConfigsetsOverviewComponent + override val loadConfigsetsUseCase: LoadConfigsetsUseCase = object : LoadConfigsetsUseCase { + override suspend fun invoke(): Result> = Result.success(configsets) + } + + override val selectFileUseCase: SelectFileUseCase = error("Not used in previews") + + override fun createConfigsetsViewModel(): ConfigsetsViewModel = ConfigsetsViewModel( + loadConfigsetsUseCase = loadConfigsetsUseCase, + dispatchers = dispatchers, + ) + + override fun createCreateConfigsetViewModel(): CreateConfigsetViewModel = CreateConfigsetViewModel( + createConfigsetUseCase = createConfigsetUseCase, + loadConfigsetsUseCase = loadConfigsetsUseCase, + dispatchers = dispatchers, + ) + + override fun createImportConfigsetViewModel(): ImportConfigsetViewModel = ImportConfigsetViewModel( + importConfigsetUseCase = importConfigsetUseCase, + selectFileUseCase = selectFileUseCase, + dispatchers = dispatchers, + ) + + override fun createConfigsetsOverviewViewModel(): ConfigsetsOverviewViewModel = ConfigsetsOverviewViewModel() +} diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/overview/ConfigsetsOverviewComponent.kt b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/utils/AppDispatchers.jvm.kt similarity index 80% rename from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/overview/ConfigsetsOverviewComponent.kt rename to solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/utils/AppDispatchers.jvm.kt index eed272cb2597..8842a634751c 100644 --- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/configsets/overview/ConfigsetsOverviewComponent.kt +++ b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/utils/AppDispatchers.jvm.kt @@ -14,6 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.ui.components.configsets.overview -interface ConfigsetsOverviewComponent +package org.apache.solr.ui.utils + +import kotlinx.coroutines.Dispatchers + +actual fun platformDispatchers(): AppDispatchers = object : AppDispatchers { + override val io = Dispatchers.IO +} diff --git a/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/utils/FileUtils.desktop.kt b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/utils/FileUtils.desktop.kt new file mode 100644 index 000000000000..cfbb2eac028f --- /dev/null +++ b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/utils/FileUtils.desktop.kt @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.utils + +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import javax.swing.SwingUtilities +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import org.apache.solr.ui.domain.PickedFile + +actual suspend fun pickFile(extensions: List): PickedFile? { + val (dir, fileName) = suspendCancellableCoroutine { cont -> + SwingUtilities.invokeLater { + val dialog = FileDialog(null as Frame?, "Choose a file", FileDialog.LOAD).apply { + isMultipleMode = false + // Note: FileDialog filtering is platform-dependent; setFilenameFilter is best-effort. + if (extensions.isNotEmpty()) { + setFilenameFilter { _, name -> + val lower = name.lowercase() + extensions.any { ext -> lower.endsWith(".${ext.lowercase()}") } + } + } + isVisible = true + } + cont.resume(dialog.directory to dialog.file) + dialog.dispose() + } + } + + if (dir == null || fileName == null) return null + + val f = File(dir, fileName) + return PickedFile( + name = f.name, + bytes = f.readBytes(), + extension = f.extension, + ) +} diff --git a/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/Main.kt b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/Main.kt index dd6a54f4eb3c..7fb3703656b5 100644 --- a/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/Main.kt +++ b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/Main.kt @@ -17,7 +17,6 @@ package org.apache.solr.ui -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.ui.ExperimentalComposeUiApi @@ -79,7 +78,7 @@ fun main() { ) ComposeViewport(document.body!!) { - SolrTheme(useDarkTheme = isSystemInDarkTheme()) { + SolrTheme(useDarkTheme = false) { Surface(modifier = Modifier.fillMaxSize()) { RootContent(component) } diff --git a/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/utils/AppDispatchers.web.kt b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/utils/AppDispatchers.web.kt new file mode 100644 index 000000000000..ae9f03eb29b7 --- /dev/null +++ b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/utils/AppDispatchers.web.kt @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.utils + +import kotlinx.coroutines.Dispatchers + +actual fun platformDispatchers(): AppDispatchers = object : AppDispatchers { + override val io = Dispatchers.Default +} diff --git a/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/utils/FileUtils.wasmJs.kt b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/utils/FileUtils.wasmJs.kt new file mode 100644 index 000000000000..6fa8a22c53da --- /dev/null +++ b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/utils/FileUtils.wasmJs.kt @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.ui.utils + +import kotlin.coroutines.resume +import kotlinx.browser.document +import kotlinx.coroutines.suspendCancellableCoroutine +import org.apache.solr.ui.domain.PickedFile +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Uint8Array +import org.khronos.webgl.get +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.asList +import org.w3c.dom.events.Event +import org.w3c.files.File +import org.w3c.files.FileReader + +@OptIn(ExperimentalWasmJsInterop::class) +actual suspend fun pickFile( + extensions: List, +): PickedFile? = suspendCancellableCoroutine { cont -> + val input = (document.createElement("input") as HTMLInputElement).apply { + type = "file" + style.display = "none" + if (extensions.isNotEmpty()) { + // Accept expects things like ".zip,.json" or MIME types. + accept = extensions.joinToString(",") { ".${it.trimStart('.')}" } + } + } + + fun cleanup() { + input.onchange = null + input.remove() + } + + input.onchange = onchange@{ + val chosen: File? = input.files?.asList()?.firstOrNull() + if (chosen == null) { + cleanup() + cont.resume(null) + return@onchange + } + + val reader = FileReader() + reader.onload = onload@{ _: Event -> + val buffer = reader.result as? ArrayBuffer + if (buffer == null) { + cleanup() + cont.resume(null) + return@onload + } + + val bytes = Uint8Array(buffer) + val out = ByteArray(bytes.length) { i -> + bytes[i].toInt().toByte() + } + + cleanup() + cont.resume( + PickedFile( + name = chosen.name, + bytes = out, + extension = chosen.name.substringAfterLast("."), + ), + ) + } + + reader.onerror = { _: Event -> + cleanup() + cont.resume(null) + } + + reader.readAsArrayBuffer(chosen) + cont.invokeOnCancellation { + try { + reader.abort() + } catch (_: Throwable) { + // Ignore: abort() may throw depending on state/platform bindings. + } + cleanup() + } + } + + document.body?.appendChild(input) + input.click() +} diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index abdd53f0c59c..3f64bc0478d0 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -330,6 +330,15 @@ solrAdminApp.config([ onSelect: '&' }, link: function(scope, element, attrs) { + // Bind once; previously this was inside the $watch which stacked listeners + // on every data change and could fire synchronously during a digest, + // triggering $rootScope:inprog. + element.on("select_node.jstree", function (event, data) { + scope.$applyAsync(function() { + scope.onSelect({url: data.node.a_attr.href, data: data}); + }); + }); + scope.$watch("data", function(newValue, oldValue) { if (newValue && !jQuery.isEmptyObject(newValue)) { var treeConfig = { @@ -339,7 +348,7 @@ solrAdminApp.config([ } }; - var tree = $(element).jstree(treeConfig); + $(element).jstree(treeConfig); // This is done to ensure that the data can be refreshed if it is updated behind the scenes. // Putting the data in the treeConfig makes it stack and doesn't update. @@ -347,13 +356,6 @@ solrAdminApp.config([ $(element).jstree(true).refresh(); $(element).jstree('open_node','li:first'); - if (tree) { - element.bind("select_node.jstree", function (event, data) { - scope.$apply(function() { - scope.onSelect({url: data.node.a_attr.href, data: data}); - }); - }); - } } }, true); } diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index 07013446a279..070ceb844f4f 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -15,7 +15,7 @@ limitations under the License. */ -solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) { +solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, ConfigSetFiles, Luke) { $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE); $scope.schemas = []; @@ -887,10 +887,10 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.updateWorking = true; $scope.updateStatusMessage = "Updating file ..."; - SchemaDesigner.post(params, $scope.fileNodeText, function (data) { + SchemaDesigner.put(params, $scope.fileNodeText, function (data) { if (data.updateFileError) { - if (data[$scope.selectedFile]) { - $scope.fileNodeText = data[$scope.selectedFile]; + if (data.fileContent) { + $scope.fileNodeText = data.fileContent; } $scope.updateFileError = data.updateFileError; } else { @@ -904,9 +904,9 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.onSelectFileNode = function (id, doSelectOnTree) { $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id; - var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema}; - SchemaDesigner.get(params, function (data) { - $scope.fileNodeText = data[$scope.selectedFile]; + var mutableId = "._designer_" + $scope.currentSchema; + ConfigSetFiles.get({configSet: mutableId, filePath: $scope.selectedFile}, function (data) { + $scope.fileNodeText = data.content; $scope.isLeafNode = false; if (doSelectOnTree) { delete $scope.selectedNode; @@ -1522,11 +1522,13 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, }; $scope.downloadConfig = function () { - // have to use an AJAX request so we can supply the Authorization header + // Use the generic configsets download endpoint on the mutable draft + var mutableId = "._designer_" + $scope.currentSchema; + var downloadUrl = "/api/configsets/" + encodeURIComponent(mutableId) + "/files"; if (sessionStorage.getItem("auth.header")) { var fileName = $scope.currentSchema+"_configset.zip"; var xhr = new XMLHttpRequest(); - xhr.open("GET", "/api/schema-designer/download/"+fileName+"?wt=raw&configSet="+$scope.currentSchema, true); + xhr.open("GET", downloadUrl, true); xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header")); xhr.responseType = 'blob'; xhr.addEventListener('load',function() { @@ -1543,7 +1545,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, }) xhr.send(); } else { - location.href = "/api/schema-designer/download/"+$scope.currentSchema+"_configset.zip?wt=raw&configSet=" + $scope.currentSchema; + location.href = downloadUrl; } }; @@ -1834,6 +1836,11 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, } SchemaDesigner.get(params, function (data) { + if (data.updateError != null) { + $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails); + return; + } + $("#sort").trigger("chosen:updated"); $("#ff").trigger("chosen:updated"); $("#hl").trigger("chosen:updated"); diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 98e7e37d9baa..47c5ad2fa6ee 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -73,6 +73,23 @@ solrAdminServices.factory('System', return $resource('admin/configs', {'wt': 'json', '_': Date.now()}, {"configs": {params: {action: "LIST"}} }); }]) +.factory('ConfigSetFiles', + ['$http', function ($http) { + // Fetches a single file from a configset via V2 /api/configsets/{name}/files/{path}. + // Each path segment is encoded separately so subdirectory paths like "lang/stopwords.txt" + // preserve their slashes (encoding them as %2F gets rejected by Jetty). + // transformResponse is overridden to skip JSON parsing since files are raw text. + return { + get: function (params, successFn, errorFn) { + var url = "/api/configsets/" + encodeURIComponent(params.configSet) + + "/files/" + params.filePath.split("/").map(encodeURIComponent).join("/"); + $http.get(url, {transformResponse: [function (data) { return data; }]}).then( + function (response) { if (successFn) successFn({content: response.data}); }, + function (response) { if (errorFn) errorFn(response); } + ); + } + }; + }]) .factory('Cores', ['$resource', function($resource) { return $resource('admin/cores', @@ -271,10 +288,11 @@ solrAdminServices.factory('System', }]) .factory('SchemaDesigner', ['$resource', function($resource) { - return $resource('/api/schema-designer/:path', {wt: 'json', path: '@path', _:Date.now()}, { + return $resource('/api/schema-designer/:configSet/:path', {wt: 'json', path: '@path', configSet: '@configSet', filePath: '@filePath', _:Date.now()}, { get: {method: "GET"}, post: {method: "POST", timeout: 90000}, put: {method: "PUT"}, + delete: {method: "DELETE"}, postXml: {headers: {'Content-type': 'text/xml'}, method: "POST", timeout: 90000}, postCsv: {headers: {'Content-type': 'application/csv'}, method: "POST", timeout: 90000}, upload: {method: "POST", transformRequest: angular.identity, headers: {'Content-Type': undefined}, timeout: 90000} diff --git a/solr/webapp/web/partials/schema-designer.html b/solr/webapp/web/partials/schema-designer.html index 63936d434375..4d7fbd5b4b38 100644 --- a/solr/webapp/web/partials/schema-designer.html +++ b/solr/webapp/web/partials/schema-designer.html @@ -476,7 +476,7 @@

Sample Documents

-

Upload a JSON, CSS, or XML file containing sample documents or simply paste some sample documents into the text area below; the Schema Designer supports a maximum of 5MB and 1,000 documents. +

Upload a JSON, JSONL, CSV, or XML file containing sample documents or simply paste some sample documents into the text area below; the Schema Designer supports a maximum of 5MB and 1,000 documents.

Click on the Analyze Documents button to have Solr determine the schema by looking at the sample values for each field. Sample documents are stored on the server so you can make changes to the schema and Schema Designer will automatically re-index the sample documents to apply the changes.