-
Notifications
You must be signed in to change notification settings - Fork 0
Add custom data mapping: per-column MIGRATE_AS_IS/MASK wizard with schema browsing #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f3dcf20
6534744
5823852
480ccce
73c6188
dc01b92
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package com.opendatamask.adapter.input.rest | ||
|
|
||
| import com.opendatamask.domain.port.input.CustomDataMappingUseCase | ||
| import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest | ||
| import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest | ||
| import com.opendatamask.domain.port.input.dto.CustomDataMappingResponse | ||
| import jakarta.validation.Valid | ||
| import org.springframework.http.HttpStatus | ||
| import org.springframework.http.ResponseEntity | ||
| import org.springframework.web.bind.annotation.* | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/workspaces/{workspaceId}/mappings") | ||
| class CustomDataMappingController( | ||
| private val customDataMappingService: CustomDataMappingUseCase | ||
| ) { | ||
|
|
||
| @PostMapping | ||
| fun createMapping( | ||
| @PathVariable workspaceId: Long, | ||
| @Valid @RequestBody request: CustomDataMappingRequest | ||
| ): ResponseEntity<CustomDataMappingResponse> = | ||
| ResponseEntity.status(HttpStatus.CREATED) | ||
| .body(customDataMappingService.createMapping(workspaceId, request)) | ||
|
|
||
| @GetMapping("/{mappingId}") | ||
| fun getMapping( | ||
| @PathVariable workspaceId: Long, | ||
| @PathVariable mappingId: Long | ||
| ): ResponseEntity<CustomDataMappingResponse> = | ||
| ResponseEntity.ok(customDataMappingService.getMapping(workspaceId, mappingId)) | ||
|
|
||
| @GetMapping | ||
| fun listMappings( | ||
| @PathVariable workspaceId: Long, | ||
| @RequestParam(required = false) connectionId: Long?, | ||
| @RequestParam(required = false) tableName: String? | ||
| ): ResponseEntity<List<CustomDataMappingResponse>> { | ||
| val hasConnectionId = connectionId != null | ||
| val hasTableName = !tableName.isNullOrBlank() | ||
| if (hasConnectionId != hasTableName) { | ||
| return ResponseEntity.badRequest().build() | ||
| } | ||
| val result = if (hasConnectionId && hasTableName) { | ||
| customDataMappingService.listMappingsForTable(workspaceId, connectionId!!, tableName!!) | ||
| } else { | ||
| customDataMappingService.listMappings(workspaceId) | ||
| } | ||
| return ResponseEntity.ok(result) | ||
| } | ||
|
|
||
| @PutMapping("/{mappingId}") | ||
| fun updateMapping( | ||
| @PathVariable workspaceId: Long, | ||
| @PathVariable mappingId: Long, | ||
| @Valid @RequestBody request: CustomDataMappingRequest | ||
| ): ResponseEntity<CustomDataMappingResponse> = | ||
| ResponseEntity.ok(customDataMappingService.updateMapping(workspaceId, mappingId, request)) | ||
|
|
||
| @DeleteMapping("/{mappingId}") | ||
| fun deleteMapping( | ||
| @PathVariable workspaceId: Long, | ||
| @PathVariable mappingId: Long | ||
| ): ResponseEntity<Void> { | ||
| customDataMappingService.deleteMapping(workspaceId, mappingId) | ||
| return ResponseEntity.noContent().build() | ||
| } | ||
|
|
||
| @PostMapping("/bulk") | ||
| fun saveBulkMappings( | ||
| @PathVariable workspaceId: Long, | ||
| @Valid @RequestBody request: BulkCustomDataMappingRequest | ||
| ): ResponseEntity<List<CustomDataMappingResponse>> = | ||
| ResponseEntity.ok(customDataMappingService.saveBulkMappings(workspaceId, request)) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.opendatamask.adapter.output.persistence | ||
|
|
||
| import com.opendatamask.domain.model.CustomDataMapping | ||
| import com.opendatamask.domain.port.output.CustomDataMappingPort | ||
| import org.springframework.data.jpa.repository.JpaRepository | ||
| import org.springframework.stereotype.Repository | ||
| import java.util.Optional | ||
|
|
||
| @Repository | ||
| interface CustomDataMappingRepository : JpaRepository<CustomDataMapping, Long>, CustomDataMappingPort { | ||
| override fun findById(id: Long): Optional<CustomDataMapping> | ||
| override fun findByWorkspaceId(workspaceId: Long): List<CustomDataMapping> | ||
| override fun findByWorkspaceIdAndConnectionIdAndTableName( | ||
| workspaceId: Long, | ||
| connectionId: Long, | ||
| tableName: String | ||
| ): List<CustomDataMapping> | ||
| override fun save(mapping: CustomDataMapping): CustomDataMapping | ||
| override fun deleteById(id: Long) | ||
| override fun deleteByWorkspaceIdAndConnectionIdAndTableName( | ||
| workspaceId: Long, | ||
| connectionId: Long, | ||
| tableName: String | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| package com.opendatamask.application.service | ||
|
|
||
| import com.opendatamask.domain.model.CustomDataMapping | ||
| import com.opendatamask.domain.model.GeneratorType | ||
| import com.opendatamask.domain.model.MappingAction | ||
| import com.opendatamask.domain.model.MaskingStrategy | ||
| import com.opendatamask.domain.port.input.CustomDataMappingUseCase | ||
| import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest | ||
| import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest | ||
| import com.opendatamask.domain.port.input.dto.CustomDataMappingResponse | ||
| import com.opendatamask.domain.port.output.CustomDataMappingPort | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
|
|
||
| @Service | ||
| class CustomDataMappingService( | ||
| private val customDataMappingRepository: CustomDataMappingPort | ||
| ) : CustomDataMappingUseCase { | ||
|
|
||
| @Transactional | ||
| override fun createMapping(workspaceId: Long, request: CustomDataMappingRequest): CustomDataMappingResponse { | ||
| validateMaskingCombination(request.action, request.maskingStrategy, request.fakeGeneratorType) | ||
| val mapping = CustomDataMapping( | ||
| workspaceId = workspaceId, | ||
| connectionId = request.connectionId, | ||
| tableName = request.tableName, | ||
| columnName = request.columnName, | ||
| action = request.action, | ||
| maskingStrategy = if (request.action == MappingAction.MASK) request.maskingStrategy else null, | ||
| fakeGeneratorType = if (request.action == MappingAction.MASK) request.fakeGeneratorType else null | ||
| ) | ||
| return customDataMappingRepository.save(mapping).toResponse() | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| override fun getMapping(workspaceId: Long, mappingId: Long): CustomDataMappingResponse = | ||
| findMapping(workspaceId, mappingId).toResponse() | ||
|
|
||
| @Transactional(readOnly = true) | ||
| override fun listMappings(workspaceId: Long): List<CustomDataMappingResponse> = | ||
| customDataMappingRepository.findByWorkspaceId(workspaceId).map { it.toResponse() } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| override fun listMappingsForTable( | ||
| workspaceId: Long, | ||
| connectionId: Long, | ||
| tableName: String | ||
| ): List<CustomDataMappingResponse> = | ||
| customDataMappingRepository | ||
| .findByWorkspaceIdAndConnectionIdAndTableName(workspaceId, connectionId, tableName) | ||
| .map { it.toResponse() } | ||
|
|
||
| @Transactional | ||
| override fun updateMapping( | ||
| workspaceId: Long, | ||
| mappingId: Long, | ||
| request: CustomDataMappingRequest | ||
| ): CustomDataMappingResponse { | ||
| validateMaskingCombination(request.action, request.maskingStrategy, request.fakeGeneratorType) | ||
| val mapping = findMapping(workspaceId, mappingId) | ||
| mapping.connectionId = request.connectionId | ||
| mapping.tableName = request.tableName | ||
| mapping.columnName = request.columnName | ||
| mapping.action = request.action | ||
| mapping.maskingStrategy = if (request.action == MappingAction.MASK) request.maskingStrategy else null | ||
| mapping.fakeGeneratorType = if (request.action == MappingAction.MASK) request.fakeGeneratorType else null | ||
| return customDataMappingRepository.save(mapping).toResponse() | ||
| } | ||
|
|
||
| @Transactional | ||
| override fun deleteMapping(workspaceId: Long, mappingId: Long) { | ||
| val mapping = findMapping(workspaceId, mappingId) | ||
| customDataMappingRepository.deleteById(mapping.id) | ||
| } | ||
|
|
||
| @Transactional | ||
| override fun saveBulkMappings( | ||
| workspaceId: Long, | ||
| request: BulkCustomDataMappingRequest | ||
| ): List<CustomDataMappingResponse> { | ||
| customDataMappingRepository.deleteByWorkspaceIdAndConnectionIdAndTableName( | ||
| workspaceId, request.connectionId, request.tableName | ||
| ) | ||
| val mappings = request.columnMappings.map { entry -> | ||
| validateMaskingCombination(entry.action, entry.maskingStrategy, entry.fakeGeneratorType) | ||
| CustomDataMapping( | ||
| workspaceId = workspaceId, | ||
| connectionId = request.connectionId, | ||
| tableName = request.tableName, | ||
| columnName = entry.columnName, | ||
| action = entry.action, | ||
| maskingStrategy = if (entry.action == MappingAction.MASK) entry.maskingStrategy else null, | ||
| fakeGeneratorType = if (entry.action == MappingAction.MASK) entry.fakeGeneratorType else null | ||
| ) | ||
|
Comment on lines
+91
to
+94
|
||
| } | ||
| return mappings.map { customDataMappingRepository.save(it).toResponse() } | ||
| } | ||
|
|
||
| private fun findMapping(workspaceId: Long, mappingId: Long): CustomDataMapping { | ||
| val mapping = customDataMappingRepository.findById(mappingId) | ||
| .orElseThrow { NoSuchElementException("Custom data mapping not found: $mappingId") } | ||
| if (mapping.workspaceId != workspaceId) { | ||
| throw NoSuchElementException("Mapping $mappingId does not belong to workspace $workspaceId") | ||
| } | ||
| return mapping | ||
| } | ||
|
|
||
| private fun validateMaskingCombination( | ||
| action: MappingAction, | ||
| maskingStrategy: MaskingStrategy?, | ||
| fakeGeneratorType: GeneratorType? | ||
| ) { | ||
| if (action == MappingAction.MASK) { | ||
| if (maskingStrategy == null) { | ||
| throw IllegalArgumentException("maskingStrategy is required when action is MASK") | ||
| } | ||
| if (maskingStrategy == MaskingStrategy.FAKE && fakeGeneratorType == null) { | ||
| throw IllegalArgumentException("fakeGeneratorType is required when maskingStrategy is FAKE") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun CustomDataMapping.toResponse() = CustomDataMappingResponse( | ||
| id = id, | ||
| workspaceId = workspaceId, | ||
| connectionId = connectionId, | ||
| tableName = tableName, | ||
| columnName = columnName, | ||
| action = action, | ||
| maskingStrategy = maskingStrategy, | ||
| fakeGeneratorType = fakeGeneratorType, | ||
| createdAt = createdAt, | ||
| updatedAt = updatedAt | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,6 +5,7 @@ import com.opendatamask.domain.port.input.DataConnectionUseCase | |||||||
| import com.opendatamask.domain.port.output.EncryptionPort | ||||||||
| import com.opendatamask.domain.port.output.ConnectorFactoryPort | ||||||||
| import com.opendatamask.domain.port.output.DataConnectionPort | ||||||||
| import com.opendatamask.domain.port.input.dto.ConnectionSchemaResponse | ||||||||
| import com.opendatamask.domain.port.input.dto.ConnectionTestResult | ||||||||
| import com.opendatamask.domain.port.input.dto.DataConnectionRequest | ||||||||
| import com.opendatamask.domain.port.input.dto.DataConnectionResponse | ||||||||
|
|
@@ -108,6 +109,34 @@ class DataConnectionService( | |||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
|
||||||||
| @Transactional(readOnly = true) |
Copilot
AI
Apr 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
browseConnectionSchema() maps over all tables and calls connector.listColumns(tableName) for each one. For JDBC connectors, listColumns() opens a new DB connection per call, so schemas with many tables can cause a connection storm. Consider adding a connector API that returns tables+columns in one call / reusing a single connection, or limiting the number of tables returned.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| package com.opendatamask.domain.model | ||
|
|
||
| import jakarta.persistence.* | ||
| import java.time.LocalDateTime | ||
|
|
||
| enum class MappingAction { | ||
| MIGRATE_AS_IS, MASK | ||
| } | ||
|
|
||
| enum class MaskingStrategy { | ||
| FAKE, HASH, NULL | ||
| } | ||
|
|
||
| @Entity | ||
| @Table( | ||
| name = "custom_data_mappings", | ||
| uniqueConstraints = [ | ||
| UniqueConstraint( | ||
| name = "uk_cdm_workspace_connection_table_column", | ||
| columnNames = ["workspace_id", "connection_id", "table_name", "column_name"] | ||
| ) | ||
| ] | ||
| ) | ||
| class CustomDataMapping( | ||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| val id: Long = 0, | ||
|
|
||
| @Column(name = "workspace_id", nullable = false) | ||
| var workspaceId: Long, | ||
|
|
||
| @Column(name = "connection_id", nullable = false) | ||
| var connectionId: Long, | ||
|
|
||
| @Column(name = "table_name", nullable = false) | ||
| var tableName: String, | ||
|
|
||
| @Column(name = "column_name", nullable = false) | ||
| var columnName: String, | ||
|
|
||
| @Enumerated(EnumType.STRING) | ||
| @Column(nullable = false) | ||
| var action: MappingAction, | ||
|
|
||
| @Enumerated(EnumType.STRING) | ||
| @Column | ||
| var maskingStrategy: MaskingStrategy? = null, | ||
|
|
||
| @Enumerated(EnumType.STRING) | ||
| @Column | ||
| var fakeGeneratorType: GeneratorType? = null, | ||
|
|
||
| @Column(nullable = false) | ||
| var createdAt: LocalDateTime = LocalDateTime.now(), | ||
|
|
||
| @Column(nullable = false) | ||
| var updatedAt: LocalDateTime = LocalDateTime.now() | ||
| ) { | ||
| @PrePersist | ||
| fun prePersist() { | ||
| createdAt = LocalDateTime.now() | ||
| updatedAt = LocalDateTime.now() | ||
| } | ||
|
|
||
| @PreUpdate | ||
| fun preUpdate() { | ||
| updatedAt = LocalDateTime.now() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.opendatamask.domain.port.input | ||
|
|
||
| import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest | ||
| import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest | ||
| import com.opendatamask.domain.port.input.dto.CustomDataMappingResponse | ||
|
|
||
| interface CustomDataMappingUseCase { | ||
| fun createMapping(workspaceId: Long, request: CustomDataMappingRequest): CustomDataMappingResponse | ||
| fun getMapping(workspaceId: Long, mappingId: Long): CustomDataMappingResponse | ||
| fun listMappings(workspaceId: Long): List<CustomDataMappingResponse> | ||
| fun listMappingsForTable(workspaceId: Long, connectionId: Long, tableName: String): List<CustomDataMappingResponse> | ||
| fun updateMapping(workspaceId: Long, mappingId: Long, request: CustomDataMappingRequest): CustomDataMappingResponse | ||
| fun deleteMapping(workspaceId: Long, mappingId: Long) | ||
| fun saveBulkMappings(workspaceId: Long, request: BulkCustomDataMappingRequest): List<CustomDataMappingResponse> | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The service persists
action = MASKeven whenmaskingStrategy/fakeGeneratorTypeare missing in the request, which allows invalid mappings to be stored (e.g., MASK with null strategy). Add cross-field validation (DTO@AssertTrueor explicit checks) to enforce valid combinations before saving.