diff --git a/.gitignore b/.gitignore index 443ace390c..70015e34ad 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,5 @@ tutorial/mimic_iii_demo/data/** /node_modules cypress/screenshots/ cypress/videos/ + +.codex diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..7caaae64bb --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +distributionType=bin +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.4/maven-wrapper-3.3.4.jar diff --git a/README.md b/README.md index 11ea1960a9..7903f5e974 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,33 @@ configurations, such as: ## Development +### Backend Development + +#### Dropwizard backend (current production backend) + +From the repository root: + +```bash +./mvnw -pl backend -am test +./mvnw -pl executable -am package +java -jar executable/target/conquery.jar standalone +``` + +#### Quarkus backend (migration target) + +From the repository root: + +```bash +./mvnw -pl backend-quarkus quarkus:dev +``` + +Useful endpoints while running: + +- `http://localhost:8090/api/ping` +- `http://localhost:8090/api/config/frontend` +- `http://localhost:8090/q/health` +- `http://localhost:8090/q/swagger-ui` + ### Testing #### Integration Tests diff --git a/backend-quarkus/README.md b/backend-quarkus/README.md new file mode 100644 index 0000000000..1ff7342201 --- /dev/null +++ b/backend-quarkus/README.md @@ -0,0 +1,109 @@ +# Conquery Quarkus Backend (Migration Scaffold) + +This module is the starting point for migrating the Dropwizard backend to Quarkus in small, safe increments. + +## Current state + +- Maven module: `backend-quarkus` +- Quarkus REST runtime with JSON support +- Health and OpenAPI extensions wired +- Baseline endpoint: `GET /api/ping` + +## Run locally + +From repository root: + +```bash +mvn -pl backend-quarkus quarkus:dev +``` + +Default port is `8090`. + +### Ingest generated dataset metadata folders at startup + +You can mount generated metadata folders (for example `.../gen/demo`) into the container and let the backend ingest +them during boot: + +```properties +conquery.metadata.enabled=true +conquery.metadata.root-path=/data/gen +conquery.metadata.folders[0]=demo +``` + +This reads `conceptTrees/*.concept.json` and `tables/*.table.json` from each configured folder and loads them as +dataset metadata. + +### Concept JSON migration helpers + +Some Dropwizard concept metadata still uses connector column references in the old `table.column` form. The migration +helper `scripts/normalize_concept_connector_columns.py` rewrites those references for the Quarkus metadata loader: + +- connector `"column": "table.column"` becomes `"table": "table", "column": "column"` +- filter `"column": "table.column"` becomes `"column": "column"` +- validity-date `column`, `startColumn`, and `endColumn` references become local column names + +Run it from the repository root: + +```bash +python3 scripts/normalize_concept_connector_columns.py path/to/*.concept.json +``` + +Use `--dry-run` first to see how many connector, filter, connector-select, and validity-date columns would be changed. Select fields +covered by the script are `column`, `startColumn`, `endColumn`, `subtractColumn`, `distinctByColumn`, `distinctBy`, and +the column values in `flags`. This script is intended to grow with additional concept metadata migration steps as they +become necessary. + +### Polymorphic metadata models + +Metadata models are code-first. Java model classes, Jackson annotations, Bean Validation constraints, and OpenAPI +`@Schema` annotations are used both for loading metadata files and for generating their schemas. Registered model +families are exposed in the OpenAPI document at `GET /q/openapi` as `oneOf` schemas with discriminator mappings. + +Filter, select, and concept-condition implementations are extensible from another CDI-enabled JAR. A filter extension provides: + +1. A concrete class implementing `FilterDefinition`, annotated with OpenAPI `@Schema` and + `@PolymorphicModelSubtype(base = FilterDefinition.class, id = "...")`. +2. A CDI bean implementing `FilterDefinitionProvider` for its model class and business-model conversion and integrity validation. The + provider remains the discovery hook for implementations from external JARs. +3. The filter value model types accepted by that provider. Each model type needs a registered `FilterValueProvider`. + +Connector selects use the equivalent `SelectDefinition` and `SelectDefinitionProvider` extension points. Concept-level +selects form a separate family with `ConceptSelectDefinition` and `ConceptSelectDefinitionProvider`. Unknown types +fail startup by default; `conquery.metadata.strict-filter-types` and +`conquery.metadata.strict-select-types` can independently switch their family to warning-and-skip behavior. + +Concept-tree conditions use `ConceptCondition` and `ConceptConditionProvider`. Because skipping a condition would +change the meaning of a concept node, unknown condition types always fail metadata validation. The built-in condition +types are `EQUAL`, `PREFIX_LIST`, `PREFIX_RANGE`, `COLUMN_EQUAL`, `PRESENT`, `AND`, `OR`, and `NOT`. The old `GROOVY` +condition is intentionally not registered because script execution requires a separate security and runtime decision. + +Query filter values use the separate `FilterValue` and `FilterValueProvider` family. Their discriminator ids may +overlap metadata filter definitions (for example `SELECT`) because polymorphic registrations are scoped by base type. +Filter providers declare their accepted `FilterValue` model classes through `acceptedValueTypes()`. Startup verifies +that each declared model has a registered value provider, and metadata conversion verifies the emitted value type. +Entity-query execution is intentionally not implemented yet. The filter value and entity-history API models are kept, +but the entity-history and entity-resolution endpoints return `501 Not Implemented` until their business semantics are migrated. + +The generic `PolymorphicModelRegistry` validates duplicate type ids and base/model compatibility at startup. Its +registrations are also installed into Jackson and projected into OpenAPI. Extension JARs must be discoverable by +Quarkus bean and Jandex indexing so their provider and annotated model class are available during application assembly. + +## Migration strategy + +1. Keep `backend` (Dropwizard) running while we port feature slices. +2. Move framework-neutral logic first (services, models, utility layers). +3. Port HTTP resources incrementally: + - `resources/api/*` + - `resources/admin/rest/*` +4. Replace Dropwizard-specific primitives with Quarkus equivalents: + - `@Auth` + Dropwizard Auth -> CDI + Quarkus Security/JAX-RS filters + - Dropwizard `Task` -> scheduled jobs/admin endpoints + - `Managed` lifecycle hooks -> Quarkus startup/shutdown events + - Freemarker views -> keep server-side templates via Quarkus Qute/Freemarker extension or move to frontend +5. Port CLI commands (`ShardCommand`, `PreprocessorCommand`, etc.) to Quarkus CLI/Picocli entrypoints. + +## Immediate next slices + +- Add one real API resource from `backend/src/main/java/com/bakdata/conquery/resources/api`. +- Introduce shared core module for code that should be reused by both runtimes. +- Add compatibility tests that hit both old and new endpoints for parity. diff --git a/backend-quarkus/pom.xml b/backend-quarkus/pom.xml new file mode 100644 index 0000000000..ef3a477cc7 --- /dev/null +++ b/backend-quarkus/pom.xml @@ -0,0 +1,146 @@ + + 4.0.0 + + + com.bakdata.conquery + parent + ${revision} + + + backend-quarkus + Conquery Backend (Quarkus) + + + io.quarkus.platform + quarkus-bom + 3.23.4 + true + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + org.jboss.logging + jboss-logging + 3.6.1.Final + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-smallrye-openapi + + + io.quarkus + quarkus-info + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-hibernate-validator + + + org.projectlombok + lombok + ${lombok.version} + provided + + + org.jetbrains.xodus + xodus-environment + 1.3.232 + + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-test-security + test + + + io.rest-assured + rest-assured + test + + + jakarta.validation + jakarta.validation-api + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-surefire-plugin + + + org.jboss.logmanager.LogManager + + + + + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + + + + + + + + diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/ConceptResource.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/ConceptResource.java new file mode 100644 index 0000000000..e44423d00e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/ConceptResource.java @@ -0,0 +1,273 @@ +package com.bakdata.conquery.quarkus.api; + +import java.util.*; + +import com.bakdata.conquery.quarkus.ids.ConceptId; +import com.bakdata.conquery.quarkus.services.DatasetService; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.smallrye.common.constraint.Nullable; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.Operation; + +@Path("/api/concepts") +@Produces(MediaType.APPLICATION_JSON) +public class ConceptResource { + + @Inject + DatasetService datasetService; + + @GET + @Path("/{conceptId}") + @Operation( + summary = "Get concept details", + description = "Returns a concept map keyed by concept id, compatible with frontend tree loading." + ) + public Map getConcept(@PathParam("conceptId") @NotBlank String conceptId) { + + DatasetCatalogRepository.Concept concept = datasetService.requireConcept(conceptId); + ConceptId parsedConceptId = ConceptId.parse(conceptId); + + Map nodes = new HashMap<>(); + + Map children = concept.children(); + + // TODO Populate matching statistics and their derived date ranges once imported event statistics are available. + ConceptNodeResponse node = new ConceptNodeResponse(concept.label(), concept.description(), null, null, concept.childrenIds().stream().map(ConceptId::toString).toList(), 0L, 0L, null, false, concept.additionalInfos(), excludeFromTimeAggregation(concept), concept.connectors().stream().map(this::toConnectorResponse).toList(), concept.selects().stream().map(this::toSelectResponse).toList()); + nodes.put(parsedConceptId.toString(), node); + + // Add children + children.forEach((id, child) -> { + ConceptNodeResponse childNode = new ConceptNodeResponse(child.label(), child.description(), null, child.parentId().toString(), child.children().stream().map(ConceptId::toString).toList(), 0L, 0L, null, false, child.additionalInfos(), null, List.of(), List.of()); + nodes.put(id.toString(), childNode); + }); + + return nodes; + } + + private boolean excludeFromTimeAggregation(DatasetCatalogRepository.Concept concept) { + return concept.defaultExcludeFromTimeAggregation() + || concept.connectors().stream().allMatch(connector -> connector.validityDates().isEmpty()); + } + + private ConnectorResponse toConnectorResponse(DatasetCatalogRepository.Connector connector) { + DatasetCatalogRepository.TableRecord tableRecord = datasetService.requireTable(connector.tableId()); + List supportedSecondaryIds = tableRecord.columns().stream() + .map(DatasetCatalogRepository.ColumnRecord::secondaryId) + .filter(Objects::nonNull) + .distinct() + .toList(); + return new ConnectorResponse( + connector.tableId().toString(), + connector.id().toString(), + connector.label(), + connector.isDefault(), + connector.filters().stream().map(this::toFilterResponse).toList(), + connector.selects().stream().map(this::toSelectResponse).toList(), + toDateColumnResponse(connector), + supportedSecondaryIds + ); + } + + private DateColumnResponse toDateColumnResponse(DatasetCatalogRepository.Connector connector) { + if (connector.validityDates().isEmpty()) { + return null; + } + List options = connector.validityDates().stream() + .map(validityDate -> new ValueResponse(validityDate.id().toString(), validityDate.label())) + .toList(); + return new DateColumnResponse(options, options.getFirst().value(), null, connector.validityDatesDescription()); + } + + private SelectResponse toSelectResponse(DatasetCatalogRepository.Select select) { + return new SelectResponse( + select.id().toString(), + select.label(), + select.description(), + select.defaultSelected(), + toSelectResultTypeResponse(select.resultType()) + ); + } + + private SelectResponse toSelectResponse(DatasetCatalogRepository.ConceptSelect select) { + return new SelectResponse( + select.id().toString(), + select.label(), + select.description(), + select.defaultSelected(), + toSelectResultTypeResponse(select.resultType()) + ); + } + + private SelectResultTypeResponse toSelectResultTypeResponse(DatasetCatalogRepository.SelectResultType resultType) { + return new SelectResultTypeResponse( + resultType.type(), + resultType.elementType() == null ? null : new ElementTypeResponse(resultType.elementType().type()) + ); + } + + private FilterResponse toFilterResponse(DatasetCatalogRepository.Filter filter) { + return new FilterResponse( + filter.id().toString(), + filter.label(), + filter.type(), + filter.unit(), + filter.tooltip(), + filter.options().stream() + .map(option -> new FrontendValue(option.value(), option.label(), option.optionValue())) + .toList(), + filter.min(), + filter.max(), + filter.pattern(), + filter.allowDropFile(), + filter.creatable(), + filter.defaultValue() + ); + } + + + @POST + @Path("/{conceptId}/resolve") + @Operation(summary = "Resolve concept codes", description = "Resolves uploaded concept codes to concept ids for the same dataset as the requested root concept.") + public ConceptResolveResponse resolveConceptCodes(@PathParam("conceptId") @NotBlank String conceptId, @Valid @NotNull ConceptCodeList payload) { + DatasetService.ConceptCodeResolution resolution = datasetService.resolveConceptCodes(conceptId, payload.concepts); + return new ConceptResolveResponse(resolution.resolvedConcepts(), resolution.unknownCodes()); + } + + public record ConceptNodeResponse( + String label, + String description, + Boolean active, + String parent, + List children, + Long matchingEntries, + Long matchingEntities, + Boolean detailsAvailable, + Boolean codeListResolvable, + List additionalInfos, + Boolean excludeFromTimeAggregation, + List tables, + List selects + ) { + } + + public record ConnectorResponse( + String id, + String connectorId, + String label, + @JsonProperty("default") Boolean isDefault, + List filters, + List selects, + DateColumnResponse dateColumn, + List supportedSecondaryIds + ) { + } + + public record ColumnResponse( + String id, + String label, + DatasetCatalogRepository.ColumnType type, + String secondaryId + ) { + } + + public record SelectResponse( + String id, + String label, + String description, + @JsonProperty("default") Boolean defaultSelected, + SelectResultTypeResponse resultType + ) { + } + + public record SelectResultTypeResponse( + String type, + ElementTypeResponse elementType + ) { + } + + public record ElementTypeResponse(String type) { + } + + public record FilterResponse( + + @NotNull String id, + /** + * User readable name of the Filter. + */ + @NotEmpty String label, + /** + * Kind of filter: Communicates to the frontend which UI element to use and what values are valid. + */ + @NotEmpty String type, + /** + * Used as display unit for enumerations etc in UI elements. + */ + String unit, + + /** + * Displayed on hover for filters. + */ + String tooltip, + + List options, + + /** + * min value for range filters. + */ + Integer min, + /** + * max value for range filters. + */ + Integer max, + + String pattern, + /** + * If true, enables users to use drag and drop files into the filter element (usually for {@link com.bakdata.conquery.models.datasets.concepts.filters.specific.SelectFilter}). + */ + boolean allowDropFile, + /** + * If true, user can manually insert their input. At the moment only true for SelectFilter without any enabled backing searches. + */ + boolean creatable, + /** + * If set, default value used for the filter by the frontend. + */ + @Nullable Object defaultValue + ) { + } + + record FrontendValue(String value, String label, String optionValue) { + } + + public record DateColumnResponse(List options, String defaultValue, String value, String tooltip) { + } + + public record ValueResponse(String value, String label) { + } + + public record ConceptResolveResponse(List resolvedConcepts, List unknownCodes) { + } + + public static final class ConceptCodeList { + public final @NotNull + @NotEmpty List<@NotBlank String> concepts; + + @JsonCreator + public ConceptCodeList(@JsonProperty("concepts") List concepts) { + this.concepts = concepts; + } + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/ConceptsResponse.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/ConceptsResponse.java new file mode 100644 index 0000000000..0e7dd5565b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/ConceptsResponse.java @@ -0,0 +1,40 @@ +package com.bakdata.conquery.quarkus.api; + +import java.util.List; +import java.util.Map; + +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public record ConceptsResponse( + List secondaryIds, + Map concepts +) { + public record SecondaryIdResponse( + String id, + String label, + String description + ) { + } + + public record ConceptSummaryResponse( + String label, + String description, + @Schema(description = "Whether this node can be dragged/selected in the query editor. " + + "When detailsAvailable is false (folder/structure node), this should be false.") + Boolean active, + String parent, + List children, + Long matchingEntries, + Long matchingEntities, + @Schema(description = "Whether detailed concept contents are available via /api/concepts/{conceptId}. " + + "If false, this node is a folder/structure node and should not be draggable.") + Boolean detailsAvailable, + Boolean codeListResolvable, + List additionalInfos, + Boolean excludeFromTimeAggregation, + List tables, + List selects + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/DatasetFormConfigResource.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/DatasetFormConfigResource.java new file mode 100644 index 0000000000..d590155e15 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/DatasetFormConfigResource.java @@ -0,0 +1,100 @@ +package com.bakdata.conquery.quarkus.api; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.bakdata.conquery.quarkus.services.DatasetService; +import com.bakdata.conquery.quarkus.services.FormConfigService; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Path("/api/datasets") +@Produces(MediaType.APPLICATION_JSON) +public class DatasetFormConfigResource { + + @Inject + DatasetService datasetService; + + @Inject + FormConfigService formConfigService; + + @Inject + Instance identity; + + @POST + @Path("/{datasetId}/form-configs") + @Operation( + summary = "Create a form configuration", + description = "Creates a form configuration for the dataset and returns its id." + ) + public PostFormConfigResponse postConfig( + @PathParam("datasetId") String datasetId, + @Valid @NotNull FormConfigCreatePayload payload + ) { + datasetService.requireDataset(datasetId); + return formConfigService.create(datasetId, payload, SecurityIdentityUtil.resolveUserName(identity)); + } + + @GET + @Path("/{datasetId}/form-configs") + @Operation( + summary = "List form configurations", + description = "Returns form configuration metadata for the dataset." + ) + public List getConfigs( + @PathParam("datasetId") String datasetId, + @QueryParam("formType") Set formType + ) { + datasetService.requireDataset(datasetId); + return formConfigService.list(datasetId, formType, SecurityIdentityUtil.resolveUserName(identity)); + } + + public static final class FormConfigCreatePayload { + @NotBlank + public String formType; + + public String label; + + @NotNull + public List<@NotBlank String> tags = List.of(); + + @NotNull + @Schema( + description = "Opaque form values payload saved by the frontend.", + implementation = Object.class + ) + public Map values; + } + + public record PostFormConfigResponse( + String id + ) { + } + + public record FormConfigOverviewResponse( + String id, + String formType, + String label, + List tags, + String ownerName, + String createdAt, + boolean own, + boolean shared, + boolean system + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/DatasetsResource.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/DatasetsResource.java new file mode 100644 index 0000000000..560affa4a6 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/DatasetsResource.java @@ -0,0 +1,433 @@ +package com.bakdata.conquery.quarkus.api; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +import com.bakdata.conquery.quarkus.config.EntityPreviewRuntimeConfig; +import com.bakdata.conquery.quarkus.config.FormQueriesRuntimeConfig; +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.ConceptId; +import com.bakdata.conquery.quarkus.ids.StructureNodeId; +import com.bakdata.conquery.quarkus.services.DatasetService; +import com.bakdata.conquery.quarkus.services.EntityQueryService; +import com.bakdata.conquery.quarkus.services.QueryStateService; +import com.bakdata.conquery.quarkus.services.QueryUploadService; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.Operation; + +@Path("/api/datasets") +@Produces(MediaType.APPLICATION_JSON) +public class DatasetsResource { + @Inject + DatasetService datasetService; + + @Inject + EntityPreviewRuntimeConfig entityPreviewConfig; + + @Inject + FormQueriesRuntimeConfig formQueriesConfig; + + @Inject + ObjectMapper objectMapper; + + @Inject + QueryStateService queryStateService; + + @Inject + QueryUploadService queryUploadService; + + @Inject + EntityQueryService entityQueryService; + + @Inject + Instance identity; + + @GET + public List getDatasets() { + return datasetService.listDatasets().stream() + .map(entry -> new DatasetResponse(entry.id().toString(), entry.label())) + .toList(); + } + + @GET + @Path("/{datasetId}/entity-preview") + public EntityPreviewResponse getEntityPreview(@PathParam("datasetId") String datasetId) { + datasetService.requireDataset(datasetId); + + List allSources = + entityPreviewConfig.allSources().stream().map(source -> new EntityPreviewResponse.LabeledSource(source.name(), source.label())).toList(); + + List defaultSources = + entityPreviewConfig.defaultSources().stream().map(source -> new EntityPreviewResponse.LabeledSource(source.name(), source.label())).toList(); + + List searchFilters = entityPreviewConfig.searchFilters() + .map(value -> Stream.of(value.split(",")) + .map(String::trim) + .filter(filter -> !filter.isEmpty()) + .toList()) + .orElse(List.of()); + String searchConcept = entityPreviewConfig.searchConcept().orElse(null); + + return new EntityPreviewResponse(allSources, defaultSources, searchFilters, searchConcept); + } + + @GET + @Path("/{datasetId}/concepts") + @Operation( + summary = "Get root concepts for a dataset", + description = "Returns top-level concept nodes. Nodes with detailsAvailable=false represent folder/structure nodes." + ) + public ConceptsResponse getConcepts(@PathParam("datasetId") String datasetId) { + + java.util.Map concepts = new LinkedHashMap<>(); + List structureNodes = datasetService.listStructureNodesForDataset(datasetId); + Map structureNodesById = structureNodes.stream() + .collect(java.util.stream.Collectors.toMap(DatasetCatalogRepository.StructureNode::id, node -> node)); + Map structureParentByConcept = new LinkedHashMap<>(); + structureNodes.forEach(node -> node.containedRoots().forEach(conceptId -> structureParentByConcept.put(conceptId, node.id()))); + // TODO Add concept search after the metadata tree and its indexing requirements have been migrated. + datasetService.listRootConceptsForDataset(datasetId).forEach(entry -> concepts.put( + entry.id().toString(), + new ConceptsResponse.ConceptSummaryResponse( + entry.label(), + entry.description(), + true, + Optional.ofNullable(structureParentByConcept.get(entry.id())).map(StructureNodeId::toString).orElse(null), + entry.childrenIds().stream().map(ConceptId::toString).toList(), + 0L, + 0L, + true, + !entry.children().isEmpty(), + entry.additionalInfos(), + excludeFromTimeAggregation(entry), + // TODO Remove detailed connector filters/selects from this summary once the frontend loads them via /api/concepts/{conceptId}. + entry.connectors().stream().map(this::toTableResponse).toList(), + entry.selects().stream().map(this::toSelectResponse).toList() + ) + )); + + Set visibleStructureNodes = structureNodes.stream() + .filter(node -> isVisibleStructureNode(node, structureNodesById)) + .map(DatasetCatalogRepository.StructureNode::id) + .collect(java.util.stream.Collectors.toSet()); + structureNodes.stream() + .sorted(java.util.Comparator.comparingInt(DatasetCatalogRepository.StructureNode::sourceOrder)) + .filter(node -> visibleStructureNodes.contains(node.id())) + .forEach(node -> concepts.put(node.id().toString(), toStructureNodeResponse(node, visibleStructureNodes))); + + return new ConceptsResponse( + List.of(), + concepts + ); + } + + private boolean excludeFromTimeAggregation(DatasetCatalogRepository.Concept concept) { + return concept.defaultExcludeFromTimeAggregation() + || concept.connectors().stream().allMatch(connector -> connector.validityDates().isEmpty()); + } + + private boolean isVisibleStructureNode( + DatasetCatalogRepository.StructureNode node, + Map nodesById + ) { + return !node.containedRoots().isEmpty() || node.children().stream() + .map(nodesById::get) + .filter(Objects::nonNull) + .anyMatch(child -> isVisibleStructureNode(child, nodesById)); + } + + private ConceptsResponse.ConceptSummaryResponse toStructureNodeResponse( + DatasetCatalogRepository.StructureNode node, + Set visibleStructureNodes + ) { + List children = Stream.concat( + node.children().stream().filter(visibleStructureNodes::contains).map(StructureNodeId::toString), + node.containedRoots().stream().map(ConceptId::toString) + ).toList(); + return new ConceptsResponse.ConceptSummaryResponse( + node.label(), + node.description(), + false, + node.parentId() == null ? null : node.parentId().toString(), + children, + 0L, + 0L, + false, + false, + List.of(), + null, + null, + null + ); + } + + + private ConceptResource.ConnectorResponse toTableResponse(DatasetCatalogRepository.Connector connector) { + // TODO combine this with ConceptResource#toConnectorResponse + DatasetCatalogRepository.TableRecord tableRecord = datasetService.requireTable(connector.tableId()); + List supportedSecondaryIds = tableRecord.columns().stream() + .map(DatasetCatalogRepository.ColumnRecord::secondaryId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + return new ConceptResource.ConnectorResponse( + connector.tableId().toString(), + connector.id().toString(), + connector.label(), + connector.isDefault(), + connector.filters().stream().map(this::toFilterResponse).toList(), + connector.selects().stream().map(this::toSelectResponse).toList(), + toDateColumnResponse(connector), + supportedSecondaryIds + ); + } + + private ConceptResource.DateColumnResponse toDateColumnResponse(DatasetCatalogRepository.Connector connector) { + if (connector.validityDates().isEmpty()) { + return null; + } + List options = connector.validityDates().stream() + .map(validityDate -> new ConceptResource.ValueResponse(validityDate.id().toString(), validityDate.label())) + .toList(); + return new ConceptResource.DateColumnResponse(options, options.getFirst().value(), null, connector.validityDatesDescription()); + } + + private ConceptResource.SelectResponse toSelectResponse(DatasetCatalogRepository.Select select) { + return new ConceptResource.SelectResponse( + select.id().toString(), + select.label(), + select.description(), + select.defaultSelected(), + toSelectResultTypeResponse(select.resultType()) + ); + } + + private ConceptResource.SelectResponse toSelectResponse(DatasetCatalogRepository.ConceptSelect select) { + return new ConceptResource.SelectResponse( + select.id().toString(), + select.label(), + select.description(), + select.defaultSelected(), + toSelectResultTypeResponse(select.resultType()) + ); + } + + private ConceptResource.SelectResultTypeResponse toSelectResultTypeResponse(DatasetCatalogRepository.SelectResultType resultType) { + return new ConceptResource.SelectResultTypeResponse( + resultType.type(), + resultType.elementType() == null ? null : new ConceptResource.ElementTypeResponse(resultType.elementType().type()) + ); + } + + private ConceptResource.FilterResponse toFilterResponse(DatasetCatalogRepository.Filter filter) { + return new ConceptResource.FilterResponse( + filter.id().toString(), + filter.label(), + filter.type(), + filter.unit(), + filter.tooltip(), + filter.options().stream() + .map(option -> new ConceptResource.FrontendValue(option.value(), option.label(), option.optionValue())) + .toList(), + filter.min(), + filter.max(), + filter.pattern(), + filter.allowDropFile(), + filter.creatable(), + filter.defaultValue() + ); + } + + private ConceptResource.ColumnResponse toColumnResponse(DatasetCatalogRepository.ColumnRecord column) { + return new ConceptResource.ColumnResponse( + column.id().toString(), + column.label(), + column.type(), + column.secondaryId() + ); + } + + + @GET + @Path("/{datasetId}/form-queries") + @Operation( + summary = "Get form configurations for a dataset", + description = "Returns raw frontend form configuration objects." + ) + public List getFormQueries(@PathParam("datasetId") String datasetId) { + datasetService.requireDataset(datasetId); + return formQueriesConfig.resources().stream().map(this::loadFormResource).toList(); + } + + @GET + @Path("/{datasetId}/queries") + @Operation( + summary = "List queries for a dataset", + description = "Returns the query history list for the given dataset." + ) + public List getQueries(@PathParam("datasetId") String datasetId) { + datasetService.requireDataset(datasetId); + return queryStateService.getDatasetQueries(datasetId); + } + + @POST + @Path("/{datasetId}/queries") + @Operation( + summary = "Create a query for a dataset", + description = "Accepts a query payload and returns the created query id." + ) + public StartQueryResponse postQueries(@PathParam("datasetId") String datasetId, @Valid @NotNull QuerySubmissionPayload payload) { + datasetService.requireDataset(datasetId); + return queryStateService.createQuery(datasetId, payload, SecurityIdentityUtil.resolveUserName(identity)); + } + + @POST + @Path("/{datasetId}/queries/upload") + @Operation( + summary = "Upload query entities", + description = "Uploads entity id rows for query upload workflow." + ) + public UploadQueryResponse uploadQueries( + @PathParam("datasetId") String datasetId, + @Valid @NotNull QueryUploadPayload payload + ) { + datasetService.requireDataset(datasetId); + QueryUploadService.UploadResult result = queryUploadService.processUpload( + new QueryUploadService.QueryUploadPayload(payload.format, payload.values, payload.label) + ); + return new UploadQueryResponse(result.resolved(), result.unresolvedId(), result.unreadableDate()); + } + + @POST + @Path("/{datasetId}/queries/entity") + @Operation( + summary = "Get entity history", + description = "Not implemented yet in the Quarkus backend." + ) + public EntityQueryService.EntityHistoryResponse getEntityHistory( + @PathParam("datasetId") String datasetId, + @Valid @NotNull EntityHistoryRequest payload + ) { + datasetService.requireDataset(datasetId); + return entityQueryService.getEntityHistory( + new EntityQueryService.EntityHistoryRequest( + payload.idKind, + payload.entityId, + payload.time, + payload.sources + ) + ); + } + + @POST + @Path("/{datasetId}/queries/resolve-entities") + @Operation( + summary = "Resolve entities from filter values", + description = "Not implemented yet in the Quarkus backend." + ) + public List> resolveEntities( + @PathParam("datasetId") String datasetId, + @Valid @NotNull List<@Valid FilterValue> payload + ) { + datasetService.requireDataset(datasetId); + return entityQueryService.resolveEntities(payload); + } + + private Object loadFormResource(String path) { + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + try (InputStream input = classLoader.getResourceAsStream(path)) { + if (input == null) { + throw new IllegalStateException("Configured form resource does not exist: " + path); + } + return objectMapper.readValue(input, Object.class); + } + catch (IOException e) { + throw new UncheckedIOException("Failed to parse form resource: " + path, e); + } + } + + public record QuerySummaryResponse( + String id, + String label, + Long numberOfResults, + String createdAt, + List tags, + boolean own, + String ownerName, + boolean system, + List resultUrls, + boolean shared, + boolean canExpand, + String queryType, + String secondaryId, + boolean containsDates + ) { + } + + public record StartQueryResponse( + String id + ) { + } + + public record UploadQueryResponse( + int resolved, + List> unresolvedId, + List> unreadableDate + ) { + } + + public static final class QueryUploadPayload { + public final @NotNull @NotEmpty List<@NotBlank String> format; + public final @NotNull List<@NotNull List<@NotBlank String>> values; + public final @NotBlank String label; + + public QueryUploadPayload(List format, List> values, String label) { + this.format = format; + this.values = values; + this.label = label; + } + } + + public static final class EntityHistoryRequest { + public final @NotBlank String idKind; + public final @NotBlank String entityId; + public final @NotNull @Valid QuerySubmissionPayload.DateRangePayload time; + public final @NotNull @NotEmpty List<@NotBlank String> sources; + + public EntityHistoryRequest(String idKind, String entityId, QuerySubmissionPayload.DateRangePayload time, List sources) { + this.idKind = idKind; + this.entityId = entityId; + this.time = time; + this.sources = sources; + } + } + + public record DatasetResponse( + String id, + String label + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/EntityPreviewResponse.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/EntityPreviewResponse.java new file mode 100644 index 0000000000..dd3386da05 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/EntityPreviewResponse.java @@ -0,0 +1,19 @@ +package com.bakdata.conquery.quarkus.api; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record EntityPreviewResponse( + List all, + @JsonProperty("default") + List defaultSources, + List searchFilters, + String searchConcept +) { + public record LabeledSource( + String name, + String label + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/FilterResource.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/FilterResource.java new file mode 100644 index 0000000000..03c2f3cd7a --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/FilterResource.java @@ -0,0 +1,128 @@ +package com.bakdata.conquery.quarkus.api; + +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.Operation; + +@Path("/api/filters") +@Produces(MediaType.APPLICATION_JSON) +public class FilterResource { + + @Inject + FilterService filterService; + + @POST + @Path("/{filterId}/resolve") + @Operation( + summary = "Resolve uploaded filter values", + description = "Resolves a list of raw values for a given filter id." + ) + public FilterResolveResponse resolve( + @PathParam("filterId") @NotBlank String filterId, + @Valid @NotNull FilterValues payload + ) { + FilterService.FilterResolveResult result = filterService.resolveFilterValues(filterId, payload.values); + return new FilterResolveResponse( + new ResolvedFilterResponse( + result.resolvedFilter().tableId(), + result.resolvedFilter().filterId(), + result.resolvedFilter().value().stream() + .map(value -> new ResolvedFilterValueResponse(value.label(), value.value(), value.optionValue())) + .toList() + ), + result.unknownCodes() + ); + } + + @POST + @Path("/{filterId}/autocomplete") + @Operation( + summary = "Autocomplete filter values", + description = "Returns autocomplete suggestions for a filter." + ) + public AutocompleteResponse autocomplete( + @PathParam("filterId") @NotBlank String filterId, + @Valid @NotNull AutocompleteRequest payload + ) { + FilterService.FilterAutocompleteResult result = filterService.autocomplete(filterId, payload.text, payload.page, payload.pageSize); + return new AutocompleteResponse( + result.total(), + result.values().stream() + .map(value -> new AutocompleteValueResponse( + value.label(), + value.value(), + value.optionValue(), + value.templateValues(), + value.disabled() + )) + .toList() + ); + } + + public static final class FilterValues { + public final @NotNull @NotEmpty List<@NotBlank String> values; + + public FilterValues(List values) { + this.values = values; + } + } + + public static final class AutocompleteRequest { + public final @NotNull Optional text; + public final @NotNull OptionalInt page; + public final @NotNull OptionalInt pageSize; + + public AutocompleteRequest(Optional text, OptionalInt page, OptionalInt pageSize) { + this.text = text; + this.page = page; + this.pageSize = pageSize; + } + } + + public record FilterResolveResponse( + ResolvedFilterResponse resolvedFilter, + List unknownCodes + ) { + } + + public record ResolvedFilterResponse( + String tableId, + String filterId, + List value + ) { + } + + public record ResolvedFilterValueResponse( + String label, + String value, + String optionValue + ) { + } + + public record AutocompleteResponse( + long total, + List values + ) { + } + + public record AutocompleteValueResponse( + String label, + String value, + String optionValue, + java.util.Map templateValues, + boolean disabled + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/FilterService.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/FilterService.java new file mode 100644 index 0000000000..2d77f1cd59 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/FilterService.java @@ -0,0 +1,99 @@ +package com.bakdata.conquery.quarkus.api; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.BadRequestException; + +@ApplicationScoped +public class FilterService { + + public FilterResolveResult resolveFilterValues(String filterId, List values) { + String tableId = extractTableId(filterId); + List resolved = values.stream() + .map(String::trim) + .filter(value -> !value.isEmpty()) + .distinct() + .map(value -> new ResolvedFilterValue(value, value, value)) + .toList(); + + return new FilterResolveResult( + new ResolvedFilter(tableId, filterId, resolved), + List.of() + ); + } + + public FilterAutocompleteResult autocomplete(String filterId, Optional text, OptionalInt page, OptionalInt pageSize) { + int resolvedPage = page.orElse(0); + int resolvedPageSize = pageSize.orElse(50); + if (resolvedPage < 0) { + throw new BadRequestException("page must be >= 0"); + } + if (resolvedPageSize < 1) { + throw new BadRequestException("pageSize must be >= 1"); + } + + String prefix = text.map(String::trim).orElse(""); + List candidates = List.of(prefix, prefix + "1", prefix + "2", prefix + "3") + .stream() + .map(String::trim) + .filter(value -> !value.isEmpty()) + .collect(java.util.stream.Collectors.collectingAndThen( + java.util.stream.Collectors.toCollection(LinkedHashSet::new), + List::copyOf + )); + + int fromIndex = Math.min(resolvedPage * resolvedPageSize, candidates.size()); + int toIndex = Math.min(fromIndex + resolvedPageSize, candidates.size()); + List values = candidates.subList(fromIndex, toIndex) + .stream() + .map(value -> new AutocompleteValue(value, value, value, Map.of(), false)) + .toList(); + + return new FilterAutocompleteResult(values, candidates.size()); + } + + private String extractTableId(String filterId) { + int separatorIndex = filterId.indexOf(':'); + return separatorIndex > 0 ? filterId.substring(0, separatorIndex) : filterId; + } + + public record FilterResolveResult( + ResolvedFilter resolvedFilter, + List unknownCodes + ) { + } + + public record ResolvedFilter( + String tableId, + String filterId, + List value + ) { + } + + public record ResolvedFilterValue( + String label, + String value, + String optionValue + ) { + } + + public record FilterAutocompleteResult( + List values, + long total + ) { + } + + public record AutocompleteValue( + String label, + String value, + String optionValue, + Map templateValues, + boolean disabled + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/FormConfigResource.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/FormConfigResource.java new file mode 100644 index 0000000000..cf405b20b4 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/FormConfigResource.java @@ -0,0 +1,95 @@ +package com.bakdata.conquery.quarkus.api; + +import java.util.List; +import java.util.Map; + +import com.bakdata.conquery.quarkus.services.FormConfigService; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Path("/api/form-configs") +@Produces(MediaType.APPLICATION_JSON) +public class FormConfigResource { + + @Inject + FormConfigService formConfigService; + + @Inject + Instance identity; + + @GET + @Path("/{formConfigId}") + @Operation( + summary = "Get a form configuration", + description = "Returns full form configuration metadata and values." + ) + public FormConfigFullResponse getConfig(@PathParam("formConfigId") String formConfigId) { + return formConfigService.get(formConfigId, SecurityIdentityUtil.resolveUserName(identity)); + } + + @PATCH + @Path("/{formConfigId}") + @Operation( + summary = "Patch a form configuration", + description = "Patches mutable form configuration fields." + ) + public FormConfigFullResponse patchConfig( + @PathParam("formConfigId") String formConfigId, + @Valid FormConfigPatchPayload payload + ) { + return formConfigService.patch(formConfigId, payload, SecurityIdentityUtil.resolveUserName(identity)); + } + + @DELETE + @Path("/{formConfigId}") + @Operation( + summary = "Delete a form configuration", + description = "Deletes a stored form configuration." + ) + public void deleteConfig(@PathParam("formConfigId") String formConfigId) { + formConfigService.delete(formConfigId); + } + + public static final class FormConfigPatchPayload { + public String label; + public List<@NotBlank String> tags; + public List<@NotBlank String> groups; + + @Schema( + description = "Opaque form values payload saved by the frontend.", + implementation = Object.class + ) + public Map values; + } + + public record FormConfigFullResponse( + String id, + String formType, + String label, + List tags, + String ownerName, + String createdAt, + boolean own, + boolean shared, + boolean system, + List groups, + @Schema( + description = "Opaque form values payload saved by the frontend.", + implementation = Object.class + ) + Map values + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/MeResource.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/MeResource.java new file mode 100644 index 0000000000..a313571534 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/MeResource.java @@ -0,0 +1,52 @@ +package com.bakdata.conquery.quarkus.api; + +import java.util.List; +import java.util.Map; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/me") +@Produces(MediaType.APPLICATION_JSON) +public class MeResource { + @Inject + Instance identity; + + @GET + public MeResponse me() { + String userName = SecurityIdentityUtil.resolveUserName(identity); + + return new MeResponse( + userName, + true, + Map.of("imdb", new PermissionFlags(true, true, true)), + List.of() + ); + } + + public record MeResponse( + String userName, + boolean hideLogoutButton, + Map datasetAbilities, + List groups + ) { + } + + public record PermissionFlags( + boolean canUpload, + boolean canViewEntityPreview, + boolean canViewQueryPreview + ) { + } + + public record GroupInfo( + String id, + String label + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/PingResource.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/PingResource.java new file mode 100644 index 0000000000..1ae1d9b6c3 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/PingResource.java @@ -0,0 +1,23 @@ +package com.bakdata.conquery.quarkus.api; + +import java.time.OffsetDateTime; +import java.util.Map; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/ping") +@Produces(MediaType.APPLICATION_JSON) +public class PingResource { + + @GET + public Map ping() { + return Map.of( + "service", "conquery-backend-quarkus", + "status", "ok", + "timestamp", OffsetDateTime.now().toString() + ); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/QueryResource.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/QueryResource.java new file mode 100644 index 0000000000..0f4e173512 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/QueryResource.java @@ -0,0 +1,346 @@ +package com.bakdata.conquery.quarkus.api; + +import java.util.List; + +import com.bakdata.conquery.quarkus.services.QueryStateService; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.DiscriminatorMapping; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Path("/api/queries") +@Produces(MediaType.APPLICATION_JSON) +public class QueryResource { + @Inject + QueryStateService queryStateService; + + @GET + @Path("/{queryId}") + @Operation( + summary = "Get a query by id", + description = "Returns the current query execution status and metadata." + ) + public QueryResponse getQuery(@PathParam("queryId") String queryId) { + return queryStateService.getQuery(queryId); + } + + @POST + @Path("/{queryId}/cancel") + @Operation( + summary = "Cancel a query", + description = "Cancels a running query." + ) + public void cancelQuery(@PathParam("queryId") String queryId) { + queryStateService.cancelQuery(queryId); + } + + @PATCH + @Path("/{queryId}") + @Operation( + summary = "Patch query metadata", + description = "Updates mutable query metadata like label, tags, sharing and groups." + ) + public void patchQuery(@PathParam("queryId") String queryId, QueryPatchPayload payload) { + queryStateService.patchQuery( + queryId, + new QueryStateService.QueryPatch(payload.label, payload.tags, payload.shared, payload.groups) + ); + } + + @DELETE + @Path("/{queryId}") + @Operation( + summary = "Delete a query", + description = "Deletes a query from history." + ) + public void deleteQuery(@PathParam("queryId") String queryId) { + queryStateService.deleteQuery(queryId); + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "status", visible = true) + @JsonSubTypes({ + @JsonSubTypes.Type(value = NewQueryResponse.class, name = "NEW"), + @JsonSubTypes.Type(value = RunningQueryResponse.class, name = "RUNNING"), + @JsonSubTypes.Type(value = DoneQueryResponse.class, name = "DONE"), + @JsonSubTypes.Type(value = FailedQueryResponse.class, name = "FAILED"), + @JsonSubTypes.Type(value = CanceledQueryResponse.class, name = "CANCELED") + }) + @Schema( + description = "Query response with status-based polymorphism.", + discriminatorProperty = "status", + oneOf = { + NewQueryResponse.class, + RunningQueryResponse.class, + DoneQueryResponse.class, + FailedQueryResponse.class, + CanceledQueryResponse.class + }, + discriminatorMapping = { + @DiscriminatorMapping(value = "NEW", schema = NewQueryResponse.class), + @DiscriminatorMapping(value = "RUNNING", schema = RunningQueryResponse.class), + @DiscriminatorMapping(value = "DONE", schema = DoneQueryResponse.class), + @DiscriminatorMapping(value = "FAILED", schema = FailedQueryResponse.class), + @DiscriminatorMapping(value = "CANCELED", schema = CanceledQueryResponse.class) + } + ) + public interface QueryResponse { + } + + public abstract static class BaseQueryResponse implements QueryResponse { + public final String id; + public final String label; + public final String createdAt; + public final boolean own; + public final boolean shared; + public final boolean system; + public final List tags; + public final QueryDefinition query; + public final String secondaryId; + public final String ownerName; + public final List groups; + public final boolean canExpand; + public final List availableSecondaryIds; + public final QueryStatus status; + + protected BaseQueryResponse( + String id, + String label, + String createdAt, + boolean own, + boolean shared, + boolean system, + List tags, + QueryDefinition query, + String secondaryId, + String ownerName, + List groups, + boolean canExpand, + List availableSecondaryIds, + QueryStatus status + ) { + this.id = id; + this.label = label; + this.createdAt = createdAt; + this.own = own; + this.shared = shared; + this.system = system; + this.tags = tags; + this.query = query; + this.secondaryId = secondaryId; + this.ownerName = ownerName; + this.groups = groups; + this.canExpand = canExpand; + this.availableSecondaryIds = availableSecondaryIds; + this.status = status; + } + } + + public static final class NewQueryResponse extends BaseQueryResponse { + public NewQueryResponse( + String id, + String label, + String createdAt, + boolean own, + boolean shared, + boolean system, + List tags, + QueryDefinition query, + String secondaryId, + String ownerName, + List groups, + boolean canExpand, + List availableSecondaryIds + ) { + super(id, label, createdAt, own, shared, system, tags, query, secondaryId, ownerName, groups, canExpand, availableSecondaryIds, QueryStatus.NEW); + } + } + + public static final class RunningQueryResponse extends BaseQueryResponse { + public final Double progress; + + public RunningQueryResponse( + String id, + String label, + String createdAt, + boolean own, + boolean shared, + boolean system, + List tags, + QueryDefinition query, + String secondaryId, + String ownerName, + List groups, + boolean canExpand, + List availableSecondaryIds, + Double progress + ) { + super(id, label, createdAt, own, shared, system, tags, query, secondaryId, ownerName, groups, canExpand, availableSecondaryIds, QueryStatus.RUNNING); + this.progress = progress; + } + } + + public static final class DoneQueryResponse extends BaseQueryResponse { + public final Long numberOfResults; + public final List resultUrls; + public final QueryType queryType; + public final long requiredTime; + public final boolean containsDates; + + public DoneQueryResponse( + String id, + String label, + String createdAt, + boolean own, + boolean shared, + boolean system, + List tags, + QueryDefinition query, + String secondaryId, + String ownerName, + List groups, + boolean canExpand, + List availableSecondaryIds, + Long numberOfResults, + List resultUrls, + QueryType queryType, + long requiredTime, + boolean containsDates + ) { + super(id, label, createdAt, own, shared, system, tags, query, secondaryId, ownerName, groups, canExpand, availableSecondaryIds, QueryStatus.DONE); + this.numberOfResults = numberOfResults; + this.resultUrls = resultUrls; + this.queryType = queryType; + this.requiredTime = requiredTime; + this.containsDates = containsDates; + } + } + + public static final class FailedQueryResponse extends BaseQueryResponse { + public final ErrorResponse error; + + public FailedQueryResponse( + String id, + String label, + String createdAt, + boolean own, + boolean shared, + boolean system, + List tags, + QueryDefinition query, + String secondaryId, + String ownerName, + List groups, + boolean canExpand, + List availableSecondaryIds, + ErrorResponse error + ) { + super(id, label, createdAt, own, shared, system, tags, query, secondaryId, ownerName, groups, canExpand, availableSecondaryIds, QueryStatus.FAILED); + this.error = error; + } + } + + public static final class CanceledQueryResponse extends BaseQueryResponse { + public final ErrorResponse error; + + public CanceledQueryResponse( + String id, + String label, + String createdAt, + boolean own, + boolean shared, + boolean system, + List tags, + QueryDefinition query, + String secondaryId, + String ownerName, + List groups, + boolean canExpand, + List availableSecondaryIds, + ErrorResponse error + ) { + super(id, label, createdAt, own, shared, system, tags, query, secondaryId, ownerName, groups, canExpand, availableSecondaryIds, QueryStatus.CANCELED); + this.error = error; + } + } + + public enum QueryStatus { + NEW, + RUNNING, + DONE, + FAILED, + CANCELED + } + + public enum QueryType { + CONCEPT_QUERY, + SECONDARY_ID_QUERY + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) + @JsonSubTypes({ + @JsonSubTypes.Type(value = ConceptQueryDefinition.class, name = "CONCEPT_QUERY"), + @JsonSubTypes.Type(value = SecondaryIdQueryDefinition.class, name = "SECONDARY_ID_QUERY") + }) + @Schema( + description = "Submitted query definition, discriminated by `type`.", + discriminatorProperty = "type", + oneOf = {ConceptQueryDefinition.class, SecondaryIdQueryDefinition.class}, + discriminatorMapping = { + @DiscriminatorMapping(value = "CONCEPT_QUERY", schema = ConceptQueryDefinition.class), + @DiscriminatorMapping(value = "SECONDARY_ID_QUERY", schema = SecondaryIdQueryDefinition.class) + } + ) + public abstract static class QueryDefinition { + public final QueryType type; + public final QuerySubmissionPayload.QueryNode root; + + protected QueryDefinition(QueryType type, QuerySubmissionPayload.QueryNode root) { + this.type = type; + this.root = root; + } + } + + public static final class ConceptQueryDefinition extends QueryDefinition { + public ConceptQueryDefinition(QuerySubmissionPayload.QueryNode root) { + super(QueryType.CONCEPT_QUERY, root); + } + } + + public static final class SecondaryIdQueryDefinition extends QueryDefinition { + public final String secondaryId; + + public SecondaryIdQueryDefinition(QuerySubmissionPayload.QueryNode root, String secondaryId) { + super(QueryType.SECONDARY_ID_QUERY, root); + this.secondaryId = secondaryId; + } + } + + public record ErrorResponse( + String message, + String code + ) { + } + + public record ResultUrlResponse( + String label, + String url + ) { + } + + public static final class QueryPatchPayload { + public String label; + public List tags; + public Boolean shared; + public List groups; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/QuerySubmissionPayload.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/QuerySubmissionPayload.java new file mode 100644 index 0000000000..bedb995c53 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/QuerySubmissionPayload.java @@ -0,0 +1,252 @@ +package com.bakdata.conquery.quarkus.api; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.validation.Valid; +import org.eclipse.microprofile.openapi.annotations.media.DiscriminatorMapping; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + visible = true, + defaultImpl = QuerySubmissionPayload.GenericQuerySubmissionPayload.class +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = QuerySubmissionPayload.ConceptQuerySubmissionPayload.class, name = "CONCEPT_QUERY"), + @JsonSubTypes.Type(value = QuerySubmissionPayload.SecondaryIdQuerySubmissionPayload.class, name = "SECONDARY_ID_QUERY"), + @JsonSubTypes.Type(value = QuerySubmissionPayload.GenericQuerySubmissionPayload.class, name = "FORM_QUERY") +}) +@Schema( + description = "Query submission payload, discriminated by `type`.", + discriminatorProperty = "type", + oneOf = { + QuerySubmissionPayload.ConceptQuerySubmissionPayload.class, + QuerySubmissionPayload.SecondaryIdQuerySubmissionPayload.class, + QuerySubmissionPayload.GenericQuerySubmissionPayload.class + }, + discriminatorMapping = { + @DiscriminatorMapping(value = "CONCEPT_QUERY", schema = QuerySubmissionPayload.ConceptQuerySubmissionPayload.class), + @DiscriminatorMapping(value = "SECONDARY_ID_QUERY", schema = QuerySubmissionPayload.SecondaryIdQuerySubmissionPayload.class), + @DiscriminatorMapping(value = "FORM_QUERY", schema = QuerySubmissionPayload.GenericQuerySubmissionPayload.class) + } +) +public abstract class QuerySubmissionPayload { + public String type; + + public static final class ConceptQuerySubmissionPayload extends QuerySubmissionPayload { + @Schema(description = "Query root node. Polymorphic and discriminated by field `type`.") + @Valid + public QueryNode root; + + public ConceptQuerySubmissionPayload() { + this.type = "CONCEPT_QUERY"; + } + } + + public static final class SecondaryIdQuerySubmissionPayload extends QuerySubmissionPayload { + public String secondaryId; + @Schema(description = "Query root node. Polymorphic and discriminated by field `type`.") + @Valid + public QueryNode root; + + public SecondaryIdQuerySubmissionPayload() { + this.type = "SECONDARY_ID_QUERY"; + } + } + + public static final class GenericQuerySubmissionPayload extends QuerySubmissionPayload { + public Map values; + + public GenericQuerySubmissionPayload() { + } + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") + @JsonSubTypes({ + @JsonSubTypes.Type(value = AndNode.class, name = "AND"), + @JsonSubTypes.Type(value = OrNode.class, name = "OR"), + @JsonSubTypes.Type(value = NegationNode.class, name = "NEGATION"), + @JsonSubTypes.Type(value = DateRestrictionNode.class, name = "DATE_RESTRICTION"), + @JsonSubTypes.Type(value = ConceptNode.class, name = "CONCEPT"), + @JsonSubTypes.Type(value = SavedQueryNode.class, name = "SAVED_QUERY"), + @JsonSubTypes.Type(value = TemporalNode.class, name = "TEMPORAL"), + @JsonSubTypes.Type(value = ExternalResolvedNode.class, name = "EXTERNAL_RESOLVED") + }) + @Schema( + description = "Base query node. Concrete schema is selected via discriminator `type`.", + discriminatorProperty = "type", + oneOf = { + AndNode.class, + OrNode.class, + NegationNode.class, + DateRestrictionNode.class, + ConceptNode.class, + SavedQueryNode.class, + TemporalNode.class, + ExternalResolvedNode.class + }, + discriminatorMapping = { + @DiscriminatorMapping(value = "AND", schema = AndNode.class), + @DiscriminatorMapping(value = "OR", schema = OrNode.class), + @DiscriminatorMapping(value = "NEGATION", schema = NegationNode.class), + @DiscriminatorMapping(value = "DATE_RESTRICTION", schema = DateRestrictionNode.class), + @DiscriminatorMapping(value = "CONCEPT", schema = ConceptNode.class), + @DiscriminatorMapping(value = "SAVED_QUERY", schema = SavedQueryNode.class), + @DiscriminatorMapping(value = "TEMPORAL", schema = TemporalNode.class), + @DiscriminatorMapping(value = "EXTERNAL_RESOLVED", schema = ExternalResolvedNode.class) + } + ) + public abstract static class QueryNode { + } + + @Schema(name = "AndQueryNode") + public static class AndNode extends QueryNode { + public List<@Valid QueryNode> children; + } + + @Schema(name = "OrQueryNode") + public static class OrNode extends QueryNode { + public List<@Valid QueryNode> children; + } + + @Schema(name = "NegationQueryNode") + public static class NegationNode extends QueryNode { + @Valid + public QueryNode child; + } + + @Schema(name = "DateRestrictionQueryNode") + public static class DateRestrictionNode extends QueryNode { + @Valid + public DateRangePayload dateRange; + @Valid + public QueryNode child; + } + + @Schema(name = "ConceptQueryNode") + public static class ConceptNode extends QueryNode { + public List ids; + public String label; + public Boolean excludeFromTimeAggregation; + public Boolean excludeFromSecondaryId; + public List<@Valid TableConfigPayload> tables; + public List selects; + } + + @Schema(name = "SavedQueryNode") + public static class SavedQueryNode extends QueryNode { + public String query; + } + + @Schema(name = "TemporalQueryNode") + public static class TemporalNode extends QueryNode { + public TemporalModePayload mode; + public QueryNode index; + public String indexSelector; + public QueryNode compare; + public String compareSelector; + } + + @Schema(name = "ExternalResolvedQueryNode") + public static class ExternalResolvedNode extends QueryNode { + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) + @JsonSubTypes({ + @JsonSubTypes.Type(value = BeforeTemporalModePayload.class, name = "BEFORE"), + @JsonSubTypes.Type(value = AfterTemporalModePayload.class, name = "AFTER"), + @JsonSubTypes.Type(value = WhileTemporalModePayload.class, name = "WHILE") + }) + @Schema( + description = "Temporal mode, discriminated by `type`.", + discriminatorProperty = "type", + oneOf = { + BeforeTemporalModePayload.class, + AfterTemporalModePayload.class, + WhileTemporalModePayload.class + }, + discriminatorMapping = { + @DiscriminatorMapping(value = "BEFORE", schema = BeforeTemporalModePayload.class), + @DiscriminatorMapping(value = "AFTER", schema = AfterTemporalModePayload.class), + @DiscriminatorMapping(value = "WHILE", schema = WhileTemporalModePayload.class) + } + ) + public abstract static class TemporalModePayload { + public TemporalModeType type; + + protected TemporalModePayload() { + } + + protected TemporalModePayload(TemporalModeType type) { + this.type = type; + } + } + + public static final class BeforeTemporalModePayload extends TemporalModePayload { + public DayRangePayload days; + + public BeforeTemporalModePayload() { + super(TemporalModeType.BEFORE); + } + + public BeforeTemporalModePayload(DayRangePayload days) { + this(); + this.days = days; + } + } + + public static final class AfterTemporalModePayload extends TemporalModePayload { + public DayRangePayload days; + + public AfterTemporalModePayload() { + super(TemporalModeType.AFTER); + } + + public AfterTemporalModePayload(DayRangePayload days) { + this(); + this.days = days; + } + } + + public static final class WhileTemporalModePayload extends TemporalModePayload { + public WhileTemporalModePayload() { + super(TemporalModeType.WHILE); + } + } + + public enum TemporalModeType { + BEFORE, + AFTER, + WHILE + } + + public static class DayRangePayload { + public Integer min; + public Integer max; + } + + public static class DateRangePayload { + public LocalDate min; + public LocalDate max; + } + + public static class TableConfigPayload { + public String id; + @Valid + public DateColumnConfigPayload dateColumn; + public List selects; + public List<@Valid FilterValue> filters; + } + + public static class DateColumnConfigPayload { + public String value; + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/SecurityIdentityUtil.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/SecurityIdentityUtil.java new file mode 100644 index 0000000000..ebe35c9429 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/api/SecurityIdentityUtil.java @@ -0,0 +1,24 @@ +package com.bakdata.conquery.quarkus.api; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.inject.Instance; + +public final class SecurityIdentityUtil { + + private SecurityIdentityUtil() { + } + + public static String resolveUserName(Instance identityInstance) { + if (identityInstance.isResolvable()) { + SecurityIdentity identity = identityInstance.get(); + if (identity != null && !identity.isAnonymous() && identity.getPrincipal() != null) { + String principalName = identity.getPrincipal().getName(); + if (principalName != null && !principalName.isBlank()) { + return principalName; + } + } + } + + return "anonymous"; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/ConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/ConceptCondition.java new file mode 100644 index 0000000000..7cc4342b02 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/ConceptCondition.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.concepts.conditions; + +import com.bakdata.conquery.quarkus.models.PolymorphicModelBase; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + visible = true, + defaultImpl = UnknownConceptCondition.class +) +@Schema(name = "MetadataConceptCondition", description = "Condition assigned to a concept-tree node. The concrete model is selected by `type`.", discriminatorProperty = "type") +@PolymorphicModelBase( + schemaName = "MetadataConceptCondition", + description = "Condition assigned to a concept-tree node. The concrete model is selected by `type`." +) +public interface ConceptCondition { + + String getType(); +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/ConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/ConceptConditionProvider.java new file mode 100644 index 0000000000..29343669d6 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/ConceptConditionProvider.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.conditions; + +import com.bakdata.conquery.quarkus.models.PolymorphicModelTypeProvider; + +public interface ConceptConditionProvider extends PolymorphicModelTypeProvider { + + default String type() { + return typeId(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/UnknownConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/UnknownConceptCondition.java new file mode 100644 index 0000000000..5bf2a256fc --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/UnknownConceptCondition.java @@ -0,0 +1,38 @@ +package com.bakdata.conquery.quarkus.concepts.conditions; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import jakarta.validation.constraints.AssertTrue; + +public final class UnknownConceptCondition implements ConceptCondition { + + private String type; + private final Map properties = new LinkedHashMap<>(); + + @Override + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + @JsonAnySetter + public void put(String name, Object value) { + properties.put(name, value); + } + + @JsonAnyGetter + public Map properties() { + return Map.copyOf(properties); + } + + @AssertTrue(message = "must use a registered concept condition type") + public boolean isRegisteredType() { + return false; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/AbstractConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/AbstractConceptCondition.java new file mode 100644 index 0000000000..9cf0553226 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/AbstractConceptCondition.java @@ -0,0 +1,16 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.definitions; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +public abstract class AbstractConceptCondition implements ConceptCondition { + + @Schema(description = "Concept-condition implementation discriminator.") + private String type; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/AndConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/AndConceptCondition.java new file mode 100644 index 0000000000..599744315e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/AndConceptCondition.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataAndConceptCondition", description = "Matches when all nested conditions match.") +@PolymorphicModelSubtype(base = ConceptCondition.class, id = "AND") +public final class AndConceptCondition extends AbstractConceptCondition { + + @Valid + @NotEmpty + private List conditions; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/ColumnEqualConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/ColumnEqualConceptCondition.java new file mode 100644 index 0000000000..3c95aab596 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/ColumnEqualConceptCondition.java @@ -0,0 +1,24 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataColumnEqualConceptCondition", description = "Matches when another column of a record/event has one of the configured values.") +@PolymorphicModelSubtype(base = ConceptCondition.class, id = "COLUMN_EQUAL") +public final class ColumnEqualConceptCondition extends AbstractConceptCondition { + + @NotEmpty + private List values; + + @NotBlank + private String column; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/EqualConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/EqualConceptCondition.java new file mode 100644 index 0000000000..8bc6702a83 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/EqualConceptCondition.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataEqualConceptCondition", description = "Matches when a value is contained exactly in this list.") +@PolymorphicModelSubtype(base = ConceptCondition.class, id = "EQUAL") +public final class EqualConceptCondition extends AbstractConceptCondition { + + @NotEmpty + private List values; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/NotConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/NotConceptCondition.java new file mode 100644 index 0000000000..d096e3aa35 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/NotConceptCondition.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.definitions; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataNotConceptCondition", description = "Matches when its nested condition does not match.") +@PolymorphicModelSubtype(base = ConceptCondition.class, id = "NOT") +public final class NotConceptCondition extends AbstractConceptCondition { + + @Valid + @NotNull + private ConceptCondition condition; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/OrConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/OrConceptCondition.java new file mode 100644 index 0000000000..975003c66c --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/OrConceptCondition.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataOrConceptCondition", description = "Matches when at least one nested condition matches.") +@PolymorphicModelSubtype(base = ConceptCondition.class, id = "OR") +public final class OrConceptCondition extends AbstractConceptCondition { + + @Valid + @NotEmpty + private List conditions; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/PrefixListConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/PrefixListConceptCondition.java new file mode 100644 index 0000000000..9a100c7080 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/PrefixListConceptCondition.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataPrefixListConceptCondition", description = "Matches values beginning with one of the configured prefixes.") +@PolymorphicModelSubtype(base = ConceptCondition.class, id = "PREFIX_LIST") +public final class PrefixListConceptCondition extends AbstractConceptCondition { + + @NotEmpty + private List prefixes; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/PrefixRangeConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/PrefixRangeConceptCondition.java new file mode 100644 index 0000000000..147303a2b9 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/PrefixRangeConceptCondition.java @@ -0,0 +1,27 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.definitions; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataPrefixRangeConceptCondition", description = "Matches fixed-length prefixes inside an inclusive lexical range.") +@PolymorphicModelSubtype(base = ConceptCondition.class, id = "PREFIX_RANGE") +public final class PrefixRangeConceptCondition extends AbstractConceptCondition { + + @NotBlank + private String min; + + @NotBlank + private String max; + + @AssertTrue(message = "min and max must have equal lengths and min must be smaller than or equal to max") + public boolean isValidRange() { + return min == null || max == null || min.length() == max.length() && min.compareTo(max) <= 0; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/PresentConceptCondition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/PresentConceptCondition.java new file mode 100644 index 0000000000..99fdeb06d2 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/definitions/PresentConceptCondition.java @@ -0,0 +1,18 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.definitions; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataPresentConceptCondition", description = "Matches when the configured column in the same record/event has a value present.") +@PolymorphicModelSubtype(base = ConceptCondition.class, id = "PRESENT") +public final class PresentConceptCondition extends AbstractConceptCondition { + + @NotBlank + private String column; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/AbstractConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/AbstractConceptConditionProvider.java new file mode 100644 index 0000000000..a5bb96d458 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/AbstractConceptConditionProvider.java @@ -0,0 +1,18 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.providers; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptConditionProvider; + +abstract class AbstractConceptConditionProvider implements ConceptConditionProvider { + + private final Class modelType; + + protected AbstractConceptConditionProvider(Class modelType) { + this.modelType = modelType; + } + + @Override + public Class modelType() { + return modelType; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/AndConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/AndConceptConditionProvider.java new file mode 100644 index 0000000000..c9c05ecdfd --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/AndConceptConditionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.providers; + +import com.bakdata.conquery.quarkus.concepts.conditions.definitions.AndConceptCondition; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class AndConceptConditionProvider extends AbstractConceptConditionProvider { + + public AndConceptConditionProvider() { + super(AndConceptCondition.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/ColumnEqualConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/ColumnEqualConceptConditionProvider.java new file mode 100644 index 0000000000..307d424e5e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/ColumnEqualConceptConditionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.providers; + +import com.bakdata.conquery.quarkus.concepts.conditions.definitions.ColumnEqualConceptCondition; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class ColumnEqualConceptConditionProvider extends AbstractConceptConditionProvider { + + public ColumnEqualConceptConditionProvider() { + super(ColumnEqualConceptCondition.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/EqualConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/EqualConceptConditionProvider.java new file mode 100644 index 0000000000..dde4038ca4 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/EqualConceptConditionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.providers; + +import com.bakdata.conquery.quarkus.concepts.conditions.definitions.EqualConceptCondition; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class EqualConceptConditionProvider extends AbstractConceptConditionProvider { + + public EqualConceptConditionProvider() { + super(EqualConceptCondition.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/NotConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/NotConceptConditionProvider.java new file mode 100644 index 0000000000..5504e93dac --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/NotConceptConditionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.providers; + +import com.bakdata.conquery.quarkus.concepts.conditions.definitions.NotConceptCondition; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class NotConceptConditionProvider extends AbstractConceptConditionProvider { + + public NotConceptConditionProvider() { + super(NotConceptCondition.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/OrConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/OrConceptConditionProvider.java new file mode 100644 index 0000000000..2e02620e3a --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/OrConceptConditionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.providers; + +import com.bakdata.conquery.quarkus.concepts.conditions.definitions.OrConceptCondition; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class OrConceptConditionProvider extends AbstractConceptConditionProvider { + + public OrConceptConditionProvider() { + super(OrConceptCondition.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/PrefixListConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/PrefixListConceptConditionProvider.java new file mode 100644 index 0000000000..3d5835abbd --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/PrefixListConceptConditionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.providers; + +import com.bakdata.conquery.quarkus.concepts.conditions.definitions.PrefixListConceptCondition; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class PrefixListConceptConditionProvider extends AbstractConceptConditionProvider { + + public PrefixListConceptConditionProvider() { + super(PrefixListConceptCondition.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/PrefixRangeConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/PrefixRangeConceptConditionProvider.java new file mode 100644 index 0000000000..c62075e151 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/PrefixRangeConceptConditionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.providers; + +import com.bakdata.conquery.quarkus.concepts.conditions.definitions.PrefixRangeConceptCondition; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class PrefixRangeConceptConditionProvider extends AbstractConceptConditionProvider { + + public PrefixRangeConceptConditionProvider() { + super(PrefixRangeConceptCondition.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/PresentConceptConditionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/PresentConceptConditionProvider.java new file mode 100644 index 0000000000..032a271e08 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/conditions/providers/PresentConceptConditionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.conditions.providers; + +import com.bakdata.conquery.quarkus.concepts.conditions.definitions.PresentConceptCondition; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class PresentConceptConditionProvider extends AbstractConceptConditionProvider { + + public PresentConceptConditionProvider() { + super(PresentConceptCondition.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterConversionContext.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterConversionContext.java new file mode 100644 index 0000000000..d633def356 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterConversionContext.java @@ -0,0 +1,63 @@ +package com.bakdata.conquery.quarkus.concepts.filters; + +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.ids.ConnectorId; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.ids.IdPartSanitizer; +import com.bakdata.conquery.quarkus.ids.TableId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +public record FilterConversionContext( + ConnectorId connectorId, + TableId tableId, + DatasetCatalogRepository.TableRecord table, + FallbackReporter fallbackReporter +) { + + public FilterConversionContext(ConnectorId connectorId, TableId tableId, DatasetCatalogRepository.TableRecord table) { + this(connectorId, tableId, table, (idType, context, fallbackValue, sanitized) -> { + }); + } + + public String idPartFromPreferredOrFallback(String preferred, String fallback, String idType, Object fallbackContext) { + if (preferred != null && !preferred.isBlank()) { + return preferred.trim(); + } + if (fallback == null || fallback.isBlank()) { + throw new IllegalArgumentException("Cannot derive " + idType + " for " + fallbackContext + " because preferred and fallback values are blank."); + } + String fallbackValue = fallback.trim(); + String sanitized = IdPartSanitizer.sanitize(fallbackValue, idType + " fallback"); + fallbackReporter.record(idType, fallbackContext, fallbackValue, sanitized); + return sanitized; + } + + public FilterId filterId(String name) { + return new FilterId(connectorId, name); + } + + public ColumnId columnId(String columnName) { + if (columnName == null || columnName.isBlank()) { + throw new IllegalArgumentException("Filter column in connector '" + connectorId + "' must not be blank."); + } + if (columnName.contains(".")) { + throw new IllegalArgumentException("Filter column in connector '" + connectorId + "' must be a local column name without dots: " + columnName); + } + ColumnId columnId = new ColumnId(tableId, columnName); + columnType(columnId); + return columnId; + } + + public DatasetCatalogRepository.ColumnType columnType(ColumnId columnId) { + return table.columns().stream() + .filter(column -> column.id().equals(columnId)) + .map(DatasetCatalogRepository.ColumnRecord::type) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown filter column '" + columnId + "' in table '" + tableId + "'.")); + } + + @FunctionalInterface + public interface FallbackReporter { + void record(String idType, Object context, String fallbackValue, String sanitized); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinition.java new file mode 100644 index 0000000000..dd0d17ddd4 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinition.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.concepts.filters; + +import com.bakdata.conquery.quarkus.models.PolymorphicModelBase; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + visible = true, + defaultImpl = UnknownFilterDefinition.class +) +@Schema(name = "MetadataFilterDefinition", description = "Filter definition used in connector metadata. The concrete model is selected by `type`.", discriminatorProperty = "type") +@PolymorphicModelBase( + schemaName = "MetadataFilterDefinition", + description = "Filter definition used in connector metadata. The concrete model is selected by `type`." +) +public interface FilterDefinition { + + String getType(); +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinitionAssembler.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinitionAssembler.java new file mode 100644 index 0000000000..ab3bd2cc1d --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinitionAssembler.java @@ -0,0 +1,81 @@ +package com.bakdata.conquery.quarkus.concepts.filters; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValueProvider; +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValueRegistry; +import com.bakdata.conquery.quarkus.ids.ConnectorId; +import com.bakdata.conquery.quarkus.ids.TableId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; + +@ApplicationScoped +@Slf4j +public class FilterDefinitionAssembler { + + @Inject + FilterDefinitionRegistry registry; + + @Inject + ObjectMapper objectMapper; + + @Inject + FilterValueRegistry filterValueRegistry; + + public List assemble( + ConnectorId connectorId, + TableId tableId, + DatasetCatalogRepository.TableRecord table, + List definitions, + FilterConversionContext.FallbackReporter fallbackReporter, + boolean strictFilterTypes + ) { + if (definitions == null) { + return List.of(); + } + + FilterConversionContext context = new FilterConversionContext(connectorId, tableId, table, fallbackReporter); + List filters = new ArrayList<>(); + for (FilterDefinition definition : definitions) { + assemble(context, definition, strictFilterTypes).ifPresent(filters::add); + } + return List.copyOf(filters); + } + + private Optional assemble(FilterConversionContext context, FilterDefinition definition, boolean strictFilterTypes) { + Optional> provider = registry.find(definition); + if (provider.isPresent()) { + return Optional.of(convert(context, definition, provider.get())); + } + + String type = definition.getType(); + String reason = type == null || type.isBlank() ? "missing filter type" : "unknown filter type '" + type + "'"; + String message = "Skipping filter for connector '" + context.connectorId() + "' because of " + reason + ": " + objectMapper.valueToTree(definition); + if (strictFilterTypes) { + throw new IllegalStateException(message); + } + log.warn("{}", message); + return Optional.empty(); + } + + private DatasetCatalogRepository.Filter convert(FilterConversionContext context, FilterDefinition definition, FilterDefinitionProvider provider) { + T payload = provider.modelType().cast(definition); + DatasetCatalogRepository.Filter filter = provider.convert(context, payload); + Set acceptedTypes = provider.acceptedValueTypes().stream() + .map(filterValueRegistry::require) + .map(FilterValueProvider::type) + .collect(Collectors.toUnmodifiableSet()); + if (!acceptedTypes.contains(filter.type())) { + throw new IllegalStateException("Filter provider " + provider.getClass().getName() + + " produced filter value type '" + filter.type() + "' but accepts " + acceptedTypes); + } + return filter; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinitionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinitionProvider.java new file mode 100644 index 0000000000..e0f4f279bc --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinitionProvider.java @@ -0,0 +1,55 @@ +package com.bakdata.conquery.quarkus.concepts.filters; + +import java.util.Set; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.models.PolymorphicModelTypeProvider; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +/** + * Registers and assembles one polymorphic metadata filter definition. + *

+ * Implementations are CDI beans. The polymorphic model registry uses {@link #modelType()} and its + * {@code @PolymorphicModelSubtype} annotation to + * configure Jackson and OpenAPI, while the metadata loader uses {@link #convert(FilterConversionContext, + * FilterDefinition)} to turn the file-facing definition into the catalog model exposed to the frontend. + * + * @param concrete filter definition handled by this provider + */ +public interface FilterDefinitionProvider extends PolymorphicModelTypeProvider { + + /** + * @return discriminator used in metadata JSON to select this provider + */ + default String type() { + return typeId(); + } + + /** + * Declares every query value model that filters produced by this provider may accept. All returned classes must + * have a registered filter value provider. The assembler verifies that the serialized type of the converted filter + * belongs to this set. + * + * @return non-empty set of accepted filter value model classes + */ + Set> acceptedValueTypes(); + + /** + * Converts a deserialized metadata definition into the catalog filter consumed by the API and frontend. + *

+ * The payload has already been selected by its discriminator and cast to {@link #modelType()}. Metadata bean + * validation is performed before assembly. Implementations remain responsible for semantic conversion, including + * resolving local column names and constructing hierarchical IDs through the supplied context. + *

+ * The returned filter must be complete and non-null. Its serialized filter value type must correspond to a + * registered filter value provider for one of {@link #acceptedValueTypes()}; violating this invariant aborts metadata + * loading. Invalid references or unsupported metadata should likewise be reported by throwing an exception with + * enough context to identify the definition. + * + * @param context connector, table, column-resolution, and ID-fallback context for this definition + * @param payload concrete file-facing definition to convert + * @return fully assembled catalog filter + */ + DatasetCatalogRepository.Filter convert(FilterConversionContext context, T payload); + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinitionRegistry.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinitionRegistry.java new file mode 100644 index 0000000000..eb0d3987e8 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/FilterDefinitionRegistry.java @@ -0,0 +1,43 @@ +package com.bakdata.conquery.quarkus.concepts.filters; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValueRegistry; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +@ApplicationScoped +public class FilterDefinitionRegistry { + + @Inject + Instance> providers; + + @Inject + FilterValueRegistry filterValueRegistry; + + private Map, FilterDefinitionProvider> providersByModelType = Map.of(); + + @PostConstruct + void init() { + Map, FilterDefinitionProvider> index = new LinkedHashMap<>(); + for (FilterDefinitionProvider provider : providers) { + if (provider.acceptedValueTypes().isEmpty()) { + throw new IllegalStateException("Filter provider " + provider.getClass().getName() + " does not declare any accepted filter value types"); + } + provider.acceptedValueTypes().forEach(filterValueRegistry::require); + FilterDefinitionProvider previous = index.put(provider.modelType(), provider); + if (previous != null) { + throw new IllegalStateException("Duplicate filter provider for model type " + provider.modelType().getName() + ": " + previous.getClass().getName() + " and " + provider.getClass().getName()); + } + } + providersByModelType = Map.copyOf(index); + } + + public Optional> find(FilterDefinition definition) { + return Optional.ofNullable(providersByModelType.get(definition.getClass())); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/StaticFrontendValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/StaticFrontendValue.java new file mode 100644 index 0000000000..11792645a9 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/StaticFrontendValue.java @@ -0,0 +1,8 @@ +package com.bakdata.conquery.quarkus.concepts.filters; + +public record StaticFrontendValue( + String value, + String label, + String optionValue +) { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/UnknownFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/UnknownFilterDefinition.java new file mode 100644 index 0000000000..5df9882a24 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/UnknownFilterDefinition.java @@ -0,0 +1,32 @@ +package com.bakdata.conquery.quarkus.concepts.filters; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; + +public final class UnknownFilterDefinition implements FilterDefinition { + + private String type; + private final Map properties = new LinkedHashMap<>(); + + @Override + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + @JsonAnySetter + public void put(String name, Object value) { + properties.put(name, value); + } + + @JsonAnyGetter + public Map properties() { + return Map.copyOf(properties); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/AbstractFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/AbstractFilterDefinition.java new file mode 100644 index 0000000000..d657799dcf --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/AbstractFilterDefinition.java @@ -0,0 +1,45 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +public abstract class AbstractFilterDefinition implements FilterDefinition { + + @Schema(description = "Filter implementation discriminator.") + private String type; + + @Schema(description = "Stable local name used to form the filter id. Falls back to the label when omitted.", pattern = "^\\w+$") + private String name; + + @Schema(description = "Label displayed to users.") + private String label; + + @JsonAlias("description") + @Schema(description = "Additional explanation displayed for the filter.") + private String tooltip; + + @Schema(description = "Unit displayed with filter values.") + private String unit; + + @Schema(description = "Optional input validation pattern.") + private String pattern; + + @Schema(description = "Whether values may be supplied using a dropped file.") + private Boolean allowDropFile; + + @Schema(description = "Default frontend value.") + private Object defaultValue; + + @Schema(description = "Optional minimum frontend value.") + private Integer min; + + @Schema(description = "Optional maximum frontend value.") + private Integer max; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/BigMultiSelectFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/BigMultiSelectFilterDefinition.java new file mode 100644 index 0000000000..2bc4c3d8d3 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/BigMultiSelectFilterDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataBigMultiSelectFilter", description = "Selection filter whose values are fetched by the frontend.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "BIG_MULTI_SELECT") +public final class BigMultiSelectFilterDefinition extends SelectFilterDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/CategoryMaxSumFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/CategoryMaxSumFilterDefinition.java new file mode 100644 index 0000000000..00c1c7cdf6 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/CategoryMaxSumFilterDefinition.java @@ -0,0 +1,24 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import com.fasterxml.jackson.annotation.JsonAlias; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataCategoryMaxSumFilter", description = "Numeric range filter over category-wise maximum sums.") +// TODO May rename this to DistincMaxSumFilter +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "CATEGORY_MAX_SUM") +public final class CategoryMaxSumFilterDefinition extends AbstractFilterDefinition { + @Schema(description = "Category column.") + @JsonAlias("category") + private String categoryColumn; + @Schema(description = "Value column.") + @JsonAlias("value") + private String valueColumn; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/CountFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/CountFilterDefinition.java new file mode 100644 index 0000000000..8ec2c5dd9b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/CountFilterDefinition.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataCountFilter", description = "Range filter over an event or distinct-value count.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "COUNT") +public final class CountFilterDefinition extends SingleColumnFilterDefinition { + @Schema(description = "Optional local columns whose value combinations are counted distinctly.") + private List distinctByColumn; + + // TODO remove this flag, decide if distinct "implicit" on value of distinctByColumn + @Schema(description = "Whether the configured count column itself is counted distinctly.") + private boolean distinct; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/CountQuartersFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/CountQuartersFilterDefinition.java new file mode 100644 index 0000000000..572ebcfd2b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/CountQuartersFilterDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataCountQuartersFilter", description = "Range filter over the number of quarters covered by a date range.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "COUNT_QUARTERS") +public final class CountQuartersFilterDefinition extends SingleColumnFilterDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/DateDistanceFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/DateDistanceFilterDefinition.java new file mode 100644 index 0000000000..df64e5bcfb --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/DateDistanceFilterDefinition.java @@ -0,0 +1,16 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataDateDistanceFilter", description = "Range filter over the distance between a date and the reference date.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "DATE_DISTANCE") +public final class DateDistanceFilterDefinition extends SingleColumnFilterDefinition { + @Schema(description = "Unit used to measure the date distance, for example YEARS or DAYS.", defaultValue = "YEARS") + private String timeUnit; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/DurationSumFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/DurationSumFilterDefinition.java new file mode 100644 index 0000000000..1c43535674 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/DurationSumFilterDefinition.java @@ -0,0 +1,18 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataDurationSumFilter", description = "Range filter over summed date-range durations.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "DURATION_SUM") +public final class DurationSumFilterDefinition extends SingleColumnFilterDefinition { + @Schema(description = "Local columns used to remove duplicate date ranges before summing durations.") + private List distinctBy; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/FlagsFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/FlagsFilterDefinition.java new file mode 100644 index 0000000000..365867f726 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/FlagsFilterDefinition.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import java.util.Map; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataFlagsFilter", description = "Multi-selection filter backed by one boolean column per option.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "FLAGS") +public final class FlagsFilterDefinition extends AbstractFilterDefinition { + @NotEmpty + @Schema(description = "Mapping from selectable labels to local boolean columns.", required = true) + private Map flags; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/MultiSelectFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/MultiSelectFilterDefinition.java new file mode 100644 index 0000000000..1e15382bb1 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/MultiSelectFilterDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataMultiSelectFilter", description = "Selection filter whose values may be embedded in the concept metadata.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "SELECT") +public final class MultiSelectFilterDefinition extends SelectFilterDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/NumberFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/NumberFilterDefinition.java new file mode 100644 index 0000000000..6d41eea6dd --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/NumberFilterDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataNumberFilter", description = "Numeric range filter over one column.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "NUMBER") +public final class NumberFilterDefinition extends SingleColumnFilterDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/PrefixTextFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/PrefixTextFilterDefinition.java new file mode 100644 index 0000000000..730716dae0 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/PrefixTextFilterDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataPrefixTextFilter", description = "Text-prefix filter over one string column.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "PREFIX_TEXT") +public final class PrefixTextFilterDefinition extends SingleColumnFilterDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/QuartersInYearFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/QuartersInYearFilterDefinition.java new file mode 100644 index 0000000000..98479e1cc4 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/QuartersInYearFilterDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataQuartersInYearFilter", description = "Range filter over the quarter of a date.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "QUARTERS_IN_YEAR") +public final class QuartersInYearFilterDefinition extends SingleColumnFilterDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SelectFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SelectFilterDefinition.java new file mode 100644 index 0000000000..8a79be14c8 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SelectFilterDefinition.java @@ -0,0 +1,19 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import java.util.List; +import java.util.Map; + +import com.bakdata.conquery.quarkus.concepts.filters.StaticFrontendValue; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +public abstract class SelectFilterDefinition extends SingleColumnFilterDefinition { + + @Schema(description = "Inline mapping from stored values to labels displayed by the frontend.") + private Map labels; + @Schema(description = "Inline frontend options. Takes precedence over labels when present.") + private List options; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SingleColumnFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SingleColumnFilterDefinition.java new file mode 100644 index 0000000000..c8c71862ef --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SingleColumnFilterDefinition.java @@ -0,0 +1,15 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +public abstract class SingleColumnFilterDefinition extends AbstractFilterDefinition { + + @NotBlank + @Schema(description = "Local name of a column in the connector table.", required = true, pattern = "^\\w+$") + private String column; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SingleSelectFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SingleSelectFilterDefinition.java new file mode 100644 index 0000000000..1c5acb0be5 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SingleSelectFilterDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataSingleSelectFilter", description = "Selection filter accepting one value.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "SINGLE_SELECT") +public final class SingleSelectFilterDefinition extends SelectFilterDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SumFilterDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SumFilterDefinition.java new file mode 100644 index 0000000000..ba2a7e1293 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/definitions/SumFilterDefinition.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.filters.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataSumFilter", description = "Numeric range filter over a sum or difference of sums.") +@PolymorphicModelSubtype(base = FilterDefinition.class, id = "SUM") +public final class SumFilterDefinition extends SingleColumnFilterDefinition { + @Schema(description = "Optional local column subtracted from the primary sum column.") + private String subtractColumn; + @Schema(description = "Local columns used to remove duplicate values before summing.") + private List distinctByColumn; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/AbstractFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/AbstractFilterProvider.java new file mode 100644 index 0000000000..2c25b0e140 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/AbstractFilterProvider.java @@ -0,0 +1,154 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.FilterDefinitionProvider; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.AbstractFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.SelectFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.SingleColumnFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValueRegistry; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.MoneyRangeFilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.RealRangeFilterValue; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.inject.Inject; + +abstract class AbstractFilterProvider implements FilterDefinitionProvider { + + private final Class modelType; + private final Set> acceptedValueTypes; + + @Inject + FilterValueRegistry filterValueRegistry; + + @SafeVarargs + protected AbstractFilterProvider(Class modelType, Class... acceptedValueTypes) { + this.modelType = modelType; + this.acceptedValueTypes = Set.of(acceptedValueTypes); + } + + @Override + public Class modelType() { + return modelType; + } + + @Override + public Set> acceptedValueTypes() { + return acceptedValueTypes; + } + + protected DatasetCatalogRepository.Filter filter( + FilterConversionContext context, + T payload, + Class valueType, + Integer min, + Integer max, + boolean creatable, + List requiredColumns + ) { + String name = context.idPartFromPreferredOrFallback(payload.getName(), payload.getLabel(), "filter id", type()); + String label = firstNonBlank(payload.getLabel(), payload.getName()).orElse(name); + FilterId id = context.filterId(name); + String frontendType = frontendType(valueType); + return new DatasetCatalogRepository.Filter( + id, + label, + frontendType, + payload.getUnit(), + payload.getTooltip(), + options(payload), + min == null ? payload.getMin() : min, + max == null ? payload.getMax() : max, + payload.getPattern(), + Boolean.TRUE.equals(payload.getAllowDropFile()), + creatable, + payload.getDefaultValue(), + requiredColumns + ); + } + + protected String frontendType(Class valueType) { + if (!acceptedValueTypes.contains(valueType)) { + throw new IllegalArgumentException("Filter provider " + getClass().getName() + + " does not accept filter value type " + valueType.getName()); + } + return filterValueRegistry.require(valueType).type(); + } + + protected ColumnId requiredColumn(FilterConversionContext context, SingleColumnFilterDefinition payload) { + String column = firstNonBlank(payload.getColumn()) + .orElseThrow(() -> new IllegalArgumentException("Filter " + type() + " must define column.")); + return context.columnId(column); + } + + protected List optionalColumns(FilterConversionContext context, List values) { + if (values == null) { + return List.of(); + } + return values.stream().map(context::columnId).toList(); + } + + protected List options(AbstractFilterDefinition payload) { + if (payload instanceof SelectFilterDefinition select && select.getOptions() != null) { + return select.getOptions().stream() + .map(value -> new DatasetCatalogRepository.FrontendValue(value.value(), value.label(), value.optionValue())) + .toList(); + } + if (payload instanceof SelectFilterDefinition select && select.getLabels() != null) { + return select.getLabels().entrySet().stream() + .map(entry -> new DatasetCatalogRepository.FrontendValue(entry.getKey(), entry.getValue(), entry.getKey())) + .toList(); + } + if (payload instanceof com.bakdata.conquery.quarkus.concepts.filters.definitions.FlagsFilterDefinition flags && flags.getFlags() != null) { + return flags.getFlags().keySet().stream() + .map(value -> new DatasetCatalogRepository.FrontendValue(value, value, value)) + .toList(); + } + return List.of(); + } + + protected List columns(ColumnId primary, List additional) { + List columns = new ArrayList<>(); + if (primary != null) { + columns.add(primary); + } + if (additional != null) { + columns.addAll(additional); + } + return columns; + } + + protected List flagColumns(FilterConversionContext context, Map flags) { + if (flags == null) { + return List.of(); + } + return flags.values().stream().map(context::columnId).toList(); + } + + protected Class numericRangeValueType(FilterConversionContext context, ColumnId column) { + return switch (context.columnType(column)) { + case MONEY -> MoneyRangeFilterValue.class; + case INTEGER -> IntegerRangeFilterValue.class; + case DECIMAL, REAL -> RealRangeFilterValue.class; + // TODO may fail here + default -> RealRangeFilterValue.class; + }; + } + + private java.util.Optional firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return java.util.Optional.of(value.trim()); + } + } + return java.util.Optional.empty(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/BigMultiSelectFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/BigMultiSelectFilterProvider.java new file mode 100644 index 0000000000..4db59cb62e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/BigMultiSelectFilterProvider.java @@ -0,0 +1,21 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.BigMultiSelectFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.BigMultiSelectFilterValue; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class BigMultiSelectFilterProvider extends AbstractFilterProvider { + public BigMultiSelectFilterProvider() { + super(BigMultiSelectFilterDefinition.class, BigMultiSelectFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, BigMultiSelectFilterDefinition payload) { + return filter(context, payload, BigMultiSelectFilterValue.class, null, null, true, List.of(requiredColumn(context, payload))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/CategoryMaxSumFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/CategoryMaxSumFilterProvider.java new file mode 100644 index 0000000000..7d359778c5 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/CategoryMaxSumFilterProvider.java @@ -0,0 +1,31 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.ArrayList; +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.CategoryMaxSumFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.MoneyRangeFilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.RealRangeFilterValue; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class CategoryMaxSumFilterProvider extends AbstractFilterProvider { + + public CategoryMaxSumFilterProvider() { + super(CategoryMaxSumFilterDefinition.class, IntegerRangeFilterValue.class, MoneyRangeFilterValue.class, RealRangeFilterValue.class); + } + + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, CategoryMaxSumFilterDefinition payload) { + List required = new ArrayList<>(); + ColumnId valueColumnId = context.columnId(payload.getValueColumn()); + required.add(valueColumnId); + required.add(context.columnId(payload.getCategoryColumn())); + return filter(context, payload, numericRangeValueType(context, valueColumnId), null, null, false, required); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/CountFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/CountFilterProvider.java new file mode 100644 index 0000000000..efb1ba6935 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/CountFilterProvider.java @@ -0,0 +1,21 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.CountFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class CountFilterProvider extends AbstractFilterProvider { + public CountFilterProvider() { + super(CountFilterDefinition.class, IntegerRangeFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, CountFilterDefinition payload) { + ColumnId column = requiredColumn(context, payload); + return filter(context, payload, IntegerRangeFilterValue.class, 1, null, false, columns(column, optionalColumns(context, payload.getDistinctByColumn()))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/CountQuartersFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/CountQuartersFilterProvider.java new file mode 100644 index 0000000000..876e0ca4b9 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/CountQuartersFilterProvider.java @@ -0,0 +1,21 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.CountQuartersFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class CountQuartersFilterProvider extends AbstractFilterProvider { + public CountQuartersFilterProvider() { + super(CountQuartersFilterDefinition.class, IntegerRangeFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, CountQuartersFilterDefinition payload) { + return filter(context, payload, IntegerRangeFilterValue.class, 1, null, false, List.of(requiredColumn(context, payload))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/DateDistanceFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/DateDistanceFilterProvider.java new file mode 100644 index 0000000000..910ce81e7e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/DateDistanceFilterProvider.java @@ -0,0 +1,21 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.DateDistanceFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class DateDistanceFilterProvider extends AbstractFilterProvider { + public DateDistanceFilterProvider() { + super(DateDistanceFilterDefinition.class, IntegerRangeFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, DateDistanceFilterDefinition payload) { + return filter(context, payload, IntegerRangeFilterValue.class, null, null, false, List.of(requiredColumn(context, payload))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/DurationSumFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/DurationSumFilterProvider.java new file mode 100644 index 0000000000..db32d7646f --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/DurationSumFilterProvider.java @@ -0,0 +1,21 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.DurationSumFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class DurationSumFilterProvider extends AbstractFilterProvider { + public DurationSumFilterProvider() { + super(DurationSumFilterDefinition.class, IntegerRangeFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, DurationSumFilterDefinition payload) { + ColumnId column = requiredColumn(context, payload); + return filter(context, payload, IntegerRangeFilterValue.class, 0, null, false, columns(column, optionalColumns(context, payload.getDistinctBy()))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/FlagsFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/FlagsFilterProvider.java new file mode 100644 index 0000000000..e04bf9a868 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/FlagsFilterProvider.java @@ -0,0 +1,19 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.FlagsFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.MultiSelectFilterValue; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class FlagsFilterProvider extends AbstractFilterProvider { + public FlagsFilterProvider() { + super(FlagsFilterDefinition.class, MultiSelectFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, FlagsFilterDefinition payload) { + return filter(context, payload, MultiSelectFilterValue.class, null, null, false, flagColumns(context, payload.getFlags())); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/NumberFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/NumberFilterProvider.java new file mode 100644 index 0000000000..7e10766aa4 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/NumberFilterProvider.java @@ -0,0 +1,25 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.NumberFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.MoneyRangeFilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.RealRangeFilterValue; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class NumberFilterProvider extends AbstractFilterProvider { + public NumberFilterProvider() { + super(NumberFilterDefinition.class, IntegerRangeFilterValue.class, MoneyRangeFilterValue.class, RealRangeFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, NumberFilterDefinition payload) { + ColumnId column = requiredColumn(context, payload); + return filter(context, payload, numericRangeValueType(context, column), null, null, false, List.of(column)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/PrefixTextFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/PrefixTextFilterProvider.java new file mode 100644 index 0000000000..1cf658c3ed --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/PrefixTextFilterProvider.java @@ -0,0 +1,21 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.PrefixTextFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.StringFilterValue; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class PrefixTextFilterProvider extends AbstractFilterProvider { + public PrefixTextFilterProvider() { + super(PrefixTextFilterDefinition.class, StringFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, PrefixTextFilterDefinition payload) { + return filter(context, payload, StringFilterValue.class, null, null, false, List.of(requiredColumn(context, payload))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/QuartersInYearFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/QuartersInYearFilterProvider.java new file mode 100644 index 0000000000..143be62760 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/QuartersInYearFilterProvider.java @@ -0,0 +1,21 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.QuartersInYearFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class QuartersInYearFilterProvider extends AbstractFilterProvider { + public QuartersInYearFilterProvider() { + super(QuartersInYearFilterDefinition.class, IntegerRangeFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, QuartersInYearFilterDefinition payload) { + return filter(context, payload, IntegerRangeFilterValue.class, 1, 4, false, List.of(requiredColumn(context, payload))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/SelectFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/SelectFilterProvider.java new file mode 100644 index 0000000000..0703167dfa --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/SelectFilterProvider.java @@ -0,0 +1,27 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.MultiSelectFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.BigMultiSelectFilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.MultiSelectFilterValue; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class SelectFilterProvider extends AbstractFilterProvider { + public SelectFilterProvider() { + super(MultiSelectFilterDefinition.class, MultiSelectFilterValue.class, BigMultiSelectFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, MultiSelectFilterDefinition payload) { + boolean hasOptions = !options(payload).isEmpty(); + Class valueType = hasOptions + ? MultiSelectFilterValue.class + : BigMultiSelectFilterValue.class; + return filter(context, payload, valueType, null, null, !hasOptions, List.of(requiredColumn(context, payload))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/SingleSelectFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/SingleSelectFilterProvider.java new file mode 100644 index 0000000000..17b4394fb4 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/SingleSelectFilterProvider.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.SingleSelectFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.SelectFilterValue; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +// TODO remove this filter or migrate it's FilterValue to StringFilterValue +@ApplicationScoped +public class SingleSelectFilterProvider extends AbstractFilterProvider { + public SingleSelectFilterProvider() { + super(SingleSelectFilterDefinition.class, SelectFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, SingleSelectFilterDefinition payload) { + return filter(context, payload, SelectFilterValue.class, null, null, false, List.of(requiredColumn(context, payload))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/SumFilterProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/SumFilterProvider.java new file mode 100644 index 0000000000..fd6ebb646c --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/providers/SumFilterProvider.java @@ -0,0 +1,30 @@ +package com.bakdata.conquery.quarkus.concepts.filters.providers; + +import java.util.ArrayList; +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.filters.FilterConversionContext; +import com.bakdata.conquery.quarkus.concepts.filters.definitions.SumFilterDefinition; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.MoneyRangeFilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.RealRangeFilterValue; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class SumFilterProvider extends AbstractFilterProvider { + public SumFilterProvider() { + super(SumFilterDefinition.class, IntegerRangeFilterValue.class, MoneyRangeFilterValue.class, RealRangeFilterValue.class); + } + + @Override + public DatasetCatalogRepository.Filter convert(FilterConversionContext context, SumFilterDefinition payload) { + ColumnId column = requiredColumn(context, payload); + List required = new ArrayList<>(columns(column, optionalColumns(context, payload.getDistinctByColumn()))); + if (payload.getSubtractColumn() != null && !payload.getSubtractColumn().isBlank()) { + required.add(context.columnId(payload.getSubtractColumn())); + } + return filter(context, payload, numericRangeValueType(context, column), null, null, false, required); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/FilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/FilterValue.java new file mode 100644 index 0000000000..6ef8798a2f --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/FilterValue.java @@ -0,0 +1,18 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values; + +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelBase; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@Schema(name = "QueryFilterValue", description = "Value assigned to a concept filter in a query. The concrete value model is selected by `type`.", discriminatorProperty = "type") +@PolymorphicModelBase( + schemaName = "QueryFilterValue", + description = "Value assigned to a concept filter in a query. The concrete value model is selected by `type`." +) +public interface FilterValue { + + @Schema(description = "The filter this value submitted to.") + FilterId filter(); +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/FilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/FilterValueProvider.java new file mode 100644 index 0000000000..76d980856f --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/FilterValueProvider.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values; + +import com.bakdata.conquery.quarkus.models.PolymorphicModelTypeProvider; + +public interface FilterValueProvider extends PolymorphicModelTypeProvider { + + default String type() { + return typeId(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/FilterValueRegistry.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/FilterValueRegistry.java new file mode 100644 index 0000000000..b0ed5aba1d --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/FilterValueRegistry.java @@ -0,0 +1,42 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values; + +import java.util.LinkedHashMap; +import java.util.Map; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +@ApplicationScoped +public class FilterValueRegistry { + + @Inject + Instance> providers; + + private Map, FilterValueProvider> providersByModelType = Map.of(); + + @PostConstruct + void init() { + Map, FilterValueProvider> index = new LinkedHashMap<>(); + for (FilterValueProvider provider : providers) { + FilterValueProvider previous = index.put(provider.modelType(), provider); + if (previous != null) { + throw new IllegalStateException("Duplicate filter value provider for model type " + provider.modelType().getName() + ": " + previous.getClass().getName() + " and " + provider.getClass().getName()); + } + } + providersByModelType = Map.copyOf(index); + } + + public FilterValueProvider require(FilterValue value) { + return require(value.getClass()); + } + + public FilterValueProvider require(Class modelType) { + FilterValueProvider provider = providersByModelType.get(modelType); + if (provider == null) { + throw new IllegalArgumentException("No filter value provider registered for " + modelType.getName()); + } + return provider; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/BigMultiSelectFilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/BigMultiSelectFilterValue.java new file mode 100644 index 0000000000..fc8ba94c45 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/BigMultiSelectFilterValue.java @@ -0,0 +1,15 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import java.util.Set; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryBigMultiSelectFilterValue", description = "A potentially large set of selected filter values.") +@PolymorphicModelSubtype(base = FilterValue.class, id = "BIG_MULTI_SELECT") +public record BigMultiSelectFilterValue(@NotNull FilterId filter, @NotNull @NotEmpty Set<@NotNull String> value) implements FilterValue { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/IntegerFilterRange.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/IntegerFilterRange.java new file mode 100644 index 0000000000..e01658daea --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/IntegerFilterRange.java @@ -0,0 +1,7 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryIntegerFilterRange", description = "Inclusive integer range. Either boundary may be omitted.") +public record IntegerFilterRange(Long min, Long max) { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/IntegerFilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/IntegerFilterValue.java new file mode 100644 index 0000000000..08c30d679c --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/IntegerFilterValue.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryIntegerFilterValue", description = "A single integer filter value.") +@PolymorphicModelSubtype(base = FilterValue.class, id = "INTEGER") +public record IntegerFilterValue(@NotNull FilterId filter, @NotNull Long value) implements FilterValue { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/IntegerRangeFilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/IntegerRangeFilterValue.java new file mode 100644 index 0000000000..6028640cc2 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/IntegerRangeFilterValue.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryIntegerRangeFilterValue", description = "An integer range filter value.") +@PolymorphicModelSubtype(base = FilterValue.class, id = "INTEGER_RANGE") +public record IntegerRangeFilterValue(@NotNull FilterId filter, @Valid @NotNull IntegerFilterRange value) implements FilterValue { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/MoneyRangeFilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/MoneyRangeFilterValue.java new file mode 100644 index 0000000000..e6266ebeec --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/MoneyRangeFilterValue.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryMoneyRangeFilterValue", description = "A monetary range represented in the configured minor currency unit.") +@PolymorphicModelSubtype(base = FilterValue.class, id = "MONEY_RANGE") +public record MoneyRangeFilterValue(@NotNull FilterId filter, @Valid @NotNull IntegerFilterRange value) implements FilterValue { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/MultiSelectFilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/MultiSelectFilterValue.java new file mode 100644 index 0000000000..83f48b9559 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/MultiSelectFilterValue.java @@ -0,0 +1,15 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import java.util.Set; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryMultiSelectFilterValue", description = "A set of selected filter values.") +@PolymorphicModelSubtype(base = FilterValue.class, id = "MULTI_SELECT") +public record MultiSelectFilterValue(@NotNull FilterId filter, @NotNull @NotEmpty Set<@NotNull String> value) implements FilterValue { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/RealFilterRange.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/RealFilterRange.java new file mode 100644 index 0000000000..19acd84701 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/RealFilterRange.java @@ -0,0 +1,9 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import java.math.BigDecimal; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryRealFilterRange", description = "Inclusive real-number range. Either boundary may be omitted.") +public record RealFilterRange(BigDecimal min, BigDecimal max) { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/RealFilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/RealFilterValue.java new file mode 100644 index 0000000000..c12f553e0c --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/RealFilterValue.java @@ -0,0 +1,14 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import java.math.BigDecimal; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryRealFilterValue", description = "A single real-number filter value.") +@PolymorphicModelSubtype(base = FilterValue.class, id = "REAL") +public record RealFilterValue(@NotNull FilterId filter, @NotNull BigDecimal value) implements FilterValue { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/RealRangeFilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/RealRangeFilterValue.java new file mode 100644 index 0000000000..62134d2a3c --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/RealRangeFilterValue.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryRealRangeFilterValue", description = "A real-number range filter value.") +@PolymorphicModelSubtype(base = FilterValue.class, id = "REAL_RANGE") +public record RealRangeFilterValue(@NotNull FilterId filter, @Valid @NotNull RealFilterRange value) implements FilterValue { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/SelectFilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/SelectFilterValue.java new file mode 100644 index 0000000000..1a8db9daf0 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/SelectFilterValue.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QuerySelectFilterValue", description = "A single selected filter value.") +@PolymorphicModelSubtype(base = FilterValue.class, id = "SELECT") +public record SelectFilterValue(@NotNull FilterId filter, @NotNull String value) implements FilterValue { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/StringFilterValue.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/StringFilterValue.java new file mode 100644 index 0000000000..86d185437b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/definitions/StringFilterValue.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.definitions; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotNull; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "QueryStringFilterValue", description = "A free-text filter value.") +@PolymorphicModelSubtype(base = FilterValue.class, id = "STRING") +public record StringFilterValue(@NotNull FilterId filter, @NotNull String value) implements FilterValue { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/AbstractFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/AbstractFilterValueProvider.java new file mode 100644 index 0000000000..8d7c191c69 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/AbstractFilterValueProvider.java @@ -0,0 +1,18 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValueProvider; + +abstract class AbstractFilterValueProvider implements FilterValueProvider { + + private final Class modelType; + + protected AbstractFilterValueProvider(Class modelType) { + this.modelType = modelType; + } + + @Override + public Class modelType() { + return modelType; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/BigMultiSelectFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/BigMultiSelectFilterValueProvider.java new file mode 100644 index 0000000000..37f0196cd7 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/BigMultiSelectFilterValueProvider.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.BigMultiSelectFilterValue; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class BigMultiSelectFilterValueProvider extends AbstractFilterValueProvider { + public BigMultiSelectFilterValueProvider() { + super(BigMultiSelectFilterValue.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/IntegerFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/IntegerFilterValueProvider.java new file mode 100644 index 0000000000..7e6b6dca00 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/IntegerFilterValueProvider.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerFilterValue; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class IntegerFilterValueProvider extends AbstractFilterValueProvider { + public IntegerFilterValueProvider() { + super(IntegerFilterValue.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/IntegerRangeFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/IntegerRangeFilterValueProvider.java new file mode 100644 index 0000000000..b580b0079b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/IntegerRangeFilterValueProvider.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.IntegerRangeFilterValue; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class IntegerRangeFilterValueProvider extends AbstractFilterValueProvider { + public IntegerRangeFilterValueProvider() { + super(IntegerRangeFilterValue.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/MoneyRangeFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/MoneyRangeFilterValueProvider.java new file mode 100644 index 0000000000..49a08be1a1 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/MoneyRangeFilterValueProvider.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.MoneyRangeFilterValue; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class MoneyRangeFilterValueProvider extends AbstractFilterValueProvider { + public MoneyRangeFilterValueProvider() { + super(MoneyRangeFilterValue.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/MultiSelectFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/MultiSelectFilterValueProvider.java new file mode 100644 index 0000000000..0e62fc26a9 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/MultiSelectFilterValueProvider.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.MultiSelectFilterValue; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class MultiSelectFilterValueProvider extends AbstractFilterValueProvider { + public MultiSelectFilterValueProvider() { + super(MultiSelectFilterValue.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/RealFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/RealFilterValueProvider.java new file mode 100644 index 0000000000..3841338f1b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/RealFilterValueProvider.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.RealFilterValue; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class RealFilterValueProvider extends AbstractFilterValueProvider { + public RealFilterValueProvider() { + super(RealFilterValue.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/RealRangeFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/RealRangeFilterValueProvider.java new file mode 100644 index 0000000000..0df564b13a --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/RealRangeFilterValueProvider.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.RealRangeFilterValue; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class RealRangeFilterValueProvider extends AbstractFilterValueProvider { + public RealRangeFilterValueProvider() { + super(RealRangeFilterValue.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/SelectFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/SelectFilterValueProvider.java new file mode 100644 index 0000000000..ce191b2019 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/SelectFilterValueProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.SelectFilterValue; +import jakarta.enterprise.context.ApplicationScoped; + +// TODO maybe remove, it may has a different symantic to the frontend than StringFilterValueProvider +@ApplicationScoped +public class SelectFilterValueProvider extends AbstractFilterValueProvider { + public SelectFilterValueProvider() { + super(SelectFilterValue.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/StringFilterValueProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/StringFilterValueProvider.java new file mode 100644 index 0000000000..3b08560280 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/filters/values/providers/StringFilterValueProvider.java @@ -0,0 +1,12 @@ +package com.bakdata.conquery.quarkus.concepts.filters.values.providers; + +import com.bakdata.conquery.quarkus.concepts.filters.values.definitions.StringFilterValue; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class StringFilterValueProvider extends AbstractFilterValueProvider { + public StringFilterValueProvider() { + super(StringFilterValue.class); + } + +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectConversionContext.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectConversionContext.java new file mode 100644 index 0000000000..7828e44e7e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectConversionContext.java @@ -0,0 +1,60 @@ +package com.bakdata.conquery.quarkus.concepts.selects; + +import java.util.Optional; + +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.ids.ConnectorId; +import com.bakdata.conquery.quarkus.ids.IdPartSanitizer; +import com.bakdata.conquery.quarkus.ids.SelectId; +import com.bakdata.conquery.quarkus.ids.TableId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +public record SelectConversionContext( + ConnectorId connectorId, + TableId tableId, + DatasetCatalogRepository.TableRecord table, + FallbackReporter fallbackReporter +) { + + public SelectId selectId(String name) { + return new SelectId(connectorId, name); + } + + public ColumnId columnId(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Select column in connector '" + connectorId + "' must not be blank."); + } + if (value.contains(".")) { + throw new IllegalArgumentException("Select column in connector '" + connectorId + "' must be a local column name without dots: " + value); + } + ColumnId columnId = new ColumnId(tableId, value); + if (table.columns().stream().noneMatch(column -> column.id().equals(columnId))) { + throw new IllegalArgumentException("Select for connector '" + connectorId + "' references unknown column '" + columnId + "'."); + } + return columnId; + } + + public DatasetCatalogRepository.ColumnType columnType(ColumnId columnId) { + return table.columns().stream() + .filter(column -> column.id().equals(columnId)) + .map(DatasetCatalogRepository.ColumnRecord::type) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown column '" + columnId + "'.")); + } + + public String idPartFromPreferredOrFallback(String preferred, String fallback, String label, String type) { + if (preferred != null && !preferred.isBlank()) { + return preferred.trim(); + } + String source = Optional.ofNullable(fallback).filter(value -> !value.isBlank()) + .orElseThrow(() -> new IllegalArgumentException("Select " + type + " must define name or label.")); + String sanitized = IdPartSanitizer.sanitize(source, label); + fallbackReporter.record(label, type, source, sanitized); + return sanitized; + } + + @FunctionalInterface + public interface FallbackReporter { + void record(String idType, Object context, String fallbackValue, String sanitized); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinition.java new file mode 100644 index 0000000000..79d7300dfc --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinition.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.concepts.selects; + +import com.bakdata.conquery.quarkus.models.PolymorphicModelBase; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + visible = true, + defaultImpl = UnknownSelectDefinition.class +) +@Schema(name = "MetadataSelectDefinition", description = "Select definition used in connector metadata. The concrete model is selected by `type`.", discriminatorProperty = "type") +@PolymorphicModelBase( + schemaName = "MetadataSelectDefinition", + description = "Select definition used in connector metadata. The concrete model is selected by `type`." +) +public interface SelectDefinition { + + String getType(); +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinitionAssembler.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinitionAssembler.java new file mode 100644 index 0000000000..8ab4220488 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinitionAssembler.java @@ -0,0 +1,62 @@ +package com.bakdata.conquery.quarkus.concepts.selects; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import com.bakdata.conquery.quarkus.ids.ConnectorId; +import com.bakdata.conquery.quarkus.ids.TableId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; + +@ApplicationScoped +@Slf4j +public class SelectDefinitionAssembler { + + @Inject + SelectDefinitionRegistry registry; + + @Inject + ObjectMapper objectMapper; + + public List assemble( + ConnectorId connectorId, + TableId tableId, + DatasetCatalogRepository.TableRecord table, + List definitions, + SelectConversionContext.FallbackReporter fallbackReporter, + boolean strictSelectTypes + ) { + if (definitions == null) { + return List.of(); + } + SelectConversionContext context = new SelectConversionContext(connectorId, tableId, table, fallbackReporter); + List selects = new ArrayList<>(); + for (SelectDefinition definition : definitions) { + assemble(context, definition, strictSelectTypes).ifPresent(selects::add); + } + return List.copyOf(selects); + } + + private Optional assemble(SelectConversionContext context, SelectDefinition definition, boolean strictSelectTypes) { + Optional> provider = registry.find(definition); + if (provider.isPresent()) { + return Optional.of(convert(context, definition, provider.get())); + } + String type = definition.getType(); + String reason = type == null || type.isBlank() ? "missing select type" : "unknown select type '" + type + "'"; + String message = "Skipping select for connector '" + context.connectorId() + "' because of " + reason + ": " + objectMapper.valueToTree(definition); + if (strictSelectTypes) { + throw new IllegalStateException(message); + } + log.warn("{}", message); + return Optional.empty(); + } + + private DatasetCatalogRepository.Select convert(SelectConversionContext context, SelectDefinition definition, SelectDefinitionProvider provider) { + return provider.convert(context, provider.modelType().cast(definition)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinitionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinitionProvider.java new file mode 100644 index 0000000000..fb6d29379d --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinitionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.selects; + +import com.bakdata.conquery.quarkus.models.PolymorphicModelTypeProvider; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +public interface SelectDefinitionProvider extends PolymorphicModelTypeProvider { + + DatasetCatalogRepository.Select convert(SelectConversionContext context, T payload); + + default String type() { + return typeId(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinitionRegistry.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinitionRegistry.java new file mode 100644 index 0000000000..e38e4039fc --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/SelectDefinitionRegistry.java @@ -0,0 +1,35 @@ +package com.bakdata.conquery.quarkus.concepts.selects; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +@ApplicationScoped +public class SelectDefinitionRegistry { + + @Inject + Instance> providers; + + private Map, SelectDefinitionProvider> providersByModelType = Map.of(); + + @PostConstruct + void init() { + Map, SelectDefinitionProvider> index = new LinkedHashMap<>(); + for (SelectDefinitionProvider provider : providers) { + SelectDefinitionProvider previous = index.put(provider.modelType(), provider); + if (previous != null) { + throw new IllegalStateException("Duplicate select provider for model type " + provider.modelType().getName() + ": " + previous.getClass().getName() + " and " + provider.getClass().getName()); + } + } + providersByModelType = Map.copyOf(index); + } + + public Optional> find(SelectDefinition definition) { + return Optional.ofNullable(providersByModelType.get(definition.getClass())); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/UnknownSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/UnknownSelectDefinition.java new file mode 100644 index 0000000000..8d61e7f029 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/UnknownSelectDefinition.java @@ -0,0 +1,32 @@ +package com.bakdata.conquery.quarkus.concepts.selects; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; + +public final class UnknownSelectDefinition implements SelectDefinition { + + private String type; + private final Map properties = new LinkedHashMap<>(); + + @Override + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + @JsonAnySetter + public void put(String name, Object value) { + properties.put(name, value); + } + + @JsonAnyGetter + public Map properties() { + return Map.copyOf(properties); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectConversionContext.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectConversionContext.java new file mode 100644 index 0000000000..5fcaaefe2d --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectConversionContext.java @@ -0,0 +1,33 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept; + +import java.util.Optional; + +import com.bakdata.conquery.quarkus.ids.ConceptId; +import com.bakdata.conquery.quarkus.ids.ConceptSelectId; +import com.bakdata.conquery.quarkus.ids.IdPartSanitizer; + +public record ConceptSelectConversionContext( + ConceptId conceptId, + FallbackReporter fallbackReporter +) { + + public ConceptSelectId selectId(String name) { + return new ConceptSelectId(conceptId, name); + } + + public String idPartFromPreferredOrFallback(String preferred, String fallback, String type) { + if (preferred != null && !preferred.isBlank()) { + return preferred.trim(); + } + String source = Optional.ofNullable(fallback).filter(value -> !value.isBlank()) + .orElseThrow(() -> new IllegalArgumentException("Concept select " + type + " must define name or label.")); + String sanitized = IdPartSanitizer.sanitize(source, "concept select id fallback"); + fallbackReporter.record("concept select id", type, source, sanitized); + return sanitized; + } + + @FunctionalInterface + public interface FallbackReporter { + void record(String idType, Object context, String fallbackValue, String sanitized); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinition.java new file mode 100644 index 0000000000..7a38f3ddf9 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinition.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept; + +import com.bakdata.conquery.quarkus.models.PolymorphicModelBase; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "type", + visible = true, + defaultImpl = UnknownConceptSelectDefinition.class +) +@Schema(name = "MetadataConceptSelectDefinition", description = "Select definition used at concept level. The concrete model is selected by `type`.", discriminatorProperty = "type") +@PolymorphicModelBase( + schemaName = "MetadataConceptSelectDefinition", + description = "Select definition used at concept level. The concrete model is selected by `type`." +) +public interface ConceptSelectDefinition { + + String getType(); +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinitionAssembler.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinitionAssembler.java new file mode 100644 index 0000000000..d915a3371a --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinitionAssembler.java @@ -0,0 +1,59 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import com.bakdata.conquery.quarkus.ids.ConceptId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; + +@ApplicationScoped +@Slf4j +public class ConceptSelectDefinitionAssembler { + + @Inject + ConceptSelectDefinitionRegistry registry; + + @Inject + ObjectMapper objectMapper; + + public List assemble( + ConceptId conceptId, + List definitions, + ConceptSelectConversionContext.FallbackReporter fallbackReporter, + boolean strictSelectTypes + ) { + if (definitions == null) { + return List.of(); + } + ConceptSelectConversionContext context = new ConceptSelectConversionContext(conceptId, fallbackReporter); + List selects = new ArrayList<>(); + for (ConceptSelectDefinition definition : definitions) { + assemble(context, definition, strictSelectTypes).ifPresent(selects::add); + } + return List.copyOf(selects); + } + + private Optional assemble(ConceptSelectConversionContext context, ConceptSelectDefinition definition, boolean strictSelectTypes) { + Optional> provider = registry.find(definition); + if (provider.isPresent()) { + return Optional.of(convert(context, definition, provider.get())); + } + String type = definition.getType(); + String reason = type == null || type.isBlank() ? "missing concept select type" : "unknown concept select type '" + type + "'"; + String message = "Skipping select for concept '" + context.conceptId() + "' because of " + reason + ": " + objectMapper.valueToTree(definition); + if (strictSelectTypes) { + throw new IllegalStateException(message); + } + log.warn("{}", message); + return Optional.empty(); + } + + private DatasetCatalogRepository.ConceptSelect convert(ConceptSelectConversionContext context, ConceptSelectDefinition definition, ConceptSelectDefinitionProvider provider) { + return provider.convert(context, provider.modelType().cast(definition)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinitionProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinitionProvider.java new file mode 100644 index 0000000000..9ff6e53781 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinitionProvider.java @@ -0,0 +1,13 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept; + +import com.bakdata.conquery.quarkus.models.PolymorphicModelTypeProvider; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +public interface ConceptSelectDefinitionProvider extends PolymorphicModelTypeProvider { + + DatasetCatalogRepository.ConceptSelect convert(ConceptSelectConversionContext context, T payload); + + default String type() { + return typeId(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinitionRegistry.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinitionRegistry.java new file mode 100644 index 0000000000..1c1d7a8f32 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/ConceptSelectDefinitionRegistry.java @@ -0,0 +1,35 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +@ApplicationScoped +public class ConceptSelectDefinitionRegistry { + + @Inject + Instance> providers; + + private Map, ConceptSelectDefinitionProvider> providersByModelType = Map.of(); + + @PostConstruct + void init() { + Map, ConceptSelectDefinitionProvider> index = new LinkedHashMap<>(); + for (ConceptSelectDefinitionProvider provider : providers) { + ConceptSelectDefinitionProvider previous = index.put(provider.modelType(), provider); + if (previous != null) { + throw new IllegalStateException("Duplicate concept select provider for model type " + provider.modelType().getName() + ": " + previous.getClass().getName() + " and " + provider.getClass().getName()); + } + } + providersByModelType = Map.copyOf(index); + } + + public Optional> find(ConceptSelectDefinition definition) { + return Optional.ofNullable(providersByModelType.get(definition.getClass())); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/UnknownConceptSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/UnknownConceptSelectDefinition.java new file mode 100644 index 0000000000..0c150c47ed --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/UnknownConceptSelectDefinition.java @@ -0,0 +1,32 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; + +public final class UnknownConceptSelectDefinition implements ConceptSelectDefinition { + + private String type; + private final Map properties = new LinkedHashMap<>(); + + @Override + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + @JsonAnySetter + public void put(String name, Object value) { + properties.put(name, value); + } + + @JsonAnyGetter + public Map properties() { + return Map.copyOf(properties); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/AbstractConceptSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/AbstractConceptSelectDefinition.java new file mode 100644 index 0000000000..403e495e31 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/AbstractConceptSelectDefinition.java @@ -0,0 +1,28 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectDefinition; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +public abstract class AbstractConceptSelectDefinition implements ConceptSelectDefinition { + + @Schema(description = "Concept select implementation discriminator.") + private String type; + + @Schema(description = "Stable local name used to form the select id. Falls back to the label when omitted.", pattern = "^\\w+$") + private String name; + + @Schema(description = "Label displayed to users.") + private String label; + + @Schema(description = "Additional explanation displayed for the select.") + private String description; + + @Schema(description = "Whether the frontend preselects this select.") + private boolean isDefault; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/ConceptValuesConceptSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/ConceptValuesConceptSelectDefinition.java new file mode 100644 index 0000000000..4f0ec2299d --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/ConceptValuesConceptSelectDefinition.java @@ -0,0 +1,17 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataConceptValuesConceptSelect", description = "Returns values of matching concept nodes.") +@PolymorphicModelSubtype(base = ConceptSelectDefinition.class, id = "CONCEPT_VALUES") +public final class ConceptValuesConceptSelectDefinition extends AbstractConceptSelectDefinition { + + @Schema(description = "Whether concept ids are returned instead of labels.") + private boolean asIds; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/EventDateUnionConceptSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/EventDateUnionConceptSelectDefinition.java new file mode 100644 index 0000000000..878bbc6873 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/EventDateUnionConceptSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataEventDateUnionConceptSelect", description = "Returns the union of matching event dates.") +@PolymorphicModelSubtype(base = ConceptSelectDefinition.class, id = "EVENT_DATE_UNION") +public final class EventDateUnionConceptSelectDefinition extends AbstractConceptSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/EventDurationSumConceptSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/EventDurationSumConceptSelectDefinition.java new file mode 100644 index 0000000000..bb00a397bf --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/EventDurationSumConceptSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataEventDurationSumConceptSelect", description = "Returns the total duration of matching events.") +@PolymorphicModelSubtype(base = ConceptSelectDefinition.class, id = "EVENT_DURATION_SUM") +public final class EventDurationSumConceptSelectDefinition extends AbstractConceptSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/ExistsConceptSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/ExistsConceptSelectDefinition.java new file mode 100644 index 0000000000..2bac963a1e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/ExistsConceptSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataExistsConceptSelect", description = "Returns whether the concept has any matching event.") +@PolymorphicModelSubtype(base = ConceptSelectDefinition.class, id = "EXISTS") +public final class ExistsConceptSelectDefinition extends AbstractConceptSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/QuarterConceptSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/QuarterConceptSelectDefinition.java new file mode 100644 index 0000000000..faf4208b81 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/definitions/QuarterConceptSelectDefinition.java @@ -0,0 +1,24 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataQuarterConceptSelect", description = "Returns the quarter containing a sampled event date.") +@PolymorphicModelSubtype(base = ConceptSelectDefinition.class, id = "QUARTER") +public final class QuarterConceptSelectDefinition extends AbstractConceptSelectDefinition { + + @NotNull + private TemporalSampler sample; + + public enum TemporalSampler { + EARLIEST, + LATEST, + RANDOM + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/AbstractConceptSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/AbstractConceptSelectProvider.java new file mode 100644 index 0000000000..3181da091a --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/AbstractConceptSelectProvider.java @@ -0,0 +1,47 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.providers; + +import java.util.Optional; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectDefinitionProvider; +import com.bakdata.conquery.quarkus.concepts.selects.concept.definitions.AbstractConceptSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ConceptSelectId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +abstract class AbstractConceptSelectProvider implements ConceptSelectDefinitionProvider { + + private final Class modelType; + + protected AbstractConceptSelectProvider(Class modelType) { + this.modelType = modelType; + } + + @Override + public Class modelType() { + return modelType; + } + + protected DatasetCatalogRepository.ConceptSelect select(ConceptSelectConversionContext context, T payload, DatasetCatalogRepository.SelectResultType resultType) { + String name = context.idPartFromPreferredOrFallback(payload.getName(), payload.getLabel(), type()); + String label = firstNonBlank(payload.getLabel(), payload.getName()).orElse(name); + ConceptSelectId id = context.selectId(name); + return new DatasetCatalogRepository.ConceptSelect(id, label, payload.getDescription(), payload.isDefault(), type(), resultType); + } + + protected DatasetCatalogRepository.SelectResultType primitive(String type) { + return DatasetCatalogRepository.SelectResultType.primitive(type); + } + + protected DatasetCatalogRepository.SelectResultType list(String elementType) { + return DatasetCatalogRepository.SelectResultType.list(primitive(elementType)); + } + + private Optional firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return Optional.of(value.trim()); + } + } + return Optional.empty(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/ConceptValuesConceptSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/ConceptValuesConceptSelectProvider.java new file mode 100644 index 0000000000..dc2060d137 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/ConceptValuesConceptSelectProvider.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.providers; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.concept.definitions.ConceptValuesConceptSelectDefinition; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class ConceptValuesConceptSelectProvider extends AbstractConceptSelectProvider { + + public ConceptValuesConceptSelectProvider() { + super(ConceptValuesConceptSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.ConceptSelect convert(ConceptSelectConversionContext context, ConceptValuesConceptSelectDefinition payload) { + return select(context, payload, list("STRING")); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/EventDateUnionConceptSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/EventDateUnionConceptSelectProvider.java new file mode 100644 index 0000000000..57d573f8e4 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/EventDateUnionConceptSelectProvider.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.providers; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.concept.definitions.EventDateUnionConceptSelectDefinition; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class EventDateUnionConceptSelectProvider extends AbstractConceptSelectProvider { + + public EventDateUnionConceptSelectProvider() { + super(EventDateUnionConceptSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.ConceptSelect convert(ConceptSelectConversionContext context, EventDateUnionConceptSelectDefinition payload) { + return select(context, payload, list("DATE_RANGE")); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/EventDurationSumConceptSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/EventDurationSumConceptSelectProvider.java new file mode 100644 index 0000000000..1faaf59291 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/EventDurationSumConceptSelectProvider.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.providers; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.concept.definitions.EventDurationSumConceptSelectDefinition; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class EventDurationSumConceptSelectProvider extends AbstractConceptSelectProvider { + + public EventDurationSumConceptSelectProvider() { + super(EventDurationSumConceptSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.ConceptSelect convert(ConceptSelectConversionContext context, EventDurationSumConceptSelectDefinition payload) { + return select(context, payload, primitive("INTEGER")); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/ExistsConceptSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/ExistsConceptSelectProvider.java new file mode 100644 index 0000000000..78ee8b9876 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/ExistsConceptSelectProvider.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.providers; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.concept.definitions.ExistsConceptSelectDefinition; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class ExistsConceptSelectProvider extends AbstractConceptSelectProvider { + + public ExistsConceptSelectProvider() { + super(ExistsConceptSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.ConceptSelect convert(ConceptSelectConversionContext context, ExistsConceptSelectDefinition payload) { + return select(context, payload, primitive("BOOLEAN")); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/QuarterConceptSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/QuarterConceptSelectProvider.java new file mode 100644 index 0000000000..74ee592184 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/concept/providers/QuarterConceptSelectProvider.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.selects.concept.providers; + +import com.bakdata.conquery.quarkus.concepts.selects.concept.ConceptSelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.concept.definitions.QuarterConceptSelectDefinition; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class QuarterConceptSelectProvider extends AbstractConceptSelectProvider { + + public QuarterConceptSelectProvider() { + super(QuarterConceptSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.ConceptSelect convert(ConceptSelectConversionContext context, QuarterConceptSelectDefinition payload) { + return select(context, payload, primitive("STRING")); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/AbstractSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/AbstractSelectDefinition.java new file mode 100644 index 0000000000..b7da7dcb3b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/AbstractSelectDefinition.java @@ -0,0 +1,28 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +public abstract class AbstractSelectDefinition implements SelectDefinition { + + @Schema(description = "Select implementation discriminator.") + private String type; + + @Schema(description = "Stable local name used to form the select id. Falls back to the label when omitted.", pattern = "^\\w+$") + private String name; + + @Schema(description = "Label displayed to users.") + private String label; + + @Schema(description = "Additional explanation displayed for the select.") + private String description; + + @Schema(description = "Whether the frontend preselects this select.") + private boolean isDefault; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/CountQuartersSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/CountQuartersSelectDefinition.java new file mode 100644 index 0000000000..fe6d0276dd --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/CountQuartersSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataCountQuartersSelect", description = "Counts distinct quarters covered by dates or date ranges.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "COUNT_QUARTERS") +public final class CountQuartersSelectDefinition extends DateRangeSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/CountSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/CountSelectDefinition.java new file mode 100644 index 0000000000..e46c484872 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/CountSelectDefinition.java @@ -0,0 +1,21 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataCountSelect", description = "Counts matching values or events.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "COUNT") +public final class CountSelectDefinition extends AbstractSelectDefinition { + private boolean distinct; + private List distinctByColumn; + @NotBlank + private String column; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DateDistanceSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DateDistanceSelectDefinition.java new file mode 100644 index 0000000000..4f11a5fb25 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DateDistanceSelectDefinition.java @@ -0,0 +1,17 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import java.time.temporal.ChronoUnit; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataDateDistanceSelect", description = "Calculates a date distance in the configured unit.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "DATE_DISTANCE") +public final class DateDistanceSelectDefinition extends SingleColumnSelectDefinition { + private ChronoUnit timeUnit = ChronoUnit.YEARS; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DateRangeSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DateRangeSelectDefinition.java new file mode 100644 index 0000000000..e03fc93d0a --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DateRangeSelectDefinition.java @@ -0,0 +1,19 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +public abstract class DateRangeSelectDefinition extends AbstractSelectDefinition { + + @Schema(description = "Local DATE_RANGE column; alternatively use startColumn and endColumn.") + private String column; + + @Schema(description = "Local range start column.") + private String startColumn; + + @Schema(description = "Local range end column.") + private String endColumn; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DateUnionSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DateUnionSelectDefinition.java new file mode 100644 index 0000000000..076df3581b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DateUnionSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataDateUnionSelect", description = "Returns the union of selected date ranges.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "DATE_UNION") +public final class DateUnionSelectDefinition extends DateRangeSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DistinctSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DistinctSelectDefinition.java new file mode 100644 index 0000000000..440576856a --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DistinctSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataDistinctSelect", description = "Selects all distinct values of a column.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "DISTINCT") +public final class DistinctSelectDefinition extends MappableSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DurationSumSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DurationSumSelectDefinition.java new file mode 100644 index 0000000000..0380de0b65 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/DurationSumSelectDefinition.java @@ -0,0 +1,17 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataDurationSumSelect", description = "Sums durations of selected date ranges.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "DURATION_SUM") +public final class DurationSumSelectDefinition extends DateRangeSelectDefinition { + private List distinctBy; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/FirstSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/FirstSelectDefinition.java new file mode 100644 index 0000000000..7c663ba76c --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/FirstSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataFirstSelect", description = "Selects the first value of a column.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "FIRST") +public final class FirstSelectDefinition extends MappableSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/FlagsSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/FlagsSelectDefinition.java new file mode 100644 index 0000000000..d63d7e109c --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/FlagsSelectDefinition.java @@ -0,0 +1,19 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import java.util.Map; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataFlagsSelect", description = "Returns labels for matching boolean flag columns.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "FLAGS") +public final class FlagsSelectDefinition extends AbstractSelectDefinition { + @NotEmpty + private Map flags; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/LastSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/LastSelectDefinition.java new file mode 100644 index 0000000000..f7eea6c21a --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/LastSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataLastSelect", description = "Selects the last value of a column.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "LAST") +public final class LastSelectDefinition extends MappableSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/MappableSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/MappableSelectDefinition.java new file mode 100644 index 0000000000..ecbe82fa19 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/MappableSelectDefinition.java @@ -0,0 +1,19 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +public abstract class MappableSelectDefinition extends SingleColumnSelectDefinition { + + @Schema(description = "Optional mapping applied to selected values.") + private String mapping; + + @Schema(description = "Optional substring range.") + private IntegerRangeDefinition substring; + + public record IntegerRangeDefinition(Integer min, Integer max) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/PrefixSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/PrefixSelectDefinition.java new file mode 100644 index 0000000000..4500bbbe13 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/PrefixSelectDefinition.java @@ -0,0 +1,17 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataPrefixSelect", description = "Returns values prefixed with a configured string.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "PREFIX") +public final class PrefixSelectDefinition extends SingleColumnSelectDefinition { + @NotNull + private String prefix; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/QuartersInYearSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/QuartersInYearSelectDefinition.java new file mode 100644 index 0000000000..28eaa75775 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/QuartersInYearSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataQuartersInYearSelect", description = "Counts quarters with matching events per year.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "QUARTERS_IN_YEAR") +public final class QuartersInYearSelectDefinition extends SingleColumnSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/RandomSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/RandomSelectDefinition.java new file mode 100644 index 0000000000..8379f5f3a2 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/RandomSelectDefinition.java @@ -0,0 +1,10 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "MetadataRandomSelect", description = "Selects one value of a column.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "RANDOM") +public final class RandomSelectDefinition extends MappableSelectDefinition { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/SingleColumnSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/SingleColumnSelectDefinition.java new file mode 100644 index 0000000000..f9c247cc7a --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/SingleColumnSelectDefinition.java @@ -0,0 +1,18 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +public abstract class SingleColumnSelectDefinition extends AbstractSelectDefinition { + + @NotBlank + @Schema(description = "Local name of the selected column.") + private String column; + + @Schema(description = "Whether values form a bounded categorical set.") + private boolean categorical; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/SumSelectDefinition.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/SumSelectDefinition.java new file mode 100644 index 0000000000..359a518d61 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/definitions/SumSelectDefinition.java @@ -0,0 +1,18 @@ +package com.bakdata.conquery.quarkus.concepts.selects.definitions; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinition; +import com.bakdata.conquery.quarkus.models.PolymorphicModelSubtype; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Getter +@Setter +@Schema(name = "MetadataSumSelect", description = "Sums a numeric column, optionally subtracting another column.") +@PolymorphicModelSubtype(base = SelectDefinition.class, id = "SUM") +public final class SumSelectDefinition extends SingleColumnSelectDefinition { + private String subtractColumn; + private List distinctByColumn; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/AbstractMappableSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/AbstractMappableSelectProvider.java new file mode 100644 index 0000000000..e7f1df578e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/AbstractMappableSelectProvider.java @@ -0,0 +1,26 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.MappableSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +abstract class AbstractMappableSelectProvider extends AbstractSingleColumnSelectProvider { + + protected AbstractMappableSelectProvider(Class payloadType) { + super(payloadType); + } + + protected DatasetCatalogRepository.Select convertMapped(SelectConversionContext context, T payload) { + ColumnId column = context.columnId(payload.getColumn()); + if (payload.getMapping() != null || payload.getSubstring() != null) { + requireColumnType(context, column, DatasetCatalogRepository.ColumnType.STRING); + } + DatasetCatalogRepository.SelectResultType result = payload.getMapping() == null + ? resultType(context, column) + : primitive("STRING"); + return select(context, payload, result, List.of(column)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/AbstractSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/AbstractSelectProvider.java new file mode 100644 index 0000000000..2359aaaacd --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/AbstractSelectProvider.java @@ -0,0 +1,106 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.SelectDefinitionProvider; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.AbstractSelectDefinition; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.DateRangeSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.ids.SelectId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +abstract class AbstractSelectProvider implements SelectDefinitionProvider { + + private final Class modelType; + + protected AbstractSelectProvider(Class modelType) { + this.modelType = modelType; + } + + @Override + public Class modelType() { + return modelType; + } + + protected DatasetCatalogRepository.Select select( + SelectConversionContext context, + T payload, + DatasetCatalogRepository.SelectResultType resultType, + List requiredColumns + ) { + String name = context.idPartFromPreferredOrFallback(payload.getName(), payload.getLabel(), "select id", type()); + String label = firstNonBlank(payload.getLabel(), payload.getName()).orElse(name); + SelectId id = context.selectId(name); + return new DatasetCatalogRepository.Select(id, label, payload.getDescription(), payload.isDefault(), type(), resultType, requiredColumns); + } + + protected List optionalColumns(SelectConversionContext context, List columns) { + if (columns == null) { + return List.of(); + } + return columns.stream().map(context::columnId).toList(); + } + + protected List dateRangeColumns(SelectConversionContext context, DateRangeSelectDefinition payload) { + boolean hasColumn = isPresent(payload.getColumn()); + boolean hasStart = isPresent(payload.getStartColumn()); + boolean hasEnd = isPresent(payload.getEndColumn()); + if (hasColumn == (hasStart || hasEnd) || hasStart != hasEnd) { + throw new IllegalArgumentException("Select " + type() + " must define either column or both startColumn and endColumn."); + } + if (hasColumn) { + return List.of(context.columnId(payload.getColumn())); + } + return List.of(context.columnId(payload.getStartColumn()), context.columnId(payload.getEndColumn())); + } + + protected List append(List first, List second) { + List columns = new ArrayList<>(first); + columns.addAll(second); + return List.copyOf(columns); + } + + protected DatasetCatalogRepository.SelectResultType resultType(SelectConversionContext context, ColumnId column) { + return DatasetCatalogRepository.SelectResultType.primitive(context.columnType(column).name()); + } + + protected void requireColumnType(SelectConversionContext context, ColumnId column, DatasetCatalogRepository.ColumnType... accepted) { + DatasetCatalogRepository.ColumnType actual = context.columnType(column); + if (!Set.of(accepted).contains(actual)) { + throw new IllegalArgumentException("Select " + type() + " column '" + column + "' has type " + actual + ", expected one of " + Set.of(accepted) + "."); + } + } + + protected void requireDateRangeTypes(SelectConversionContext context, List columns) { + if (columns.size() == 1) { + requireColumnType(context, columns.getFirst(), DatasetCatalogRepository.ColumnType.DATE, DatasetCatalogRepository.ColumnType.DATE_RANGE); + return; + } + columns.forEach(column -> requireColumnType(context, column, DatasetCatalogRepository.ColumnType.DATE)); + } + + protected DatasetCatalogRepository.SelectResultType primitive(String type) { + return DatasetCatalogRepository.SelectResultType.primitive(type); + } + + protected DatasetCatalogRepository.SelectResultType list(String elementType) { + return DatasetCatalogRepository.SelectResultType.list(primitive(elementType)); + } + + private boolean isPresent(String value) { + return value != null && !value.isBlank(); + } + + private Optional firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return Optional.of(value.trim()); + } + } + return Optional.empty(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/AbstractSingleColumnSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/AbstractSingleColumnSelectProvider.java new file mode 100644 index 0000000000..75b6e18aa8 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/AbstractSingleColumnSelectProvider.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.SingleColumnSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; + +abstract class AbstractSingleColumnSelectProvider extends AbstractSelectProvider { + + protected AbstractSingleColumnSelectProvider(Class payloadType) { + super(payloadType); + } + + protected DatasetCatalogRepository.Select convertColumn(SelectConversionContext context, T payload) { + ColumnId column = context.columnId(payload.getColumn()); + return select(context, payload, resultType(context, column), List.of(column)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/CountQuartersSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/CountQuartersSelectProvider.java new file mode 100644 index 0000000000..fdb89e6061 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/CountQuartersSelectProvider.java @@ -0,0 +1,25 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.CountQuartersSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class CountQuartersSelectProvider extends AbstractSelectProvider { + + public CountQuartersSelectProvider() { + super(CountQuartersSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, CountQuartersSelectDefinition payload) { + List columns = dateRangeColumns(context, payload); + requireDateRangeTypes(context, columns); + return select(context, payload, primitive("INTEGER"), columns); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/CountSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/CountSelectProvider.java new file mode 100644 index 0000000000..9600ac0a6b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/CountSelectProvider.java @@ -0,0 +1,27 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.ArrayList; +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.CountSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class CountSelectProvider extends AbstractSelectProvider { + + public CountSelectProvider() { + super(CountSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, CountSelectDefinition payload) { + List columns = new ArrayList<>(); + columns.add(context.columnId(payload.getColumn())); + columns.addAll(optionalColumns(context, payload.getDistinctByColumn())); + return select(context, payload, primitive("INTEGER"), columns); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DateDistanceSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DateDistanceSelectProvider.java new file mode 100644 index 0000000000..3a8e27db51 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DateDistanceSelectProvider.java @@ -0,0 +1,25 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.DateDistanceSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class DateDistanceSelectProvider extends AbstractSingleColumnSelectProvider { + + public DateDistanceSelectProvider() { + super(DateDistanceSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, DateDistanceSelectDefinition payload) { + ColumnId column = context.columnId(payload.getColumn()); + requireColumnType(context, column, DatasetCatalogRepository.ColumnType.DATE, DatasetCatalogRepository.ColumnType.DATE_RANGE); + return select(context, payload, primitive("INTEGER"), List.of(column)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DateUnionSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DateUnionSelectProvider.java new file mode 100644 index 0000000000..444a877d34 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DateUnionSelectProvider.java @@ -0,0 +1,25 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.DateUnionSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class DateUnionSelectProvider extends AbstractSelectProvider { + + public DateUnionSelectProvider() { + super(DateUnionSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, DateUnionSelectDefinition payload) { + List columns = dateRangeColumns(context, payload); + requireDateRangeTypes(context, columns); + return select(context, payload, list("DATE_RANGE"), columns); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DistinctSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DistinctSelectProvider.java new file mode 100644 index 0000000000..a1df976882 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DistinctSelectProvider.java @@ -0,0 +1,30 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.DistinctSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class DistinctSelectProvider extends AbstractMappableSelectProvider { + + public DistinctSelectProvider() { + super(DistinctSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, DistinctSelectDefinition payload) { + ColumnId column = context.columnId(payload.getColumn()); + if (payload.getMapping() != null || payload.getSubstring() != null) { + requireColumnType(context, column, DatasetCatalogRepository.ColumnType.STRING); + } + DatasetCatalogRepository.SelectResultType elementType = payload.getMapping() == null + ? resultType(context, column) + : primitive("STRING"); + return select(context, payload, DatasetCatalogRepository.SelectResultType.list(elementType), List.of(column)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DurationSumSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DurationSumSelectProvider.java new file mode 100644 index 0000000000..0ac3961060 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/DurationSumSelectProvider.java @@ -0,0 +1,25 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.DurationSumSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class DurationSumSelectProvider extends AbstractSelectProvider { + + public DurationSumSelectProvider() { + super(DurationSumSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, DurationSumSelectDefinition payload) { + List rangeColumns = dateRangeColumns(context, payload); + requireDateRangeTypes(context, rangeColumns); + return select(context, payload, primitive("INTEGER"), append(rangeColumns, optionalColumns(context, payload.getDistinctBy()))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/FirstSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/FirstSelectProvider.java new file mode 100644 index 0000000000..21369a7fd3 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/FirstSelectProvider.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.FirstSelectDefinition; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class FirstSelectProvider extends AbstractMappableSelectProvider { + + public FirstSelectProvider() { + super(FirstSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, FirstSelectDefinition payload) { + return convertMapped(context, payload); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/FlagsSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/FlagsSelectProvider.java new file mode 100644 index 0000000000..0fbb61c156 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/FlagsSelectProvider.java @@ -0,0 +1,25 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.FlagsSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class FlagsSelectProvider extends AbstractSelectProvider { + + public FlagsSelectProvider() { + super(FlagsSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, FlagsSelectDefinition payload) { + List columns = payload.getFlags().values().stream().map(context::columnId).toList(); + columns.forEach(column -> requireColumnType(context, column, DatasetCatalogRepository.ColumnType.BOOLEAN)); + return select(context, payload, list("STRING"), columns); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/LastSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/LastSelectProvider.java new file mode 100644 index 0000000000..6959ef10d9 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/LastSelectProvider.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.LastSelectDefinition; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class LastSelectProvider extends AbstractMappableSelectProvider { + + public LastSelectProvider() { + super(LastSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, LastSelectDefinition payload) { + return convertMapped(context, payload); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/PrefixSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/PrefixSelectProvider.java new file mode 100644 index 0000000000..8e1ef038bc --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/PrefixSelectProvider.java @@ -0,0 +1,25 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.PrefixSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class PrefixSelectProvider extends AbstractSingleColumnSelectProvider { + + public PrefixSelectProvider() { + super(PrefixSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, PrefixSelectDefinition payload) { + ColumnId column = context.columnId(payload.getColumn()); + requireColumnType(context, column, DatasetCatalogRepository.ColumnType.STRING); + return select(context, payload, list("STRING"), List.of(column)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/QuartersInYearSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/QuartersInYearSelectProvider.java new file mode 100644 index 0000000000..dd5a5709cd --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/QuartersInYearSelectProvider.java @@ -0,0 +1,25 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.QuartersInYearSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class QuartersInYearSelectProvider extends AbstractSingleColumnSelectProvider { + + public QuartersInYearSelectProvider() { + super(QuartersInYearSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, QuartersInYearSelectDefinition payload) { + ColumnId column = context.columnId(payload.getColumn()); + requireColumnType(context, column, DatasetCatalogRepository.ColumnType.DATE, DatasetCatalogRepository.ColumnType.DATE_RANGE); + return select(context, payload, primitive("INTEGER"), List.of(column)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/RandomSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/RandomSelectProvider.java new file mode 100644 index 0000000000..5d07d148dc --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/RandomSelectProvider.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.RandomSelectDefinition; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class RandomSelectProvider extends AbstractMappableSelectProvider { + + public RandomSelectProvider() { + super(RandomSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, RandomSelectDefinition payload) { + return convertMapped(context, payload); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/SumSelectProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/SumSelectProvider.java new file mode 100644 index 0000000000..0cc45936e0 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/concepts/selects/providers/SumSelectProvider.java @@ -0,0 +1,42 @@ +package com.bakdata.conquery.quarkus.concepts.selects.providers; + +import java.util.ArrayList; +import java.util.List; + +import com.bakdata.conquery.quarkus.concepts.selects.SelectConversionContext; +import com.bakdata.conquery.quarkus.concepts.selects.definitions.SumSelectDefinition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class SumSelectProvider extends AbstractSingleColumnSelectProvider { + + public SumSelectProvider() { + super(SumSelectDefinition.class); + } + + + @Override + public DatasetCatalogRepository.Select convert(SelectConversionContext context, SumSelectDefinition payload) { + ColumnId column = context.columnId(payload.getColumn()); + requireColumnType( + context, + column, + DatasetCatalogRepository.ColumnType.INTEGER, + DatasetCatalogRepository.ColumnType.MONEY, + DatasetCatalogRepository.ColumnType.DECIMAL, + DatasetCatalogRepository.ColumnType.REAL + ); + List columns = new ArrayList<>(List.of(column)); + if (payload.getSubtractColumn() != null && !payload.getSubtractColumn().isBlank()) { + ColumnId subtractColumn = context.columnId(payload.getSubtractColumn()); + if (context.columnType(subtractColumn) != context.columnType(column)) { + throw new IllegalArgumentException("Select SUM columns must have the same type."); + } + columns.add(subtractColumn); + } + columns.addAll(optionalColumns(context, payload.getDistinctByColumn())); + return select(context, payload, resultType(context, column), columns); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/ConceptsRuntimeConfig.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/ConceptsRuntimeConfig.java new file mode 100644 index 0000000000..ee49adba69 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/ConceptsRuntimeConfig.java @@ -0,0 +1,37 @@ +package com.bakdata.conquery.quarkus.config; + +import java.util.List; +import java.util.Optional; + +import io.smallrye.config.ConfigMapping; + +@ConfigMapping(prefix = "conquery") +public interface ConceptsRuntimeConfig { + /** + * Static root concept definitions used when no generated metadata folder is mounted. + */ + Optional> concepts(); + + interface ConceptEntry { + /** + * Concept id as exposed to the frontend API. + */ + String id(); + + /** + * Human-readable concept label shown in the concept tree. + */ + String label(); + + /** + * Human-readable concept description shown in the concept tree. + */ + String description(); + + + /** + * Dataset id this concept belongs to. + */ + String dataset(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/DatasetMetadataRuntimeConfig.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/DatasetMetadataRuntimeConfig.java new file mode 100644 index 0000000000..484a167f4e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/DatasetMetadataRuntimeConfig.java @@ -0,0 +1,39 @@ +package com.bakdata.conquery.quarkus.config; + +import java.util.List; +import java.util.Optional; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "conquery.metadata") +public interface DatasetMetadataRuntimeConfig { + + /** + * Enables startup ingestion of generated dataset metadata folders. + */ + @WithDefault("false") + boolean enabled(); + + /** + * Base directory that contains generated dataset metadata folders. + */ + Optional rootPath(); + + /** + * Folder names or absolute paths to ingest as static dataset metadata. + */ + Optional> folders(); + + /** + * Fails metadata ingestion when a connector filter type has no registered provider. + */ + @WithDefault("true") + boolean strictFilterTypes(); + + /** + * Fails metadata ingestion when a connector select type has no registered provider. + */ + @WithDefault("true") + boolean strictSelectTypes(); +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/DatasetsRuntimeConfig.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/DatasetsRuntimeConfig.java new file mode 100644 index 0000000000..0b7127c960 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/DatasetsRuntimeConfig.java @@ -0,0 +1,26 @@ +package com.bakdata.conquery.quarkus.config; + +import java.util.List; +import java.util.Optional; + +import io.smallrye.config.ConfigMapping; + +@ConfigMapping(prefix = "conquery") +public interface DatasetsRuntimeConfig { + /** + * Static dataset definitions used when no generated metadata folder is mounted. + */ + Optional> datasets(); + + interface DatasetEntry { + /** + * Stable dataset id used in API paths and dataset-scoped storage. + */ + String id(); + + /** + * Human-readable dataset label shown in frontend dataset selection. + */ + String label(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/EntityPreviewRuntimeConfig.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/EntityPreviewRuntimeConfig.java new file mode 100644 index 0000000000..ac80c963ed --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/EntityPreviewRuntimeConfig.java @@ -0,0 +1,45 @@ +package com.bakdata.conquery.quarkus.config; + +import java.util.List; +import java.util.Optional; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithName; + +@ConfigMapping(prefix = "conquery.entity-preview") +public interface EntityPreviewRuntimeConfig { + + /** + * All entity preview sources available for frontend previews. + */ + @WithName("all") + List allSources(); + + /** + * Entity preview sources selected by default. + */ + @WithName("default") + List defaultSources(); + + /** + * Optional concept id used as the entry point for entity preview search. + */ + Optional searchConcept(); + + /** + * Optional comma-separated list of filter ids available for entity preview search. + */ + Optional searchFilters(); + + interface Source { + /** + * Stable source id used by the frontend. + */ + String name(); + + /** + * Human-readable source label. + */ + String label(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FormQueriesRuntimeConfig.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FormQueriesRuntimeConfig.java new file mode 100644 index 0000000000..b0baf10a72 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FormQueriesRuntimeConfig.java @@ -0,0 +1,15 @@ +package com.bakdata.conquery.quarkus.config; + +import java.util.List; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "conquery.form-queries") +public interface FormQueriesRuntimeConfig { + /** + * Classpath resources containing frontend form query configuration payloads. + */ + @WithDefault("forms/export_form.frontend_conf.json,forms/table_export_form.frontend_conf.json") + List resources(); +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FrontendConfigResource.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FrontendConfigResource.java new file mode 100644 index 0000000000..aa51af3fdb --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FrontendConfigResource.java @@ -0,0 +1,90 @@ +package com.bakdata.conquery.quarkus.config; + +import java.time.Year; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import io.quarkus.info.BuildInfo; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/config") +@Produces(MediaType.APPLICATION_JSON) +public class FrontendConfigResource { + @Inject + Instance buildInfo; + + @Inject + FrontendRuntimeConfig frontendConfig; + + @GET + @Path("/frontend") + public FrontendConfigurationResponse getFrontendConfig() { + + FrontendRuntimeConfig.Currency currencyConfig = frontendConfig.currency(); + FrontendConfigurationResponse.CurrencyConfigResponse currency = + new FrontendConfigurationResponse.CurrencyConfigResponse( + currencyConfig.unit(), + currencyConfig.thousandSeparator(), + currencyConfig.decimalSeparator(), + currencyConfig.decimalScale() + ); + + + FrontendRuntimeConfig.QueryUpload queryUploadConfig = frontendConfig.queryUpload(); + List configuredIds = + queryUploadConfig.ids() + .orElse(List.of()) + .stream() + .map(id -> new FrontendConfigurationResponse.ColumnConfigResponse( + id.name(), + id.field(), + Optional.ofNullable(id.label()).orElse(Map.of(Locale.ROOT.toString(), "result")), + id.primaryId(), + id.print() + )) + .toList(); + + FrontendConfigurationResponse.IdColumnConfigResponse queryUpload = + new FrontendConfigurationResponse.IdColumnConfigResponse( + queryUploadConfig.table(), + configuredIds.isEmpty() + ? List.of( + new FrontendConfigurationResponse.ColumnConfigResponse( + "ID", + "pid", + Map.of("und", "result"), + true, + true + ) + ) + : configuredIds + ); + + List versions = List.of( + new FrontendConfigurationResponse.VersionContainerResponse("Backend", getBackendVersion(), null) + ); + + return new FrontendConfigurationResponse( + versions, + currency, + queryUpload, + frontendConfig.manualUrl().orElse(null), + frontendConfig.contactEmail().orElse(null), + Year.now().minusYears(frontendConfig.observationPeriodYears()).atDay(1) + ); + } + + private String getBackendVersion() { + if (buildInfo != null && buildInfo.isResolvable()) { + return buildInfo.get().version(); + } + return "0.0.0-SNAPSHOT"; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FrontendConfigurationResponse.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FrontendConfigurationResponse.java new file mode 100644 index 0000000000..c8e21bbb38 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FrontendConfigurationResponse.java @@ -0,0 +1,45 @@ +package com.bakdata.conquery.quarkus.config; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public record FrontendConfigurationResponse( + List versions, + CurrencyConfigResponse currency, + IdColumnConfigResponse queryUpload, + String manualUrl, + String contactEmail, + LocalDate observationPeriodStart +) { + + public record VersionContainerResponse( + String name, + String version, + String error + ) { + } + + public record CurrencyConfigResponse( + String unit, + String thousandSeparator, + String decimalSeparator, + int decimalScale + ) { + } + + public record IdColumnConfigResponse( + String table, + List ids + ) { + } + + public record ColumnConfigResponse( + String name, + String field, + Map label, + boolean primaryId, + boolean print + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FrontendRuntimeConfig.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FrontendRuntimeConfig.java new file mode 100644 index 0000000000..0293960ba0 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/FrontendRuntimeConfig.java @@ -0,0 +1,108 @@ +package com.bakdata.conquery.quarkus.config; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "conquery.frontend") +public interface FrontendRuntimeConfig { + + /** + * Currency formatting options returned by the frontend configuration endpoint. + */ + Currency currency(); + + /** + * Query upload id-column configuration returned by the frontend configuration endpoint. + */ + QueryUpload queryUpload(); + + /** + * Number of years before the current year used to derive the observation period start. + */ + @WithDefault("6") + int observationPeriodYears(); + + /** + * Optional manual/help URL shown by the frontend. + */ + Optional manualUrl(); + + /** + * Optional support contact email shown by the frontend. + */ + Optional contactEmail(); + + interface Currency { + /** + * Currency unit or symbol. + */ + @WithDefault("€") + String unit(); + + /** + * Thousands separator for currency values. + */ + @WithDefault(".") + String thousandSeparator(); + + /** + * Decimal separator for currency values. + */ + @WithDefault(",") + String decimalSeparator(); + + /** + * Number of fraction digits for currency values. + */ + @WithDefault("2") + int decimalScale(); + } + + interface QueryUpload { + /** + * Logical table name used by the frontend query upload workflow. + */ + @WithDefault("entities") + String table(); + + /** + * Entity id columns accepted by the frontend query upload workflow. + */ + Optional> ids(); + } + + interface IdColumn { + /** + * Display name of the upload id column. + */ + @WithDefault("ID") + String name(); + + /** + * Field name used in uploaded rows. + */ + @WithDefault("pid") + String field(); + + /** + * Localized labels for this id column. + */ + Map label(); + + /** + * Whether this id column is the primary entity id. + */ + @WithDefault("true") + boolean primaryId(); + + /** + * Whether this id column should be printed in uploaded query result displays. + */ + @WithDefault("true") + boolean print(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/JacksonConfiguration.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/JacksonConfiguration.java new file mode 100644 index 0000000000..a84b5e2bf5 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/JacksonConfiguration.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.config; + +import com.bakdata.conquery.quarkus.models.PolymorphicModelRegistry; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import io.quarkus.jackson.ObjectMapperCustomizer; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +@Singleton +public class JacksonConfiguration implements ObjectMapperCustomizer { + + @Inject + PolymorphicModelRegistry modelRegistry; + + @Override + public void customize(ObjectMapper objectMapper) { + objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); + modelRegistry.allTypes().forEach(type -> objectMapper.registerSubtypes(new NamedType(type.modelType(), type.typeId()))); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/StorageRuntimeConfig.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/StorageRuntimeConfig.java new file mode 100644 index 0000000000..e1f921f7d4 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/config/StorageRuntimeConfig.java @@ -0,0 +1,26 @@ +package com.bakdata.conquery.quarkus.config; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "conquery.storage") +public interface StorageRuntimeConfig { + /** + * Storage backend used for manager metadata and dataset catalog repositories. + */ + @WithDefault("IN_MEMORY") + String backend(); + + /** + * Xodus-specific storage settings used when the XODUS backend is enabled. + */ + Xodus xodus(); + + interface Xodus { + /** + * Base directory for Xodus-backed manager metadata and dataset-scoped environments. + */ + @WithDefault("storage/quarkus-meta") + String path(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ColumnId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ColumnId.java new file mode 100644 index 0000000000..e928aa2036 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ColumnId.java @@ -0,0 +1,37 @@ +package com.bakdata.conquery.quarkus.ids; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record ColumnId(TableId tableId, String name) { + + public ColumnId { + if (tableId == null) { + throw new IllegalArgumentException("Column id table must not be null."); + } + name = IdPart.requireValid(name, "Column id name"); + } + + public DatasetId datasetId() { + return tableId.datasetId(); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static ColumnId parse(String value) { + String[] parts = IdPart.split(value, "Column id", 3); + if (parts.length != 3) { + throw new IllegalArgumentException("Column id must have exactly three parts: " + value); + } + return new ColumnId(new TableId(DatasetId.parse(parts[0]), parts[1]), parts[2]); + } + + public static ColumnId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + return tableId + "." + name; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ConceptId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ConceptId.java new file mode 100644 index 0000000000..8897b32b1e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ConceptId.java @@ -0,0 +1,44 @@ +package com.bakdata.conquery.quarkus.ids; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record ConceptId(DatasetId datasetId, List path) { + + public ConceptId { + if (datasetId == null) { + throw new IllegalArgumentException("Concept id dataset must not be null."); + } + path = path == null ? List.of() : path.stream() + .map(part -> IdPart.requireValid(part, "Concept id path part")) + .toList(); + } + + public ConceptId child(String name) { + List childPath = new java.util.ArrayList<>(path); + childPath.add(name); + return new ConceptId(datasetId, childPath); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static ConceptId parse(String value) { + String[] parts = IdPart.split(value, "Concept id", 1); + return new ConceptId(DatasetId.parse(parts[0]), Arrays.stream(parts).skip(1).toList()); + } + + public static ConceptId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + if (path.isEmpty()) { + return datasetId.toString(); + } + return datasetId + "." + String.join(".", path); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ConceptSelectId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ConceptSelectId.java new file mode 100644 index 0000000000..f9ecf1996e --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ConceptSelectId.java @@ -0,0 +1,38 @@ +package com.bakdata.conquery.quarkus.ids; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record ConceptSelectId(ConceptId conceptId, String name) { + + public ConceptSelectId { + if (conceptId == null) { + throw new IllegalArgumentException("Concept select id concept must not be null."); + } + name = IdPart.requireValid(name, "Concept select id name"); + } + + public DatasetId datasetId() { + return conceptId.datasetId(); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static ConceptSelectId parse(String value) { + String[] parts = IdPart.split(value, "Concept select id", 2); + String selectName = parts[parts.length - 1]; + ConceptId conceptId = new ConceptId(DatasetId.parse(parts[0]), Arrays.stream(parts, 1, parts.length - 1).toList()); + return new ConceptSelectId(conceptId, selectName); + } + + public static ConceptSelectId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + return conceptId + "." + name; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ConnectorId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ConnectorId.java new file mode 100644 index 0000000000..be20615571 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ConnectorId.java @@ -0,0 +1,38 @@ +package com.bakdata.conquery.quarkus.ids; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record ConnectorId(ConceptId conceptId, String name) { + + public ConnectorId { + if (conceptId == null) { + throw new IllegalArgumentException("Connector id concept must not be null."); + } + name = IdPart.requireValid(name, "Connector id name"); + } + + public DatasetId datasetId() { + return conceptId.datasetId(); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static ConnectorId parse(String value) { + String[] parts = IdPart.split(value, "Connector id", 2); + String connectorName = parts[parts.length - 1]; + ConceptId conceptId = new ConceptId(DatasetId.parse(parts[0]), Arrays.stream(parts, 1, parts.length - 1).toList()); + return new ConnectorId(conceptId, connectorName); + } + + public static ConnectorId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + return conceptId + "." + name; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/DatasetId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/DatasetId.java new file mode 100644 index 0000000000..5a45f3fa3c --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/DatasetId.java @@ -0,0 +1,26 @@ +package com.bakdata.conquery.quarkus.ids; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record DatasetId(String name) { + + public DatasetId { + name = IdPart.requireValid(name, "Dataset id"); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static DatasetId parse(String value) { + return new DatasetId(IdPart.split(value, "Dataset id", 1)[0]); + } + + public static DatasetId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + return name; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/FilterId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/FilterId.java new file mode 100644 index 0000000000..cdfac77b45 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/FilterId.java @@ -0,0 +1,39 @@ +package com.bakdata.conquery.quarkus.ids; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record FilterId(ConnectorId connectorId, String name) { + + public FilterId { + if (connectorId == null) { + throw new IllegalArgumentException("Filter id connector must not be null."); + } + name = IdPart.requireValid(name, "Filter id name"); + } + + public DatasetId datasetId() { + return connectorId.datasetId(); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static FilterId parse(String value) { + String[] parts = IdPart.split(value, "Filter id", 3); + String filterName = parts[parts.length - 1]; + String connectorName = parts[parts.length - 2]; + ConceptId conceptId = new ConceptId(DatasetId.parse(parts[0]), Arrays.stream(parts, 1, parts.length - 2).toList()); + return new FilterId(new ConnectorId(conceptId, connectorName), filterName); + } + + public static FilterId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + return connectorId + "." + name; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/IdPart.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/IdPart.java new file mode 100644 index 0000000000..d666f20fba --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/IdPart.java @@ -0,0 +1,41 @@ +package com.bakdata.conquery.quarkus.ids; + +import java.util.regex.Pattern; + +final class IdPart { + + public static final String PART_PATTERN = "[A-Za-z0-9äöüß_/-]+"; + private static final Pattern VALID_PART = Pattern.compile(PART_PATTERN); + + private IdPart() { + } + + static String requireValid(String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + String trimmed = value.trim(); + if (!VALID_PART.matcher(trimmed).matches()) { + throw new IllegalArgumentException(label + " must match " + PART_PATTERN + " but was: " + value); + } + return trimmed; + } + + static boolean isValid(String value) { + return value != null && VALID_PART.matcher(value.trim()).matches(); + } + + static String[] split(String value, String label, int minimumParts) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + String[] parts = value.trim().split("\\.", -1); + if (parts.length < minimumParts) { + throw new IllegalArgumentException(label + " must have at least " + minimumParts + " part(s): " + value); + } + for (int index = 0; index < parts.length; index++) { + parts[index] = requireValid(parts[index], label + " part " + index); + } + return parts; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/IdPartSanitizer.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/IdPartSanitizer.java new file mode 100644 index 0000000000..207e7c2545 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/IdPartSanitizer.java @@ -0,0 +1,22 @@ +package com.bakdata.conquery.quarkus.ids; + +public final class IdPartSanitizer { + + private IdPartSanitizer() { + } + + public static String sanitize(String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + String sanitized = value.trim() + .replace('.', '_') + .replaceAll("[^A-Za-z0-9äöü_/-]+", "_") + .replaceAll("_+", "_") + .replaceAll("^_+|_+$", ""); + if (sanitized.isBlank() || !IdPart.isValid(sanitized)) { + throw new IllegalArgumentException(label + " cannot be sanitized into a valid id part: " + value); + } + return sanitized; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/SelectId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/SelectId.java new file mode 100644 index 0000000000..5b74ade972 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/SelectId.java @@ -0,0 +1,39 @@ +package com.bakdata.conquery.quarkus.ids; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record SelectId(ConnectorId connectorId, String name) { + + public SelectId { + if (connectorId == null) { + throw new IllegalArgumentException("Select id connector must not be null."); + } + name = IdPart.requireValid(name, "Select id name"); + } + + public DatasetId datasetId() { + return connectorId.datasetId(); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SelectId parse(String value) { + String[] parts = IdPart.split(value, "Select id", 3); + String selectName = parts[parts.length - 1]; + String connectorName = parts[parts.length - 2]; + ConceptId conceptId = new ConceptId(DatasetId.parse(parts[0]), Arrays.stream(parts, 1, parts.length - 2).toList()); + return new SelectId(new ConnectorId(conceptId, connectorName), selectName); + } + + public static SelectId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + return connectorId + "." + name; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/StructureNodeId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/StructureNodeId.java new file mode 100644 index 0000000000..b83842d278 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/StructureNodeId.java @@ -0,0 +1,44 @@ +package com.bakdata.conquery.quarkus.ids; + +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record StructureNodeId(DatasetId datasetId, List path) { + + public StructureNodeId { + if (datasetId == null) { + throw new IllegalArgumentException("Structure node id dataset must not be null."); + } + if (path == null || path.isEmpty()) { + throw new IllegalArgumentException("Structure node id path must not be empty."); + } + path = path.stream() + .map(part -> IdPart.requireValid(part, "Structure node id path part")) + .toList(); + } + + public StructureNodeId child(String name) { + List childPath = new java.util.ArrayList<>(path); + childPath.add(name); + return new StructureNodeId(datasetId, childPath); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static StructureNodeId parse(String value) { + String[] parts = IdPart.split(value, "Structure node id", 2); + return new StructureNodeId(DatasetId.parse(parts[0]), Arrays.stream(parts).skip(1).toList()); + } + + public static StructureNodeId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + return datasetId + "." + String.join(".", path); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/TableId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/TableId.java new file mode 100644 index 0000000000..5cc8efed4b --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/TableId.java @@ -0,0 +1,33 @@ +package com.bakdata.conquery.quarkus.ids; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record TableId(DatasetId datasetId, String name) { + + public TableId { + if (datasetId == null) { + throw new IllegalArgumentException("Table id dataset must not be null."); + } + name = IdPart.requireValid(name, "Table id name"); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static TableId parse(String value) { + String[] parts = IdPart.split(value, "Table id", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Table id must have exactly two parts: " + value); + } + return new TableId(DatasetId.parse(parts[0]), parts[1]); + } + + public static TableId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + return datasetId + "." + name; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ValidityDateId.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ValidityDateId.java new file mode 100644 index 0000000000..a7512d1be8 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/ids/ValidityDateId.java @@ -0,0 +1,39 @@ +package com.bakdata.conquery.quarkus.ids; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record ValidityDateId(ConnectorId connectorId, String name) { + + public ValidityDateId { + if (connectorId == null) { + throw new IllegalArgumentException("Validity date id connector must not be null."); + } + name = IdPart.requireValid(name, "Validity date id name"); + } + + public DatasetId datasetId() { + return connectorId.datasetId(); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static ValidityDateId parse(String value) { + String[] parts = IdPart.split(value, "Validity date id", 3); + String validityDateName = parts[parts.length - 1]; + String connectorName = parts[parts.length - 2]; + ConceptId conceptId = new ConceptId(DatasetId.parse(parts[0]), Arrays.stream(parts, 1, parts.length - 2).toList()); + return new ValidityDateId(new ConnectorId(conceptId, connectorName), validityDateName); + } + + public static ValidityDateId valueOf(String value) { + return parse(value); + } + + @Override + @JsonValue + public String toString() { + return connectorId + "." + name; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelBase.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelBase.java new file mode 100644 index 0000000000..7800850ba8 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelBase.java @@ -0,0 +1,17 @@ +package com.bakdata.conquery.quarkus.models; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface PolymorphicModelBase { + + String discriminator() default "type"; + + String schemaName(); + + String description() default ""; +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelRegistry.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelRegistry.java new file mode 100644 index 0000000000..e91f3d1254 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelRegistry.java @@ -0,0 +1,82 @@ +package com.bakdata.conquery.quarkus.models; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +@ApplicationScoped +public class PolymorphicModelRegistry { + + @Inject + Instance> providers; + + private Map, Map>> typesByBase = Map.of(); + + @PostConstruct + void init() { + Map, Map>> index = new LinkedHashMap<>(); + for (PolymorphicModelTypeProvider provider : providers) { + register(index, provider); + } + Map, Map>> immutableIndex = new LinkedHashMap<>(); + index.forEach((base, types) -> immutableIndex.put(base, Map.copyOf(types))); + typesByBase = Map.copyOf(immutableIndex); + } + + public List> allTypes() { + return typesByBase.values().stream() + .flatMap(types -> types.values().stream()) + .sorted(Comparator.comparing(type -> type.baseType().getName() + ":" + type.typeId())) + .toList(); + } + + public List> types(Class baseType) { + List> result = new ArrayList<>(); + for (PolymorphicModelType type : typesByBase.getOrDefault(baseType, Map.of()).values()) { + result.add(cast(type)); + } + result.sort(Comparator.comparing(PolymorphicModelType::typeId)); + return List.copyOf(result); + } + + public Optional> find(Class baseType, String typeId) { + return Optional.ofNullable(typesByBase.getOrDefault(baseType, Map.of()).get(typeId)).map(this::cast); + } + + private void register(Map, Map>> index, PolymorphicModelTypeProvider provider) { + Class baseType = provider.baseType(); + Class modelType = provider.modelType(); + String typeId = provider.typeId(); + if (typeId == null || typeId.isBlank()) { + throw new IllegalStateException("Polymorphic model type id must not be blank for " + modelType.getName()); + } + if (!baseType.isAssignableFrom(modelType)) { + throw new IllegalStateException("Polymorphic model " + modelType.getName() + " does not implement " + baseType.getName()); + } + if (baseType.getAnnotation(PolymorphicModelBase.class) == null) { + throw new IllegalStateException("Polymorphic model base " + baseType.getName() + " must be annotated with @PolymorphicModelBase"); + } + PolymorphicModelType descriptor = descriptor(provider); + PolymorphicModelType previous = index.computeIfAbsent(baseType, ignored -> new LinkedHashMap<>()).put(typeId, descriptor); + if (previous != null) { + throw new IllegalStateException("Duplicate polymorphic model type '" + typeId + "' for " + baseType.getName() + ": " + previous.modelType().getName() + " and " + modelType.getName()); + } + } + + private PolymorphicModelType descriptor(PolymorphicModelTypeProvider provider) { + return new PolymorphicModelType<>(provider.baseType(), provider.typeId(), provider.modelType()); + } + + @SuppressWarnings("unchecked") + private PolymorphicModelType cast(PolymorphicModelType type) { + return (PolymorphicModelType) type; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelSubtype.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelSubtype.java new file mode 100644 index 0000000000..9bdd891a5d --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelSubtype.java @@ -0,0 +1,20 @@ +package com.bakdata.conquery.quarkus.models; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares the serialized identity of a concrete polymorphic model. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface PolymorphicModelSubtype { + + Class base(); + + String id(); +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelType.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelType.java new file mode 100644 index 0000000000..19bdf035e1 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelType.java @@ -0,0 +1,8 @@ +package com.bakdata.conquery.quarkus.models; + +public record PolymorphicModelType( + Class baseType, + String typeId, + Class modelType +) { +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelTypeProvider.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelTypeProvider.java new file mode 100644 index 0000000000..44cc26b77f --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelTypeProvider.java @@ -0,0 +1,24 @@ +package com.bakdata.conquery.quarkus.models; + +public interface PolymorphicModelTypeProvider { + + Class modelType(); + + @SuppressWarnings("unchecked") + default Class baseType() { + return (Class) subtype().base(); + } + + default String typeId() { + return subtype().id(); + } + + private PolymorphicModelSubtype subtype() { + PolymorphicModelSubtype subtype = modelType().getAnnotation(PolymorphicModelSubtype.class); + if (subtype == null) { + throw new IllegalStateException("Polymorphic model " + modelType().getName() + + " must be annotated with @PolymorphicModelSubtype"); + } + return subtype; + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelsOpenApiFilter.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelsOpenApiFilter.java new file mode 100644 index 0000000000..e9eb86e428 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/models/PolymorphicModelsOpenApiFilter.java @@ -0,0 +1,229 @@ +package com.bakdata.conquery.quarkus.models; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.quarkus.arc.Arc; +import io.quarkus.smallrye.openapi.OpenApiFilter; +import org.eclipse.microprofile.openapi.OASFactory; +import org.eclipse.microprofile.openapi.OASFilter; +import org.eclipse.microprofile.openapi.models.Components; +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.eclipse.microprofile.openapi.models.media.Discriminator; +import org.eclipse.microprofile.openapi.models.media.Schema; + +/** + * Aligns the generated OpenAPI document with the runtime Jackson model. + *

+ * Vanilla SmallRye OpenAPI generation cannot fully describe the model for two reasons: + *

    + *
  • Polymorphic implementations are registered dynamically through CDI providers. The static OpenAPI scan does + * not know which implementations belong to a base type, so it cannot build the corresponding {@code oneOf} and + * discriminator mappings.
  • + *
  • Some value objects, notably IDs, are structured records in Java but use a delegating {@code @JsonCreator} + * and {@code @JsonValue} to deserialize and serialize as scalars. SmallRye otherwise exposes their internal record + * components instead of their JSON representation.
  • + *
+ * This filter projects the runtime polymorphic registry into OpenAPI and replaces affected component schemas with the + * scalar type used by Jackson. As a result, generated clients see the same shapes that the API actually accepts and + * returns. + */ +@OpenApiFilter(OpenApiFilter.RunStage.RUN) +public class PolymorphicModelsOpenApiFilter implements OASFilter { + + @Override + public void filterOpenAPI(OpenAPI openAPI) { + PolymorphicModelRegistry registry = Arc.container().instance(PolymorphicModelRegistry.class).get(); + Components components = openAPI.getComponents(); + if (components == null) { + components = OASFactory.createComponents(); + openAPI.setComponents(components); + } + + Map, List>> byBase = new LinkedHashMap<>(); + registry.allTypes().forEach(type -> byBase.computeIfAbsent(type.baseType(), ignored -> new ArrayList<>()).add(type)); + for (Map.Entry, List>> family : byBase.entrySet()) { + addFamilySchema(components, family.getKey(), family.getValue()); + } + replaceJacksonScalarSchemas(components, registry.allTypes()); + } + + private void replaceJacksonScalarSchemas(Components components, Collection> types) { + Set> visited = new HashSet<>(); + for (PolymorphicModelType type : types) { + visitReferencedTypes(components, type.modelType(), visited); + } + } + + private void visitReferencedTypes(Components components, Type type, Set> visited) { + if (type instanceof ParameterizedType parameterizedType) { + visitReferencedTypes(components, parameterizedType.getRawType(), visited); + for (Type argument : parameterizedType.getActualTypeArguments()) { + visitReferencedTypes(components, argument, visited); + } + return; + } + if (!(type instanceof Class modelType) || modelType.isPrimitive() || modelType.isArray() || !visited.add(modelType)) { + return; + } + + Optional> scalarType = jacksonScalarType(modelType); + if (scalarType.isPresent()) { + replaceComponentSchema(components, modelType, scalarType.get()); + return; + } + if (!modelType.getPackageName().startsWith("com.bakdata.conquery")) { + return; + } + + for (RecordComponent component : modelType.getRecordComponents() == null ? new RecordComponent[0] : modelType.getRecordComponents()) { + visitReferencedTypes(components, component.getGenericType(), visited); + } + for (Field field : modelType.getDeclaredFields()) { + if (!field.isSynthetic() && !Modifier.isStatic(field.getModifiers())) { + visitReferencedTypes(components, field.getGenericType(), visited); + } + } + Class superType = modelType.getSuperclass(); + if (superType != null) { + visitReferencedTypes(components, superType, visited); + } + } + + private Optional> jacksonScalarType(Class modelType) { + Optional> creatorType = delegatingCreatorType(modelType); + Optional> jsonValueType = jsonValueType(modelType); + if (creatorType.isEmpty() || jsonValueType.isEmpty()) { + return Optional.empty(); + } + if (!creatorType.get().equals(jsonValueType.get())) { + throw new IllegalStateException("Jackson scalar model " + modelType.getName() + + " uses incompatible @JsonCreator and @JsonValue types: " + + creatorType.get().getName() + " and " + jsonValueType.get().getName()); + } + return creatorType; + } + + private Optional> delegatingCreatorType(Class modelType) { + for (Method method : modelType.getDeclaredMethods()) { + JsonCreator creator = method.getAnnotation(JsonCreator.class); + if (isDelegatingCreator(creator, method.getParameterCount())) { + return Optional.of(method.getParameterTypes()[0]); + } + } + for (Constructor constructor : modelType.getDeclaredConstructors()) { + JsonCreator creator = constructor.getAnnotation(JsonCreator.class); + if (isDelegatingCreator(creator, constructor.getParameterCount())) { + return Optional.of(constructor.getParameterTypes()[0]); + } + } + return Optional.empty(); + } + + private boolean isDelegatingCreator(JsonCreator creator, int parameterCount) { + return creator != null && creator.mode() == JsonCreator.Mode.DELEGATING && parameterCount == 1; + } + + private Optional> jsonValueType(Class modelType) { + for (Method method : modelType.getDeclaredMethods()) { + if (isJsonValue(method)) { + return Optional.of(method.getReturnType()); + } + } + for (Field field : modelType.getDeclaredFields()) { + if (isJsonValue(field)) { + return Optional.of(field.getType()); + } + } + return Optional.empty(); + } + + private boolean isJsonValue(AnnotatedElement element) { + JsonValue annotation = element.getAnnotation(JsonValue.class); + return annotation != null && annotation.value(); + } + + private void replaceComponentSchema(Components components, Class modelType, Class scalarType) { + if (components.getSchemas() == null) { + return; + } + String name = schemaName(modelType); + Schema existing = components.getSchemas().get(name); + if (existing == null) { + return; + } + Schema replacement = OASFactory.createSchema().addType(openApiScalarType(modelType, scalarType)); + if (existing.getDescription() != null) { + replacement.description(existing.getDescription()); + } + components.addSchema(name, replacement); + } + + private Schema.SchemaType openApiScalarType(Class modelType, Class scalarType) { + if (scalarType == String.class || scalarType == char.class || scalarType == Character.class) { + return Schema.SchemaType.STRING; + } + if (scalarType == boolean.class || scalarType == Boolean.class) { + return Schema.SchemaType.BOOLEAN; + } + if (scalarType == byte.class || scalarType == Byte.class + || scalarType == short.class || scalarType == Short.class + || scalarType == int.class || scalarType == Integer.class + || scalarType == long.class || scalarType == Long.class) { + return Schema.SchemaType.INTEGER; + } + if (Number.class.isAssignableFrom(scalarType) || scalarType == float.class || scalarType == double.class) { + return Schema.SchemaType.NUMBER; + } + throw new IllegalStateException("Unsupported @JsonCreator scalar type " + scalarType.getName() + " on " + modelType.getName()); + } + + private void addFamilySchema(Components components, Class baseType, List> types) { + PolymorphicModelBase base = baseType.getAnnotation(PolymorphicModelBase.class); + Discriminator discriminator = OASFactory.createDiscriminator().propertyName(base.discriminator()); + List alternatives = new ArrayList<>(); + for (PolymorphicModelType type : types) { + String schemaName = schemaName(type.modelType()); + Schema concreteSchema = components.getSchemas() == null ? null : components.getSchemas().get(schemaName); + if (concreteSchema == null) { + throw new IllegalStateException("No generated OpenAPI schema found for registered polymorphic model " + type.modelType().getName() + ". Annotate and index the model class so SmallRye OpenAPI can discover it."); + } + concreteSchema.addProperty(base.discriminator(), OASFactory.createSchema() + .addType(Schema.SchemaType.STRING) + .constValue(type.typeId())); + concreteSchema.addRequired(base.discriminator()); + String reference = "#/components/schemas/" + schemaName; + discriminator.addMapping(type.typeId(), reference); + alternatives.add(OASFactory.createSchema().ref(reference)); + } + Schema familySchema = OASFactory.createSchema() + .description(base.description()) + .discriminator(discriminator) + .oneOf(List.copyOf(alternatives)); + components.addSchema(base.schemaName(), familySchema); + } + + private String schemaName(Class modelType) { + org.eclipse.microprofile.openapi.annotations.media.Schema annotation = modelType.getAnnotation(org.eclipse.microprofile.openapi.annotations.media.Schema.class); + if (annotation != null && !annotation.name().isBlank()) { + return annotation.name(); + } + return modelType.getSimpleName(); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/DatasetService.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/DatasetService.java new file mode 100644 index 0000000000..1c79e85b25 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/DatasetService.java @@ -0,0 +1,107 @@ +package com.bakdata.conquery.quarkus.services; + +import java.util.List; + +import com.bakdata.conquery.quarkus.config.StorageRuntimeConfig; +import com.bakdata.conquery.quarkus.ids.ConceptId; +import com.bakdata.conquery.quarkus.ids.DatasetId; +import com.bakdata.conquery.quarkus.ids.TableId; +import com.bakdata.conquery.quarkus.storage.DatasetCatalogRepository; +import com.bakdata.conquery.quarkus.storage.NamespaceStorage; +import com.bakdata.conquery.quarkus.storage.NamespaceStorageRegistry; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.common.NotImplementedYet; + +@ApplicationScoped +public class DatasetService { + + @Inject + NamespaceStorageRegistry namespaceStorageRegistry; + + @Inject + @SuppressWarnings("unused") + StorageRuntimeConfig storageRuntimeConfig; + + public List listDatasets() { + return namespaceStorageRegistry.listDatasets(); + } + + public DatasetCatalogRepository.DatasetRecord requireDataset(String datasetId) { + return requireDataset(DatasetId.parse(datasetId)); + } + + public DatasetCatalogRepository.DatasetRecord requireDataset(DatasetId datasetId) { + return requireNamespace(datasetId).dataset(); + } + + public DatasetCatalogRepository.Concept requireConcept(String conceptId) { + return requireConcept(ConceptId.parse(conceptId)); + } + + public DatasetCatalogRepository.Concept requireConcept(ConceptId conceptId) { + NamespaceStorage namespace = requireNamespace(conceptId.datasetId()); + return namespace.findConcept(conceptId).orElseThrow(() -> new NotFoundException("Unknown concept: " + conceptId)); + } + + public List listRootConceptsForDataset(String datasetId) { + return listRootConceptsForDataset(DatasetId.parse(datasetId)); + } + + public List listRootConceptsForDataset(DatasetId datasetId) { + return requireNamespace(datasetId).listConcepts(); + } + + public List listStructureNodesForDataset(String datasetId) { + return requireNamespace(DatasetId.parse(datasetId)).listStructureNodes(); + } + + public List listTablesForDataset(String datasetId) { + return listTablesForDataset(DatasetId.parse(datasetId)); + } + + public List listTablesForDataset(DatasetId datasetId) { + return requireNamespace(datasetId).listTables(); + } + + public DatasetCatalogRepository.TableRecord requireTable(String tableId) { + return requireTable(TableId.parse(tableId)); + } + + public DatasetCatalogRepository.TableRecord requireTable(TableId tableId) { + NamespaceStorage namespace = requireNamespace(tableId.datasetId()); + return namespace.findTable(tableId).orElseThrow(() -> new NotFoundException("Unknown table: " + tableId)); + } + + public ConceptCodeResolution resolveConceptCodes(String rootConceptId, List codes) { + ConceptId parsedRootConceptId = ConceptId.parse(rootConceptId); + DatasetCatalogRepository.Concept rootConcept = requireConcept(parsedRootConceptId); + NamespaceStorage namespace = requireNamespace(parsedRootConceptId.datasetId()); + + // TODO implement with updated concept model + throw new NotImplementedYet(); + +// List resolvedInOrder = codes.stream() +// .map(this::normalizeCode) +// .map(lookupByCode::get) +// .filter(java.util.Objects::nonNull) +// .toList(); +// List unknownCodes = codes.stream() +// .filter(code -> !lookupByCode.containsKey(normalizeCode(code))) +// .toList(); +// +// return new ConceptCodeResolution(List.copyOf(new LinkedHashSet<>(resolvedInOrder)), unknownCodes); + } + + private NamespaceStorage requireNamespace(DatasetId datasetId) { + return namespaceStorageRegistry.findNamespace(datasetId) + .orElseThrow(() -> new NotFoundException("Unknown dataset: " + datasetId)); + } + + public record ConceptCodeResolution( + List resolvedConcepts, + List unknownCodes + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/EntityQueryService.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/EntityQueryService.java new file mode 100644 index 0000000000..2e0a81bc96 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/EntityQueryService.java @@ -0,0 +1,90 @@ +package com.bakdata.conquery.quarkus.services; + +import java.util.List; +import java.util.Map; + +import com.bakdata.conquery.quarkus.api.QuerySubmissionPayload; +import com.bakdata.conquery.quarkus.concepts.filters.values.FilterValue; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +@ApplicationScoped +public class EntityQueryService { + + // TODO(quarkus-migration): Implement entity queries against dataset data. The records below only preserve the API models. + public EntityHistoryResponse getEntityHistory(EntityHistoryRequest request) { + throw notImplemented(); + } + + public List> resolveEntities(List filters) { + throw notImplemented(); + } + + private WebApplicationException notImplemented() { + return new WebApplicationException("Entity queries are not implemented yet.", Response.Status.NOT_IMPLEMENTED); + } + + public record EntityHistoryRequest( + String idKind, + String entityId, + QuerySubmissionPayload.DateRangePayload time, + List sources + ) { + } + + public record EntityHistoryResponse( + List resultUrls, + List columnDescriptions, + List infos, + List timeStratifiedInfos + ) { + } + + public record ResultUrlWithLabelResponse( + String label, + String url + ) { + } + + public record ColumnDescriptionResponse( + String label, + String description, + String type, + List> semantics, + String defaultLabel, + String selectId, + String userConceptLabel + ) { + } + + public record EntityInfoResponse( + String label, + String value, + String type, + List> semantics + ) { + } + + public record TimeStratifiedInfoResponse( + String label, + String description, + Map totals, + List columns, + List years + ) { + } + + public record TimeStratifiedInfoYearResponse( + int year, + Map values, + List quarters + ) { + } + + public record TimeStratifiedInfoQuarterResponse( + int quarter, + Map values + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/FormConfigService.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/FormConfigService.java new file mode 100644 index 0000000000..fb06edd2d7 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/FormConfigService.java @@ -0,0 +1,123 @@ +package com.bakdata.conquery.quarkus.services; + +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import com.bakdata.conquery.quarkus.api.DatasetFormConfigResource; +import com.bakdata.conquery.quarkus.api.FormConfigResource; +import com.bakdata.conquery.quarkus.storage.meta.ManagerMetaStorage; +import com.bakdata.conquery.quarkus.storage.model.StoredFormConfig; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; + +@ApplicationScoped +public class FormConfigService { + + @Inject + ManagerMetaStorage metaStorage; + + public DatasetFormConfigResource.PostFormConfigResponse create( + String datasetId, + DatasetFormConfigResource.FormConfigCreatePayload payload, + String ownerName + ) { + String id = datasetId + ":" + payload.formType + ":" + UUID.randomUUID(); + StoredFormConfig stored = new StoredFormConfig( + id, + datasetId, + payload.formType, + payload.label, + List.copyOf(payload.tags), + ownerName, + Instant.now(), + false, + List.of(), + payload.values + ); + metaStorage.formConfigs().save(stored); + return new DatasetFormConfigResource.PostFormConfigResponse(id); + } + + public List list( + String datasetId, + Set requestedFormTypes, + String requester + ) { + return metaStorage.formConfigs().listByDataset(datasetId).stream() + .filter(config -> requestedFormTypes == null + || requestedFormTypes.isEmpty() + || requestedFormTypes.contains(config.getFormType())) + .sorted((left, right) -> right.getCreatedAt().compareTo(left.getCreatedAt())) + .map(config -> toOverview(config, requester)) + .toList(); + } + + public FormConfigResource.FormConfigFullResponse get(String formConfigId, String requester) { + return toFull(require(formConfigId), requester); + } + + public FormConfigResource.FormConfigFullResponse patch( + String formConfigId, + FormConfigResource.FormConfigPatchPayload payload, + String requester + ) { + StoredFormConfig config = require(formConfigId); + if (payload.label != null) { + config.setLabel(payload.label); + } + if (payload.tags != null) { + config.setTags(List.copyOf(payload.tags)); + } + if (payload.groups != null) { + config.setGroups(List.copyOf(payload.groups)); + } + if (payload.values != null) { + config.setValues(payload.values); + } + metaStorage.formConfigs().save(config); + return toFull(config, requester); + } + + public void delete(String formConfigId) { + if (!metaStorage.formConfigs().deleteById(formConfigId)) { + throw new NotFoundException("Unknown form-config: " + formConfigId); + } + } + + private DatasetFormConfigResource.FormConfigOverviewResponse toOverview(StoredFormConfig config, String requester) { + return new DatasetFormConfigResource.FormConfigOverviewResponse( + config.getId(), + config.getFormType(), + config.getLabel(), + config.getTags(), + config.getOwnerName(), + config.getCreatedAt().toString(), + config.getOwnerName().equals(requester), + !config.getGroups().isEmpty(), + config.isSystem() + ); + } + + private FormConfigResource.FormConfigFullResponse toFull(StoredFormConfig config, String requester) { + return new FormConfigResource.FormConfigFullResponse( + config.getId(), + config.getFormType(), + config.getLabel(), + config.getTags(), + config.getOwnerName(), + config.getCreatedAt().toString(), + config.getOwnerName().equals(requester), + !config.getGroups().isEmpty(), + config.isSystem(), + config.getGroups(), + config.getValues() + ); + } + + private StoredFormConfig require(String formConfigId) { + return metaStorage.formConfigs().findById(formConfigId).orElseThrow(() -> new NotFoundException("Unknown form-config: " + formConfigId)); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/QueryStateService.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/QueryStateService.java new file mode 100644 index 0000000000..daa11b9b9d --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/QueryStateService.java @@ -0,0 +1,287 @@ +package com.bakdata.conquery.quarkus.services; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import com.bakdata.conquery.quarkus.api.DatasetsResource; +import com.bakdata.conquery.quarkus.api.QueryResource; +import com.bakdata.conquery.quarkus.api.QuerySubmissionPayload; +import com.bakdata.conquery.quarkus.storage.meta.ManagerMetaStorage; +import com.bakdata.conquery.quarkus.storage.model.StoredQuery; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; + +@ApplicationScoped +public class QueryStateService { + + @Inject + ManagerMetaStorage metaStorage; + + public DatasetsResource.StartQueryResponse createQuery(String datasetId, QuerySubmissionPayload payload, String ownerName) { + String queryId = UUID.randomUUID().toString(); + Instant createdAt = Instant.now(); + QueryResource.QueryType queryType = resolveQueryType(payload); + QueryResource.QueryDefinition definition = toQueryDefinition(payload, queryType); + + StoredQuery stored = new StoredQuery( + queryId, + datasetId, + labelFor(queryId), + createdAt, + ownerName, + definition, + extractSecondaryId(payload), + containsDates(extractRoot(payload)), + QueryResource.QueryStatus.NEW, + List.of(), + false, + List.of() + ); + + metaStorage.queries().save(stored); + return new DatasetsResource.StartQueryResponse(queryId); + } + + public List getDatasetQueries(String datasetId) { + return metaStorage.queries().listByDataset(datasetId).stream() + .sorted(Comparator.comparing(StoredQuery::getCreatedAt).reversed()) + .map(this::toSummaryResponse) + .toList(); + } + + public QueryResource.QueryResponse getQuery(String queryId) { + StoredQuery query = requireQuery(queryId); + return toQueryResponse(query); + } + + public void cancelQuery(String queryId) { + StoredQuery query = requireQuery(queryId); + if (query.getStatus() == QueryResource.QueryStatus.NEW || query.getStatus() == QueryResource.QueryStatus.RUNNING) { + query.setStatus(QueryResource.QueryStatus.CANCELED); + } + } + + public void patchQuery(String queryId, QueryPatch patch) { + StoredQuery query = requireQuery(queryId); + if (patch.label() != null && !patch.label().isBlank()) { + query.setLabel(patch.label()); + } + if (patch.tags() != null) { + query.setTags(List.copyOf(patch.tags())); + } + if (patch.shared() != null) { + query.setShared(patch.shared()); + } + if (patch.groups() != null) { + query.setGroups(List.copyOf(patch.groups())); + } + } + + public void deleteQuery(String queryId) { + if (!metaStorage.queries().deleteById(queryId)) { + throw new NotFoundException("Unknown query: " + queryId); + } + } + + private DatasetsResource.QuerySummaryResponse toSummaryResponse(StoredQuery query) { + return new DatasetsResource.QuerySummaryResponse( + query.getId(), + query.getLabel(), + query.getStatus() == QueryResource.QueryStatus.DONE ? 0L : null, + query.getCreatedAt().toString(), + query.getTags(), + true, + query.getOwnerName(), + false, + List.of(), + query.isShared(), + false, + query.getDefinition().type.name(), + query.getSecondaryId(), + query.isContainsDates() + ); + } + + private QueryResource.QueryResponse toQueryResponse(StoredQuery query) { + return switch (query.getStatus()) { + case RUNNING -> new QueryResource.RunningQueryResponse( + query.getId(), + query.getLabel(), + query.getCreatedAt().toString(), + true, + query.isShared(), + false, + query.getTags(), + query.getDefinition(), + query.getSecondaryId(), + query.getOwnerName(), + query.getGroups(), + false, + List.of(), + null + ); + case DONE -> new QueryResource.DoneQueryResponse( + query.getId(), + query.getLabel(), + query.getCreatedAt().toString(), + true, + query.isShared(), + false, + query.getTags(), + query.getDefinition(), + query.getSecondaryId(), + query.getOwnerName(), + query.getGroups(), + false, + List.of(), + 0L, + List.of(), + query.getDefinition().type, + 0L, + query.isContainsDates() + ); + case FAILED -> new QueryResource.FailedQueryResponse( + query.getId(), + query.getLabel(), + query.getCreatedAt().toString(), + true, + query.isShared(), + false, + query.getTags(), + query.getDefinition(), + query.getSecondaryId(), + query.getOwnerName(), + query.getGroups(), + false, + List.of(), + new QueryResource.ErrorResponse("Query failed", "FAILED") + ); + case CANCELED -> new QueryResource.CanceledQueryResponse( + query.getId(), + query.getLabel(), + query.getCreatedAt().toString(), + true, + query.isShared(), + false, + query.getTags(), + query.getDefinition(), + query.getSecondaryId(), + query.getOwnerName(), + query.getGroups(), + false, + List.of(), + new QueryResource.ErrorResponse("Query canceled", "CANCELED") + ); + case NEW -> new QueryResource.NewQueryResponse( + query.getId(), + query.getLabel(), + query.getCreatedAt().toString(), + true, + query.isShared(), + false, + query.getTags(), + query.getDefinition(), + query.getSecondaryId(), + query.getOwnerName(), + query.getGroups(), + false, + List.of() + ); + }; + } + + private QueryResource.QueryDefinition toQueryDefinition(QuerySubmissionPayload payload, QueryResource.QueryType queryType) { + QuerySubmissionPayload.QueryNode root = extractRoot(payload); + return switch (queryType) { + case SECONDARY_ID_QUERY -> new QueryResource.SecondaryIdQueryDefinition(root, extractSecondaryId(payload)); + case CONCEPT_QUERY -> new QueryResource.ConceptQueryDefinition(root); + }; + } + + private QueryResource.QueryType resolveQueryType(QuerySubmissionPayload payload) { + if (payload instanceof QuerySubmissionPayload.SecondaryIdQuerySubmissionPayload) { + return QueryResource.QueryType.SECONDARY_ID_QUERY; + } + if (payload instanceof QuerySubmissionPayload.ConceptQuerySubmissionPayload) { + return QueryResource.QueryType.CONCEPT_QUERY; + } + if (payload != null && payload.type != null) { + try { + return QueryResource.QueryType.valueOf(payload.type.toUpperCase(Locale.ROOT)); + } + catch (IllegalArgumentException ignored) { + // Fall through to heuristic handling. + } + } + String secondaryId = extractSecondaryId(payload); + if (secondaryId != null && !secondaryId.isBlank()) { + return QueryResource.QueryType.SECONDARY_ID_QUERY; + } + return QueryResource.QueryType.CONCEPT_QUERY; + } + + private String extractSecondaryId(QuerySubmissionPayload payload) { + if (payload instanceof QuerySubmissionPayload.SecondaryIdQuerySubmissionPayload secondaryIdPayload) { + return secondaryIdPayload.secondaryId; + } + return null; + } + + private QuerySubmissionPayload.QueryNode extractRoot(QuerySubmissionPayload payload) { + if (payload instanceof QuerySubmissionPayload.ConceptQuerySubmissionPayload conceptPayload) { + return conceptPayload.root; + } + if (payload instanceof QuerySubmissionPayload.SecondaryIdQuerySubmissionPayload secondaryIdPayload) { + return secondaryIdPayload.root; + } + return null; + } + + private boolean containsDates(QuerySubmissionPayload.QueryNode node) { + if (node == null) { + return false; + } + if (node instanceof QuerySubmissionPayload.DateRestrictionNode || node instanceof QuerySubmissionPayload.TemporalNode) { + return true; + } + return switch (node) { + case QuerySubmissionPayload.AndNode andNode -> anyContainsDates(andNode.children); + case QuerySubmissionPayload.OrNode orNode -> anyContainsDates(orNode.children); + case QuerySubmissionPayload.NegationNode negationNode -> containsDates(negationNode.child); + default -> false; + }; + } + + private boolean anyContainsDates(List children) { + if (children == null || children.isEmpty()) { + return false; + } + for (QuerySubmissionPayload.QueryNode child : new ArrayList<>(children)) { + if (containsDates(child)) { + return true; + } + } + return false; + } + + private StoredQuery requireQuery(String queryId) { + return metaStorage.queries().findById(queryId).orElseThrow(() -> new NotFoundException("Unknown query: " + queryId)); + } + + private static String labelFor(String queryId) { + return "Query " + queryId; + } + + public record QueryPatch( + String label, + List tags, + Boolean shared, + List groups + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/QueryUploadService.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/QueryUploadService.java new file mode 100644 index 0000000000..5a3bc6116f --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/services/QueryUploadService.java @@ -0,0 +1,48 @@ +package com.bakdata.conquery.quarkus.services; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class QueryUploadService { + + public UploadResult processUpload(QueryUploadPayload payload) { + int expectedColumns = payload.format().size(); + List> unresolvedId = new ArrayList<>(); + List> unreadableDate = new ArrayList<>(); + int resolved = 0; + + for (List row : payload.values()) { + if (row == null || row.isEmpty()) { + unresolvedId.add(List.of()); + continue; + } + if (row.size() < expectedColumns) { + unresolvedId.add(List.copyOf(row)); + continue; + } + // TODO(quarkus-migration): Implement actual ID resolution against dataset contents. + // We currently only validate row shape. We still need to verify that externally + // provided IDs are known in the selected dataset and mark unknown ones unresolved. + resolved++; + } + + return new UploadResult(resolved, unresolvedId, unreadableDate); + } + + public record QueryUploadPayload( + List format, + List> values, + String label + ) { + } + + public record UploadResult( + int resolved, + List> unresolvedId, + List> unreadableDate + ) { + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/storage/ConfigDatasetCatalogRepository.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/storage/ConfigDatasetCatalogRepository.java new file mode 100644 index 0000000000..d50ad03101 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/storage/ConfigDatasetCatalogRepository.java @@ -0,0 +1,141 @@ +package com.bakdata.conquery.quarkus.storage; + +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import com.bakdata.conquery.quarkus.ids.ConceptId; +import com.bakdata.conquery.quarkus.ids.DatasetId; +import com.bakdata.conquery.quarkus.ids.StructureNodeId; +import com.bakdata.conquery.quarkus.ids.TableId; +import io.quarkus.arc.properties.IfBuildProperty; +import io.quarkus.runtime.Startup; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +@Startup +@IfBuildProperty(name = "conquery.storage.backend", stringValue = "IN_MEMORY", enableIfMissing = true) +public class ConfigDatasetCatalogRepository implements DatasetCatalogRepository { + + @Inject + DatasetMetadataFolderLoader metadataFolderLoader; + + private Map datasetsById = Map.of(); + private Map> conceptsByDatasetId = Map.of(); + private Map> structureNodesByDatasetId = Map.of(); + private Map> tablesByDatasetId = Map.of(); + + @PostConstruct + void init() { + Map datasetIndex = new LinkedHashMap<>(); + Map> conceptDatasetIndex = new LinkedHashMap<>(); + Map> structureNodeDatasetIndex = new LinkedHashMap<>(); + Map> tableDatasetIndex = new LinkedHashMap<>(); + + metadataFolderLoader.loadConfiguredDatasets().forEach(dataset -> { + datasetIndex.put(dataset.dataset().id(), dataset.dataset()); + conceptDatasetIndex.put(dataset.dataset().id(), new LinkedHashMap<>(dataset.conceptsById())); + structureNodeDatasetIndex.put(dataset.dataset().id(), new LinkedHashMap<>(dataset.structureNodesById())); + tableDatasetIndex.put(dataset.dataset().id(), new LinkedHashMap<>(dataset.tablesById())); + }); + + datasetsById = java.util.Collections.unmodifiableMap(new LinkedHashMap<>(datasetIndex)); + conceptsByDatasetId = freezeNestedMap(conceptDatasetIndex); + structureNodesByDatasetId = freezeNestedMap(structureNodeDatasetIndex); + tablesByDatasetId = freezeNestedMap(tableDatasetIndex); + } + + @Override + public List listDatasets() { + return datasetsById.values().stream().toList(); + } + + @Override + public Optional findDataset(DatasetId datasetId) { + return Optional.ofNullable(datasetsById.get(datasetId)); + } + + @Override + public void saveDataset(DatasetRecord dataset) { + throw readOnly(); + } + + @Override + public boolean deleteDataset(DatasetId datasetId) { + throw readOnly(); + } + + @Override + public List listConceptsForDataset(DatasetId datasetId) { + return conceptsByDatasetId.getOrDefault(datasetId, Map.of()).values().stream().toList(); + } + + @Override + public Optional findConcept(ConceptId conceptId) { + return Optional.ofNullable(conceptsByDatasetId.getOrDefault(conceptId.datasetId(), Map.of()).get(conceptId)); + } + + @Override + public void saveConcept(Concept concept) { + throw readOnly(); + } + + @Override + public boolean deleteConcept(ConceptId conceptId) { + throw readOnly(); + } + + @Override + public List listStructureNodesForDataset(DatasetId datasetId) { + return structureNodesByDatasetId.getOrDefault(datasetId, Map.of()).values().stream().toList(); + } + + @Override + public Optional findStructureNode(StructureNodeId structureNodeId) { + return Optional.ofNullable(structureNodesByDatasetId.getOrDefault(structureNodeId.datasetId(), Map.of()).get(structureNodeId)); + } + + @Override + public void saveStructureNode(StructureNode structureNode) { + throw readOnly(); + } + + @Override + public boolean deleteStructureNode(StructureNodeId structureNodeId) { + throw readOnly(); + } + + public List listTablesForDataset(DatasetId datasetId) { + return tablesByDatasetId.getOrDefault(datasetId, Map.of()).values().stream().toList(); + } + + @Override + public Optional findTable(TableId tableId) { + return Optional.ofNullable(tablesByDatasetId.getOrDefault(tableId.datasetId(), Map.of()).get(tableId)); + } + + @Override + public void saveTable(TableRecord table) { + throw readOnly(); + } + + @Override + public boolean deleteTable(TableId tableId) { + throw readOnly(); + } + + private Map> freezeNestedMap(Map> source) { + Map> result = new LinkedHashMap<>(); + source.forEach((datasetId, entries) -> result.put(datasetId, java.util.Collections.unmodifiableMap(new LinkedHashMap<>(entries)))); + return java.util.Collections.unmodifiableMap(result); + } + + private UnsupportedOperationException readOnly() { + return new UnsupportedOperationException( + "ConfigDatasetCatalogRepository is read-only. Adapt application configuration and restart." + ); + } +} diff --git a/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/storage/DatasetCatalogRepository.java b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/storage/DatasetCatalogRepository.java new file mode 100644 index 0000000000..81d0dd63d5 --- /dev/null +++ b/backend-quarkus/src/main/java/com/bakdata/conquery/quarkus/storage/DatasetCatalogRepository.java @@ -0,0 +1,232 @@ +package com.bakdata.conquery.quarkus.storage; + +import java.util.*; + +import com.bakdata.conquery.quarkus.concepts.conditions.ConceptCondition; +import com.bakdata.conquery.quarkus.ids.ColumnId; +import com.bakdata.conquery.quarkus.ids.ConceptId; +import com.bakdata.conquery.quarkus.ids.ConceptSelectId; +import com.bakdata.conquery.quarkus.ids.ConnectorId; +import com.bakdata.conquery.quarkus.ids.DatasetId; +import com.bakdata.conquery.quarkus.ids.FilterId; +import com.bakdata.conquery.quarkus.ids.SelectId; +import com.bakdata.conquery.quarkus.ids.StructureNodeId; +import com.bakdata.conquery.quarkus.ids.TableId; +import com.bakdata.conquery.quarkus.ids.ValidityDateId; + +public interface DatasetCatalogRepository { + + List listDatasets(); + + Optional findDataset(DatasetId datasetId); + + void saveDataset(DatasetRecord dataset); + + boolean deleteDataset(DatasetId datasetId); + + List listConceptsForDataset(DatasetId datasetId); + + Optional findConcept(ConceptId conceptId); + + void saveConcept(Concept concept); + + boolean deleteConcept(ConceptId conceptId); + + List listStructureNodesForDataset(DatasetId datasetId); + + Optional findStructureNode(StructureNodeId structureNodeId); + + void saveStructureNode(StructureNode structureNode); + + boolean deleteStructureNode(StructureNodeId structureNodeId); + + List listTablesForDataset(DatasetId datasetId); + + Optional findTable(TableId tableId); + + void saveTable(TableRecord table); + + boolean deleteTable(TableId tableId); + + record DatasetRecord( + DatasetId id, + String label + ) { + } + + record ConceptElement( + ConceptId id, + String label, + String description, + List additionalInfos, + ConceptId parentId, + List children, + ConceptCondition condition + ) { + public ConceptElement(ConceptId id, String label, String description) { + this(id, label, description, List.of(), null, List.of(), null); + } + + public ConceptElement { + additionalInfos = additionalInfos == null ? List.of() : List.copyOf(additionalInfos); + children = children == null ? List.of() : List.copyOf(children); + } + + } + + record Concept( + ConceptId id, + String label, + String description, + List additionalInfos, + boolean defaultExcludeFromTimeAggregation, + // All Children flat lookup + Map children, + // Direct children + List childrenIds, + List connectors, + List selects + + ){ + public Concept { + additionalInfos = additionalInfos == null ? List.of() : List.copyOf(additionalInfos); + selects = selects == null ? List.of() : List.copyOf(selects); + } + } + + record AdditionalInfo(String key, String value) { + } + + record StructureNode( + StructureNodeId id, + String label, + String description, + int sourceOrder, + StructureNodeId parentId, + List children, + List containedRoots + ) { + public StructureNode { + children = children == null ? List.of() : List.copyOf(children); + containedRoots = containedRoots == null ? List.of() : List.copyOf(containedRoots); + } + + // TODO Add structure-node additionalInfos once the shared frontend metadata model is migrated. + } + + record Connector( + ConnectorId id, + TableId tableId, + ColumnId columnId, + String label, + String name, + List