package jnpf.limsService.provider; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jnpf.exception.DataException; import lombok.Value; import org.springframework.jdbc.core.JdbcTemplate; import javax.sql.DataSource; import java.util.List; import java.util.LinkedHashMap; import java.util.Map; import java.util.HashSet; import java.util.Set; import java.util.Objects; import java.util.function.Supplier; import java.util.regex.Pattern; import java.util.stream.Collectors; /** Only registered business objects can contribute source candidates. */ public class LimsElnSourceMetadata { private static final Pattern RESTRICTED_COLUMN = Pattern.compile( "(^|_)(password|pwd|secret|token|credential|signature|sign|salt|private_key|qianming|flow|tenant|delete_mark)(_|$)", Pattern.CASE_INSENSITIVE); private final JdbcTemplate configuration; private final JdbcTemplate metadata; private final Supplier actors; private final ObjectMapper json = new ObjectMapper(); public LimsElnSourceMetadata(DataSource configurationDataSource, DataSource readOnlyDataSource, Supplier actors) { this.configuration = new JdbcTemplate(configurationDataSource); this.metadata = new JdbcTemplate(readOnlyDataSource); configuration.setQueryTimeout(5); metadata.setQueryTimeout(5); this.actors = actors; } public List objects() { requireManager(); return configuration.query("SELECT object_id, display_name, root_anchor FROM lims_eln_provider_object " + "WHERE f_tenant_id = '0' AND biz_enabled = true ORDER BY object_id", (row, index) -> new BusinessObject(row.getString(1), row.getString(2), row.getBoolean(3))); } public List fields(String objectId) { requireManager(); List objects = configuration.query("SELECT schema_name, table_name, tenant_column, delete_column FROM lims_eln_provider_object " + "WHERE f_tenant_id = '0' AND object_id = ? AND biz_enabled = true", (row, index) -> new String[]{row.getString(1), row.getString(2), row.getString(3), row.getString(4)}, objectId); if (objects.size() != 1) throw new DataException("Source object is not registered"); String[] object = objects.get(0); FormHints hints = formHints(object[1]); return metadata.query("SELECT c.oid, a.attnum, a.atttypid, a.attname, t.typname, " + "pg_catalog.col_description(c.oid, a.attnum) AS label " + "FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + "JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid " + "JOIN pg_catalog.pg_type t ON t.oid = a.atttypid " + "WHERE n.nspname = ? AND c.relname = ? AND c.relkind IN ('r', 'p') " + "AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum", (row, index) -> object[2].equals(row.getString("attname")) || Objects.equals(object[3], row.getString("attname")) || RESTRICTED_COLUMN.matcher(row.getString("attname")).find() || hints.restricted.contains(row.getString("attname")) ? null : new Candidate("c" + row.getLong("oid") + "_" + row.getInt("attnum") + "_" + row.getLong("atttypid"), hints.labels.getOrDefault(row.getString("attname"), row.getString("label") == null ? row.getString("attname") : row.getString("label")), dataType(row.getString("typname")), row.getString("typname"), row.getString("attname"), hints.nameOptions.get(row.getString("attname")), hints.nameLookups.get(row.getString("attname")), hints.conflictingNames.contains(row.getString("attname"))), object[0], object[1]).stream().filter(Objects::nonNull) .filter(field -> field.getDataType() != null).collect(Collectors.toList()); } public Map options(String objectId, String fieldId) { Candidate field = fields(objectId).stream().filter(candidate -> candidate.getId().equals(fieldId)).findFirst() .orElseThrow(() -> new DataException("Source candidate is no longer available")); if (field.getNameOptions() != null) return new LinkedHashMap<>(field.getNameOptions()); NameLookup lookup = field.getNameLookup(); if (lookup == null || !"base_dictionary_data".equals(lookup.getTable())) return java.util.Collections.emptyMap(); String table = LimsElnSourceExecution.identifier(lookup.getSchema()) + "." + LimsElnSourceExecution.identifier(lookup.getTable()); List rows = metadata.query("SELECT " + LimsElnSourceExecution.identifier(lookup.getKeyColumn()) + ", " + LimsElnSourceExecution.identifier(lookup.getLabelColumn()) + " FROM " + table + " WHERE f_tenant_id = '0' AND (f_delete_mark IS NULL OR f_delete_mark = 0) AND f_enabled_mark = 1" + " AND " + LimsElnSourceExecution.identifier(lookup.getQualifierColumn()) + " = ?" + " ORDER BY " + LimsElnSourceExecution.identifier(lookup.getKeyColumn()) + " LIMIT 1001", (row, index) -> new String[]{row.getString(1), row.getString(2)}, lookup.getQualifierValue()); if (rows.size() > 1000) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_LIMIT", "Too many source enum options"); Map options = new LinkedHashMap<>(); for (String[] row : rows) { if (row[0] == null || row[0].trim().isEmpty() || row[1] == null || row[1].trim().isEmpty()) continue; if (options.putIfAbsent(row[0], row[1]) != null) { throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_AMBIGUOUS", "Source enum codes are not unique"); } } return options; } public List enumObjects() { requireManager(); return configuration.query("SELECT o.object_id, o.display_name, o.root_anchor FROM lims_eln_provider_object o " + "JOIN lims_eln_provider_enum_object e ON e.f_tenant_id = o.f_tenant_id AND e.object_id = o.object_id " + "WHERE o.f_tenant_id = '0' AND o.biz_enabled = true ORDER BY o.object_id", (row, index) -> new BusinessObject(row.getString(1), row.getString(2), row.getBoolean(3))); } public Map enumOptions(EnumSelection selection) { requireManager(); if (selection == null) throw new DataException("Source enum selection is required"); ObjectMapping object = objectMapping(selection.getObjectId()); List policies = configuration.query("SELECT enabled_column, enabled_value FROM lims_eln_provider_enum_object " + "WHERE f_tenant_id = '0' AND object_id = ?", (row, index) -> new String[]{row.getString(1), row.getString(2)}, selection.getObjectId()); if (policies.size() != 1) throw new DataException("Source object is not registered for enum options"); List candidates = fields(selection.getObjectId()); Candidate key = enumField(candidates, selection.getValueFieldId()); Candidate label = enumField(candidates, selection.getLabelFieldId()); String table = LimsElnSourceExecution.identifier(object.getSchema()) + "." + LimsElnSourceExecution.identifier(object.getTable()); StringBuilder sql = new StringBuilder("SELECT ").append(LimsElnSourceExecution.identifier(key.getColumn())) .append(", ").append(LimsElnSourceExecution.identifier(label.getColumn())).append(" FROM ").append(table) .append(" WHERE ").append(LimsElnSourceExecution.identifier(object.getTenantColumn())).append(" = '0'"); if (object.getDeleteColumn() != null) { String deleted = LimsElnSourceExecution.identifier(object.getDeleteColumn()); sql.append(" AND (").append(deleted).append(" IS NULL OR ").append(deleted).append(" = 0)"); } String[] policy = policies.get(0); if (policy[0] != null) { if (candidates.stream().noneMatch(field -> field.getColumn().equals(policy[0]))) { throw new DataException("Source enum enabled field is unavailable"); } sql.append(" AND ").append(LimsElnSourceExecution.identifier(policy[0])).append("::text = ?"); } sql.append(" ORDER BY ").append(LimsElnSourceExecution.identifier(key.getColumn())).append(" LIMIT 1001"); return metadata.execute((org.springframework.jdbc.core.ConnectionCallback>) connection -> { connection.setReadOnly(true); connection.setAutoCommit(false); try { LimsElnSourceExecution.enforceReadOnlyTransaction(connection); try (java.sql.PreparedStatement query = connection.prepareStatement(sql.toString())) { query.setQueryTimeout(5); if (policy[0] != null) query.setString(1, policy[1]); Map options = new LinkedHashMap<>(); try (java.sql.ResultSet rows = query.executeQuery()) { int count = 0; while (rows.next()) { if (++count > 1000) throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_LIMIT", "Too many source enum options"); String value = rows.getString(1); String name = rows.getString(2); if (value == null || value.trim().isEmpty() || name == null || name.trim().isEmpty()) continue; if (options.putIfAbsent(value, name) != null) { throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_AMBIGUOUS", "Source enum codes are not unique"); } } } return options; } } finally { connection.rollback(); } }); } private static Candidate enumField(List candidates, String id) { return candidates.stream().filter(field -> field.getId().equals(id) && "TEXT".equals(field.getDataType())) .findFirst().orElseThrow(() -> new DataException("Source enum code and name must be available text fields")); } NameLookup enumNameLookup(EnumSelection selection) { requireManager(); if (selection == null || enumObjects().stream().noneMatch(object -> object.getId().equals(selection.getObjectId()))) { throw new DataException("Source object is not registered for enum options"); } ObjectMapping object = objectMapping(selection.getObjectId()); List candidates = fields(selection.getObjectId()); return new NameLookup(object.getSchema(), object.getTable(), enumField(candidates, selection.getValueFieldId()).getColumn(), enumField(candidates, selection.getLabelFieldId()).getColumn(), null, null, object.getTenantColumn(), object.getDeleteColumn()); } Map> freezeColumnIdentities(LimsElnReadonlyResolver.FrozenDefinition definition) { requireManager(); return metadata.execute((org.springframework.jdbc.core.ConnectionCallback>>) connection -> LimsElnSourceExecution.columnIdentities(connection, definition, definition.getFields().keySet())); } private FormHints formHints(String table) { FormHints hints = new FormHints(); List forms = configuration.query("SELECT f_tables_data, f_form_data, f_db_link_id FROM base_visual_release " + "WHERE f_tenant_id = '0' AND f_state = 1 AND (f_delete_mark IS NULL OR f_delete_mark = 0) " + "AND strpos(f_tables_data, ?) > 0", (row, index) -> new String[]{row.getString(1), row.getString(2), row.getString(3)}, table); for (String[] form : forms) { try { if (form[0] == null || form[0].trim().isEmpty()) continue; JsonNode tables = json.readTree(form[0]); if (tables == null || !tables.isArray()) throw new DataException("Published source form metadata is invalid"); String main = null; boolean matching = false; for (JsonNode entry : tables) { String name = entry.path("table").asText(); if ("1".equals(entry.path("typeId").asText())) main = name; if (table.equals(name)) matching = true; } if (matching) { JsonNode definition = json.readTree(form[1]); if (definition == null || !definition.isObject() || !definition.path("fields").isArray()) { throw new DataException("Published source form metadata is invalid"); } // Unknown connections contribute restrictions, never names or executable lookup rules. boolean trustedNames = form[2] == null || form[2].isEmpty() || "0".equals(form[2]); collectHints(definition.path("fields"), table, main, hints, 0, trustedNames); } } catch (JsonProcessingException | IllegalArgumentException e) { throw new DataException("Published source form metadata is invalid"); } } hints.nameOptions.keySet().stream().filter(hints.nameLookups::containsKey).forEach(hints.conflictingNames::add); hints.dynamicNames.stream().filter(column -> hints.nameOptions.containsKey(column) || hints.nameLookups.containsKey(column)).forEach(hints.conflictingNames::add); for (String column : hints.conflictingNames) { hints.nameOptions.remove(column); hints.nameLookups.remove(column); } return hints; } private void collectHints(JsonNode fields, String table, String inheritedTable, FormHints hints, int depth, boolean trustedNames) { if (depth > 20) throw new DataException("Source form nesting exceeds limits"); if (!fields.isArray()) throw new DataException("Published source form metadata is invalid"); for (JsonNode field : fields) { JsonNode config = field.path("__config__"); if (!field.isObject() || !config.isObject()) { throw new DataException("Published source form metadata is invalid"); } String fieldTable = config.path("tableName").asText(""); if (fieldTable.isEmpty()) fieldTable = inheritedTable; String column = field.path("__vModel__").asText(); String label = config.path("label").asText(); if (table.equals(fieldTable) && !column.isEmpty()) { String control = config.path("jnpfKey").asText(); if (field.path("multiple").asBoolean() || field.path("showPassword").asBoolean() || "password".equals(field.path("type").asText()) || "signature".equals(control) || "sign".equals(control) || "checkbox".equals(control) || "uploadImg".equals(control) || "uploadFile".equals(control) || config.path("isVirtualField").asBoolean()) { hints.restricted.add(column); } if (trustedNames && !label.trim().isEmpty()) { hints.labels.merge(column, label, (previous, next) -> previous.compareTo(next) <= 0 ? previous : next); } if (trustedNames && ("select".equals(control) || "radio".equals(control)) && "dynamic".equals(config.path("dataType").asText())) { hints.dynamicNames.add(column); } if (trustedNames && ("select".equals(control) || "radio".equals(control)) && "static".equals(config.path("dataType").asText())) { Map names = staticNames(field); Map previous = hints.nameOptions.putIfAbsent(column, names); if (previous != null) { names.forEach((code, name) -> { String existing = previous.putIfAbsent(code, name); if (existing != null && !existing.equals(name)) hints.conflictingNames.add(column); }); if (previous.size() > 1000) { throw new LimsElnReadonlyResolver.SourceFailure("SOURCE_LIMIT", "Too many source enum options"); } } } if (trustedNames && ("select".equals(control) || "radio".equals(control)) && "dictionary".equals(config.path("dataType").asText())) { String key = field.path("props").path("value").asText(); String category = config.path("dictionaryType").asText(""); if (!("id".equals(key) || "enCode".equals(key)) || category.trim().isEmpty() || !"fullName".equals(field.path("props").path("label").asText())) { throw new DataException("Invalid source dictionary metadata"); } NameLookup lookup = platformNameLookup("base_dictionary_data", "id".equals(key) ? "f_id" : "f_en_code", "f_full_name", "f_dictionary_type_id", category); NameLookup previous = hints.nameLookups.putIfAbsent(column, lookup); if (previous != null && !previous.equals(lookup)) { hints.conflictingNames.add(column); } } if (trustedNames && "userSelect".equals(control) && !hints.restricted.contains(column)) { NameLookup lookup = platformNameLookup("base_user", "f_id", "f_real_name", null, null); NameLookup previous = hints.nameLookups.putIfAbsent(column, lookup); if (previous != null && !previous.equals(lookup)) { hints.conflictingNames.add(column); } } } JsonNode children = config.path("children"); if (!children.isMissingNode() && !children.isNull()) { collectHints(children, table, fieldTable, hints, depth + 1, trustedNames); } } } private static class FormHints { private final Map labels = new LinkedHashMap<>(); private final Set restricted = new HashSet<>(); private final Map> nameOptions = new LinkedHashMap<>(); private final Map nameLookups = new LinkedHashMap<>(); private final Set conflictingNames = new HashSet<>(); private final Set dynamicNames = new HashSet<>(); } private NameLookup platformNameLookup(String table, String key, String label, String qualifier, String value) { List schemas = configuration.query("SELECT n.nspname FROM pg_catalog.pg_class c " + "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + "WHERE c.oid = pg_catalog.to_regclass(?) AND c.relkind IN ('r', 'p')", (row, index) -> row.getString(1), table); if (schemas.size() != 1) throw new DataException("Source display-name object is unavailable"); return new NameLookup(schemas.get(0), table, key, label, qualifier, value, "f_tenant_id", "f_delete_mark"); } private static Map staticNames(JsonNode field) { JsonNode options = field.path("options"); String key = field.path("props").path("value").asText(""); String label = field.path("props").path("label").asText(""); if (!options.isArray() || options.size() > 1000 || key.isEmpty() || label.isEmpty()) { throw new DataException("Invalid source display-name metadata"); } Map names = new LinkedHashMap<>(); for (JsonNode option : options) { if (!option.path(key).isTextual() || !option.path(label).isTextual() || option.path(key).asText().trim().isEmpty() || option.path(label).asText().trim().isEmpty() || names.putIfAbsent(option.path(key).asText(), option.path(label).asText()) != null) { throw new DataException("Invalid or ambiguous source display-name metadata"); } } return names; } ObjectMapping objectMapping(String objectId) { requireManager(); List result = configuration.query("SELECT schema_name, table_name, id_column, tenant_column, root_anchor, delete_column " + "FROM lims_eln_provider_object WHERE f_tenant_id = '0' AND object_id = ? AND biz_enabled = true", (row, index) -> new ObjectMapping(row.getString(1), row.getString(2), row.getString(3), row.getString(4), row.getBoolean(5), row.getString(6)), objectId); if (result.size() != 1) throw new DataException("Source object is not registered"); return result.get(0); } private void 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"); } } private static String dataType(String postgresType) { switch (postgresType) { case "text": case "varchar": case "bpchar": return "TEXT"; case "numeric": case "int2": case "int4": case "int8": return "DECIMAL"; case "date": return "DATE"; case "timestamp": case "timestamptz": return "DATETIME"; case "bool": return "BOOLEAN"; default: return null; } } @Value @com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class BusinessObject { String id; String displayName; boolean rootAnchor; } @Value @com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class Candidate { String id; String displayName; String dataType; @JsonIgnore String postgresType; @JsonIgnore String column; Map nameOptions; @JsonIgnore NameLookup nameLookup; boolean nameRuleConflict; public boolean isDisplayNameAvailable() { return "TEXT".equals(dataType) && (nameLookup != null || nameOptions != null && !nameOptions.isEmpty()); } } @Value static class NameLookup { String schema; String table; String keyColumn; String labelColumn; String qualifierColumn; String qualifierValue; String tenantColumn; String deleteColumn; } @lombok.Data @com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.LowerCamelCaseStrategy.class) public static class EnumSelection { private String objectId; private String valueFieldId; private String labelFieldId; } @Value static class ObjectMapping { String schema; String table; String idColumn; String tenantColumn; boolean rootAnchor; String deleteColumn; } }