package jnpf.limsService.provider; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jnpf.audit.sdk.annotation.AuditLog; import jnpf.exception.DataException; import jnpf.limsService.LimsTestWorkItemSnapshotService; import jnpf.limsService.provider.LimsElnReadonlyResolver.Field; import jnpf.limsService.provider.LimsElnReadonlyResolver.FrozenDefinition; import jnpf.limsService.provider.LimsElnReadonlyResolver.Snapshot; import jnpf.limsService.provider.LimsElnReadonlyResolver.Join; import jnpf.limsService.provider.LimsElnReadonlyResolver.Condition; import lombok.Data; import lombok.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.support.TransactionTemplate; import javax.sql.DataSource; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.Supplier; /** Dataset publication and property preview share the same authorized read implementation. */ public class LimsElnDatasetCatalog implements LimsElnSourceCatalog.SourceReader { private static final RowMapper VERSION = (row, index) -> new Version(row.getString("f_id"), row.getString("dataset_code"), row.getInt("revision"), row.getString("display_name"), row.getString("status"), row.getString("definition_json"), row.getString("frozen_definition_json"), row.getInt("edit_version"), row.getString("validation_sample_id"), row.getString("published_by"), row.getString("published_at")); private final JdbcTemplate jdbc; private final TransactionTemplate transactions; private final LimsElnSourceMetadata metadata; private final LimsElnSourceExecution execution; private final Supplier actors; private final ObjectMapper json = new ObjectMapper(); public LimsElnDatasetCatalog(DataSource configurationDataSource, DataSource readOnlyDataSource, LimsElnSourceMetadata metadata, LimsTestWorkItemSnapshotService taskAccess, Supplier actors) { jdbc = new JdbcTemplate(configurationDataSource); jdbc.setQueryTimeout(5); transactions = new TransactionTemplate(new DataSourceTransactionManager(configurationDataSource)); this.metadata = metadata; execution = new LimsElnSourceExecution(readOnlyDataSource, taskAccess); this.actors = actors; } @AuditLog(action = "ELN_DATASET_DRAFT_CREATE", bizType = "ELN_SOURCE_DATASET", bizCodeExpr = "#result?.code", targetTable = "lims_eln_provider_dataset_version", targetIdExpr = "#result?.id") public Version createDraft(Draft draft) { requireManager(); validate(draft); String id = UUID.randomUUID().toString(); String definitionJson = encode(draft); try { transactions.execute(status -> { jdbc.update("INSERT INTO lims_eln_provider_dataset (dataset_code) VALUES (?)", draft.getCode()); jdbc.update("INSERT INTO lims_eln_provider_dataset_version " + "(f_id, dataset_code, revision, display_name, status, definition_json) VALUES (?, ?, 1, ?, 'DRAFT', ?)", id, draft.getCode(), draft.getDisplayName(), definitionJson); return null; }); } catch (DuplicateKeyException e) { throw new DataException("Dataset identity already exists"); } return detail(id); } @AuditLog(action = "ELN_SOURCE_PATH_CREATE", failureAction = "ELN_SOURCE_PATH_CREATE_FAILED", bizType = "ELN_SOURCE_DATASET", bizCodeExpr = "#result?.code", bizCodeRequired = false, targetTable = "lims_eln_provider_dataset_version", targetIdExpr = "#result?.id") public Version createPublished(Draft draft, String sampleId) { return transactions.execute(status -> { Version created = createDraft(draft); return publish(created.getId(), created.getEditVersion(), sampleId); }); } public Version detail(String id) { requireManager(); List versions = jdbc.query("SELECT * FROM lims_eln_provider_dataset_version " + "WHERE f_tenant_id = '0' AND f_id = ?", VERSION, id); if (versions.size() != 1) throw new DataException("Dataset version not found"); return versions.get(0); } public List versions() { requireManager(); return jdbc.query("SELECT * FROM lims_eln_provider_dataset_version WHERE f_tenant_id = '0' ORDER BY dataset_code, revision", VERSION); } Version version(String code, int revision) { requireManager(); List versions = jdbc.query("SELECT * FROM lims_eln_provider_dataset_version " + "WHERE f_tenant_id = '0' AND dataset_code = ? AND revision = ?", VERSION, code, revision); if (versions.size() != 1) throw new DataException("Source path version not found"); return versions.get(0); } Draft definition(Version version) { requireManager(); return decode(version.getDefinitionJson(), Draft.class); } @AuditLog(action = "ELN_DATASET_VERSION_CREATE", bizType = "ELN_SOURCE_DATASET", bizCodeExpr = "#result?.code", targetTable = "lims_eln_provider_dataset_version", targetIdExpr = "#result?.id") public Version newVersion(String publishedId, Draft changes) { Version previous = detail(publishedId); validate(changes); if (!"PUBLISHED".equals(previous.getStatus())) throw new DataException("Published dataset required"); if (!previous.getCode().equals(changes.getCode())) throw new DataException("Dataset code is immutable"); String id = UUID.randomUUID().toString(); Integer revision = jdbc.queryForObject("SELECT MAX(revision) + 1 FROM lims_eln_provider_dataset_version " + "WHERE f_tenant_id = '0' AND dataset_code = ?", Integer.class, previous.getCode()); try { jdbc.update("INSERT INTO lims_eln_provider_dataset_version " + "(f_id, dataset_code, revision, display_name, status, definition_json) VALUES (?, ?, ?, ?, 'DRAFT', ?)", id, previous.getCode(), revision, changes.getDisplayName(), encode(changes)); } catch (DuplicateKeyException e) { throw new DataException("Dataset already has a draft or changed concurrently"); } return detail(id); } @AuditLog(action = "ELN_DATASET_DRAFT_UPDATE", bizType = "ELN_SOURCE_DATASET", bizCodeExpr = "#result?.code", targetTable = "lims_eln_provider_dataset_version", targetIdExpr = "#id") public Version updateDraft(String id, int expectedEditVersion, Draft changes) { Version previous = detail(id); validate(changes); if (!"DRAFT".equals(previous.getStatus())) throw new DataException("Published dataset is immutable"); if (previous.getEditVersion() != expectedEditVersion) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_CONFLICT", "Dataset version has changed"); if (!previous.getCode().equals(changes.getCode())) throw new DataException("Dataset code is immutable"); if (jdbc.update("UPDATE lims_eln_provider_dataset_version SET display_name = ?, definition_json = ?, " + "edit_version = edit_version + 1 WHERE f_tenant_id = '0' AND f_id = ? AND status = 'DRAFT' AND edit_version = ?", changes.getDisplayName(), encode(changes), id, expectedEditVersion) != 1) { throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_CONFLICT", "Dataset version has changed"); } return detail(id); } @AuditLog(action = "ELN_DATASET_PUBLISH", failureAction = "ELN_DATASET_PUBLISH_FAILED", bizType = "ELN_SOURCE_DATASET", bizCodeExpr = "#result?.code", bizCodeRequired = false, targetTable = "lims_eln_provider_dataset_version", targetIdExpr = "#id") public Version publish(String id, int expectedEditVersion, String sampleId) { LimsElnSourceCatalog.Actor actor = requireManager(); Version draft = detail(id); if (!"DRAFT".equals(draft.getStatus())) throw new DataException("Published dataset is immutable"); if (draft.getEditVersion() != expectedEditVersion) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_CONFLICT", "Dataset version has changed"); FrozenDefinition frozen = compile(decode(draft.getDefinitionJson(), Draft.class), draft.getRevision()); List preview = preview(frozen, actor, sampleId, new ArrayList<>(frozen.getFields().keySet())); if (preview.stream().anyMatch(value -> !("SUCCESS".equals(value.getOutcomeCode()) || "MISSING".equals(value.getOutcomeCode()))) || preview.stream().noneMatch(value -> "SUCCESS".equals(value.getOutcomeCode()))) { throw new DataException("Dataset requires an authorized, applicable preview with a value"); } frozen.setResultField(null); if (jdbc.update("UPDATE lims_eln_provider_dataset_version SET status = 'PUBLISHED', frozen_definition_json = ?, " + "validation_sample_id = ?, published_by = ?, published_at = ?, edit_version = edit_version + 1 " + "WHERE f_tenant_id = '0' AND f_id = ? AND status = 'DRAFT' AND edit_version = ?", encode(frozen), sampleId, actor.getUserId(), Instant.now().toString(), id, expectedEditVersion) != 1) { throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_CONFLICT", "Dataset version has changed"); } return detail(id); } public Validation validateStructure(String id) { Version draft = detail(id); if (!"DRAFT".equals(draft.getStatus())) throw new DataException("Draft dataset required"); compile(decode(draft.getDefinitionJson(), Draft.class), draft.getRevision()); return new Validation(true, false); } public List preview(String id, String sampleId) { LimsElnSourceCatalog.Actor actor = requireManager(); Version version = detail(id); FrozenDefinition frozen = "PUBLISHED".equals(version.getStatus()) ? decode(version.getFrozenDefinitionJson(), FrozenDefinition.class) : compile(decode(version.getDefinitionJson(), Draft.class), version.getRevision()); return preview(frozen, actor, sampleId, new ArrayList<>(frozen.getFields().keySet())); } @Override public LimsElnSourceCatalog.Preview read(LimsElnSourceCatalog.Definition definition, LimsElnSourceCatalog.Actor actor, String sampleId) { if (!requireManager().equals(actor)) throw new DataException("Invalid source preview caller"); if (definition == null || definition.getDatasetCode() == null || definition.getDatasetRevision() == null) { throw new DataException("A fixed dataset version is required"); } List versions = jdbc.query("SELECT * FROM lims_eln_provider_dataset_version " + "WHERE f_tenant_id = '0' AND dataset_code = ? AND revision = ? AND status = 'PUBLISHED'", VERSION, definition.getDatasetCode(), definition.getDatasetRevision()); if (versions.size() != 1) throw new DataException("Published dataset version not found"); FrozenDefinition frozen = decode(versions.get(0).getFrozenDefinitionJson(), FrozenDefinition.class); List values = preview(frozen, actor, sampleId, Collections.singletonList(definition.getFieldId())); if (values.size() != 1 || !"SUCCESS".equals(values.get(0).getOutcomeCode())) { throw new DataException("Source requires an authorized, applicable preview with a value"); } frozen.setResultField(definition.getFieldId()); return new LimsElnSourceCatalog.Preview(Collections.singletonList(values.get(0).getValue()), encode(frozen)); } private List preview(FrozenDefinition frozen, LimsElnSourceCatalog.Actor actor, String sampleId, List keys) { if (sampleId == null || sampleId.trim().isEmpty()) throw new DataException("An authorized preview sample is required"); List previews = new ArrayList<>(); for (String key : keys) { Field field = frozen.getFields().get(key); if (field == null) throw new DataException("Dataset output field not found"); frozen.setResultField(key); previews.add(new LimsElnSourceCatalog.Version("preview-" + key, "0", key, 1, "PREVIEW", key, field.getDataType(), "{}", encode(frozen), null, null, null, 0)); } return execution.execute(Collections.singletonList(sampleId), previews, actor); } private FrozenDefinition compile(Draft draft, int revision) { validate(draft); LimsElnSourceMetadata.ObjectMapping root = metadata.objectMapping(draft.getRootObjectId()); if (!root.isRootAnchor()) throw new DataException("Dataset root must be the registered test work item"); FrozenDefinition frozen = new FrozenDefinition(); frozen.setDatasetCode(draft.getCode()); frozen.setDatasetRevision(revision); frozen.setSchema(root.getSchema()); frozen.setTable(root.getTable()); frozen.setIdColumn(root.getIdColumn()); frozen.setTenantColumn(root.getTenantColumn()); frozen.setDeleteColumn(root.getDeleteColumn()); List> steps = new ArrayList<>(); steps.add(metadata.fields(draft.getRootObjectId())); for (Relation relation : draft.getRelations()) { if (relation == null || relation.getFromStep() < 0 || relation.getFromStep() >= steps.size()) { throw new DataException("Invalid dataset relation"); } LimsElnSourceMetadata.ObjectMapping target = metadata.objectMapping(relation.getObjectId()); List targetFields = metadata.fields(relation.getObjectId()); LimsElnSourceMetadata.Candidate from = candidate(steps.get(relation.getFromStep()), relation.getFromFieldId()); LimsElnSourceMetadata.Candidate to = candidate(targetFields, relation.getTargetFieldId()); if (!from.getDataType().equals(to.getDataType())) throw new DataException("Incompatible dataset relation fields"); Join join = new Join(); join.setSchema(target.getSchema()); join.setTable(target.getTable()); join.setTenantColumn(target.getTenantColumn()); join.setDeleteColumn(target.getDeleteColumn()); join.setFromStep(relation.getFromStep()); join.setFromColumn(from.getColumn()); join.setTargetColumn(to.getColumn()); frozen.getJoins().add(join); steps.add(targetFields); } Map fields = new LinkedHashMap<>(); for (Map.Entry entry : draft.getFields().entrySet()) { Selection selection = entry.getValue(); if (selection.getStep() < 0 || selection.getStep() >= steps.size()) throw new DataException("Invalid dataset field step"); LimsElnSourceMetadata.Candidate candidate = candidate(steps.get(selection.getStep()), selection.getFieldId()); Field field = new Field(); field.setColumn(candidate.getColumn()); field.setDataType(candidate.getDataType()); field.setStep(selection.getStep()); LimsElnSourceMetadata.NameLookup lookup = candidate.getNameLookup(); if (selection.getEnumSource() != null) { if (!"TEXT".equals(candidate.getDataType())) throw new DataException("Source enum value must be text"); lookup = metadata.enumNameLookup(selection.getEnumSource()); } if ("DISPLAY_NAME".equals(selection.getValueMode())) { if (!candidate.isDisplayNameAvailable() && lookup == null) { throw new DataException("Source display name is unavailable"); } field.setValueMode("DISPLAY_NAME"); if (lookup == null) { field.setNameOptions(candidate.getNameOptions()); } else { Join join = new Join(); join.setSchema(lookup.getSchema()); join.setTable(lookup.getTable()); join.setTenantColumn(lookup.getTenantColumn()); join.setDeleteColumn(lookup.getDeleteColumn()); join.setFromStep(selection.getStep()); join.setFromColumn(candidate.getColumn()); join.setTargetColumn(lookup.getKeyColumn()); join.setQualifierColumn(lookup.getQualifierColumn()); join.setQualifierValue(lookup.getQualifierValue()); int index = frozen.getJoins().indexOf(join); if (index < 0) { if (frozen.getJoins().size() >= 4) throw new DataException("Source relation count exceeds limits"); index = frozen.getJoins().size(); frozen.getJoins().add(join); } field.setColumn(lookup.getLabelColumn()); field.setStep(index + 1); } } else if (!"RAW".equals(selection.getValueMode())) { throw new DataException("Invalid source value mode"); } if ("DATE_ONLY".equals(selection.getFormat()) && "timestamp".equals(candidate.getPostgresType())) { field.setDataType("DATE"); } else if (!("RAW".equals(selection.getFormat()) || "PLAIN_DECIMAL".equals(selection.getFormat()) && "DECIMAL".equals(field.getDataType()) || "ISO_DATE".equals(selection.getFormat()) && "DATE".equals(field.getDataType()))) { throw new DataException("Invalid source format for field type"); } field.setFormat(selection.getFormat()); fields.put(entry.getKey(), field); } frozen.setFields(fields); frozen.setScopeMode(draft.getScopeMode()); frozen.setConditions(draft.getConditions()); LimsElnSourceExecution.validateScope(frozen); frozen.setColumnIdentities(metadata.freezeColumnIdentities(frozen)); return frozen; } private static LimsElnSourceMetadata.Candidate candidate(List fields, String id) { return fields.stream().filter(field -> field.getId().equals(id)).findFirst() .orElseThrow(() -> new DataException("Source candidate is unavailable")); } private LimsElnSourceCatalog.Actor requireManager() { LimsElnSourceCatalog.Actor actor = actors.get(); if (actor == null || !"0".equals(actor.getTenantId()) || actor.getUserId() == null || actor.getUserId().trim().isEmpty() || !actor.isFieldManager()) { throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_FORBIDDEN", "Field management permission required"); } return actor; } private static void validate(Draft draft) { if (draft == null || draft.getCode() == null || !draft.getCode().matches("[a-zA-Z][a-zA-Z0-9_.-]{0,79}") || draft.getDisplayName() == null || draft.getDisplayName().trim().isEmpty() || draft.getDisplayName().length() > 200 || draft.getRootObjectId() == null || draft.getFields() == null || draft.getFields().isEmpty() || draft.getRelations() == null || draft.getRelations().size() > 4 || draft.getFields().size() > 50 || draft.getFields().entrySet().stream().anyMatch(entry -> entry.getKey() == null || !entry.getKey().matches("[a-zA-Z][a-zA-Z0-9_.-]{0,79}") || entry.getValue() == null || entry.getValue().getFieldId() == null)) { throw new DataException("Invalid dataset definition"); } } private String encode(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new DataException("Invalid dataset definition"); } } private T decode(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new DataException("Invalid dataset definition"); } } @Data @com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Draft { private String code; private String displayName; private String rootObjectId; private Map fields = new LinkedHashMap<>(); private List relations = new ArrayList<>(); private String scopeMode = "ALL"; private List conditions = new ArrayList<>(); } @Data @com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Selection { private String fieldId; private int step; private String valueMode = "RAW"; private String format = "RAW"; private LimsElnSourceMetadata.EnumSelection enumSource; } @Data @com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Relation { private String objectId; private int fromStep; private String fromFieldId; private String targetFieldId; } @Value @com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Validation { boolean structureValid; boolean previewValidated; } @Value @com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Version { String id; String code; int revision; String displayName; String status; String definitionJson; @JsonIgnore String frozenDefinitionJson; int editVersion; String validationSampleId; String publishedBy; String publishedAt; } }