diff --git a/robot-command/src/main/java/org/obolibrary/robot/CommandLineHelper.java b/robot-command/src/main/java/org/obolibrary/robot/CommandLineHelper.java index b8f993bcb..ea7fccfc8 100644 --- a/robot-command/src/main/java/org/obolibrary/robot/CommandLineHelper.java +++ b/robot-command/src/main/java/org/obolibrary/robot/CommandLineHelper.java @@ -528,6 +528,44 @@ public static List getInputOntologies( return inputOntologies; } + /** + * Given an IOHelper, a state object, and a command line, return the input ontology list for + * commands whose output state ontology is the existing state ontology or the first provided input + * ontology. If the state already contains an ontology, it is prepended and state metadata is + * preserved. Otherwise, metadata for the first local input is recorded for downstream commands + * that may need to reload imports. + * + * @param ioHelper the IOHelper to load the ontology with + * @param state the command state to read and update + * @param line the command line to use + * @return the list of input ontologies, beginning with the state ontology when present + * @throws IllegalArgumentException if required options are missing + * @throws IOException if an ontology cannot be loaded + */ + public static List getPrimaryInputOntologies( + IOHelper ioHelper, CommandState state, CommandLine line) + throws IllegalArgumentException, IOException { + List inputOntologies = new ArrayList<>(); + boolean hasStateOntology = state.getOntology() != null; + if (hasStateOntology) { + inputOntologies.add(state.getOntology()); + inputOntologies.addAll(getInputOntologies(ioHelper, line, true)); + } else { + InputOntologies loadedOntologies = getInputOntologiesWithMetadata(ioHelper, line); + inputOntologies.addAll(loadedOntologies.getOntologies()); + if (loadedOntologies.getPrimaryOntologyPath() != null) { + state.setOntologyPath(loadedOntologies.getPrimaryOntologyPath()); + } + if (loadedOntologies.getCatalogPath() != null) { + state.setCatalogPath(loadedOntologies.getCatalogPath()); + } + if (inputOntologies.isEmpty()) { + throw new IllegalArgumentException(missingInputsError); + } + } + return inputOntologies; + } + /** * Given an IOHelper, a state object, and a command line, update the state with the ontology. * @@ -1080,23 +1118,73 @@ private static File[] getFilesByPattern(String pattern) throws IllegalArgumentEx */ public static List getInputOntologies(IOHelper ioHelper, CommandLine line) throws IllegalArgumentException, IOException { - List inputOntologies = new ArrayList<>(); + return getInputOntologiesWithMetadata(ioHelper, line).getOntologies(); + } + + /** + * Given an IOHelper and a command line, check input options and return loaded input ontologies + * plus metadata for the first ontology. + * + * @param ioHelper the IOHelper to load the ontology with + * @param line the command line to use + * @return loaded ontologies and primary input metadata + * @throws IllegalArgumentException on bad pattern + * @throws IOException if the ontology cannot be loaded + */ + private static InputOntologies getInputOntologiesWithMetadata(IOHelper ioHelper, CommandLine line) + throws IllegalArgumentException, IOException { + String catalogPath = getOptionalValue(line, "catalog"); + return getInputOntologiesWithMetadata(ioHelper, line, catalogPath); + } + + /** + * Given an IOHelper, a command line, and a catalog path, check input options and return loaded + * input ontologies plus metadata for the first ontology. + * + * @param ioHelper the IOHelper to load the ontology with + * @param line the command line to use + * @param catalogPath the catalog file to use, or null to guess local catalogs + * @return loaded ontologies and primary input metadata + * @throws IllegalArgumentException on bad pattern + * @throws IOException if the ontology cannot be loaded + */ + private static InputOntologies getInputOntologiesWithMetadata( + IOHelper ioHelper, CommandLine line, String catalogPath) + throws IllegalArgumentException, IOException { + InputOntologies inputOntologies = new InputOntologies(catalogPath); String inputFormat = getOptionalValue(line, "input-format"); // Check for input files List inputOntologyPaths = getOptionalValues(line, "input"); for (String inputOntologyPath : inputOntologyPaths) { - inputOntologies.add(ioHelper.loadOntology(inputOntologyPath, true, inputFormat)); + OWLOntology ontology; + if (catalogPath != null) { + ontology = ioHelper.loadOntology(inputOntologyPath, catalogPath, inputFormat); + } else { + ontology = ioHelper.loadOntology(inputOntologyPath, true, inputFormat); + } + inputOntologies.add(ontology, inputOntologyPath); } // Check for input IRIs List inputOntologyIRIs = getOptionalValues(line, "input-iri"); for (String inputOntologyIRI : inputOntologyIRIs) { - inputOntologies.add(ioHelper.loadOntology(IRI.create(inputOntologyIRI), null, inputFormat)); + inputOntologies.add( + ioHelper.loadOntology(IRI.create(inputOntologyIRI), catalogPath, inputFormat), null); } // Check for input patterns (wildcard) String pattern = getOptionalValue(line, "inputs"); if (pattern != null) { + File catalogFile = null; + if (catalogPath != null) { + catalogFile = new File(catalogPath); + } for (File inputOntologyFile : getFilesByPattern(pattern)) { - inputOntologies.add(ioHelper.loadOntology(inputOntologyFile, true, inputFormat)); + OWLOntology ontology; + if (catalogFile != null) { + ontology = ioHelper.loadOntology(inputOntologyFile, catalogFile, inputFormat); + } else { + ontology = ioHelper.loadOntology(inputOntologyFile, true, inputFormat); + } + inputOntologies.add(ontology, inputOntologyFile.getPath()); } } return inputOntologies; @@ -1114,28 +1202,68 @@ public static List getInputOntologies(IOHelper ioHelper, CommandLin */ public static List getInputOntologies( IOHelper ioHelper, CommandLine line, String catalogPath) throws IOException { - List inputOntologies = new ArrayList<>(); - String inputFormat = getOptionalValue(line, "input-format"); - // Check for input files - List inputOntologyPaths = getOptionalValues(line, "input"); - for (String inputOntologyPath : inputOntologyPaths) { - inputOntologies.add(ioHelper.loadOntology(inputOntologyPath, catalogPath, inputFormat)); - } - // Check for input IRIs - List inputOntologyIRIs = getOptionalValues(line, "input-iri"); - for (String inputOntologyIRI : inputOntologyIRIs) { - inputOntologies.add( - ioHelper.loadOntology(IRI.create(inputOntologyIRI), catalogPath, inputFormat)); + return getInputOntologiesWithMetadata(ioHelper, line, catalogPath).getOntologies(); + } + + /** Loaded input ontologies with metadata for the first loaded ontology. */ + private static class InputOntologies { + /** Loaded ontologies. */ + private List ontologies = new ArrayList<>(); + + /** Path to the explicit catalog, if one was provided. */ + private String catalogPath; + + /** Path to the first local input ontology, if the first input was local. */ + private String primaryOntologyPath; + + /** + * Create a result container. + * + * @param catalogPath path to the explicit catalog, or null + */ + InputOntologies(String catalogPath) { + this.catalogPath = catalogPath; } - // Check for input patterns (wildcard) - String pattern = getOptionalValue(line, "inputs"); - if (pattern != null) { - File catalogFile = new File(catalogPath); - for (File inputOntologyFile : getFilesByPattern(pattern)) { - inputOntologies.add(ioHelper.loadOntology(inputOntologyFile, catalogFile, inputFormat)); + + /** + * Add a loaded ontology and the local path that loaded it, if any. + * + * @param ontology loaded ontology + * @param ontologyPath local ontology path, or null for IRI inputs + */ + void add(OWLOntology ontology, String ontologyPath) { + if (ontologies.isEmpty()) { + primaryOntologyPath = ontologyPath; } + ontologies.add(ontology); + } + + /** + * Return the loaded ontologies. + * + * @return loaded ontologies + */ + List getOntologies() { + return ontologies; + } + + /** + * Return the explicit catalog path. + * + * @return explicit catalog path, or null + */ + String getCatalogPath() { + return catalogPath; + } + + /** + * Return the first local input path. + * + * @return first local input path, or null + */ + String getPrimaryOntologyPath() { + return primaryOntologyPath; } - return inputOntologies; } /** diff --git a/robot-command/src/main/java/org/obolibrary/robot/MergeCommand.java b/robot-command/src/main/java/org/obolibrary/robot/MergeCommand.java index e37a54520..2938baad1 100644 --- a/robot-command/src/main/java/org/obolibrary/robot/MergeCommand.java +++ b/robot-command/src/main/java/org/obolibrary/robot/MergeCommand.java @@ -1,6 +1,5 @@ package org.obolibrary.robot; -import java.util.ArrayList; import java.util.List; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Options; @@ -114,14 +113,8 @@ public CommandState execute(CommandState state, String[] args) throws Exception state = new CommandState(); } - List inputOntologies = new ArrayList<>(); - // inputOntologies should not be empty - boolean notEmpty = false; - if (state.getOntology() != null) { - notEmpty = true; - inputOntologies.add(state.getOntology()); - } - inputOntologies.addAll(CommandLineHelper.getInputOntologies(ioHelper, line, notEmpty)); + List inputOntologies = + CommandLineHelper.getPrimaryInputOntologies(ioHelper, state, line); boolean collapseImportClosure = CommandLineHelper.getBooleanValue(line, "collapse-import-closure", true); diff --git a/robot-command/src/main/java/org/obolibrary/robot/UnmergeCommand.java b/robot-command/src/main/java/org/obolibrary/robot/UnmergeCommand.java index 7427334b0..64daa297c 100644 --- a/robot-command/src/main/java/org/obolibrary/robot/UnmergeCommand.java +++ b/robot-command/src/main/java/org/obolibrary/robot/UnmergeCommand.java @@ -1,6 +1,5 @@ package org.obolibrary.robot; -import java.util.ArrayList; import java.util.List; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Options; @@ -101,13 +100,8 @@ public CommandState execute(CommandState state, String[] args) throws Exception state = new CommandState(); } - List inputOntologies = new ArrayList<>(); - boolean notEmpty = false; - if (state.getOntology() != null) { - notEmpty = true; - inputOntologies.add(state.getOntology()); - } - inputOntologies.addAll(CommandLineHelper.getInputOntologies(ioHelper, line, notEmpty)); + List inputOntologies = + CommandLineHelper.getPrimaryInputOntologies(ioHelper, state, line); OWLOntology outputOntology = UnmergeOperation.unmerge(inputOntologies); diff --git a/robot-command/src/test/java/org/obolibrary/robot/PrimaryInputCommandTest.java b/robot-command/src/test/java/org/obolibrary/robot/PrimaryInputCommandTest.java new file mode 100644 index 000000000..396421359 --- /dev/null +++ b/robot-command/src/test/java/org/obolibrary/robot/PrimaryInputCommandTest.java @@ -0,0 +1,334 @@ +package org.obolibrary.robot; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import org.apache.commons.io.FileUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** Tests for commands that keep the existing or first input ontology as state. */ +public class PrimaryInputCommandTest { + + /** Temporary test files. */ + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + /** Test files used by command chains. */ + private static class Fixtures { + private String primaryCatalogPath; + private String badCatalogPath; + private String primaryOntologyPath; + private String primaryInputsPattern; + private String auxiliaryOntologyPath; + private String updatePath; + } + + /** + * Test that standalone merge records metadata for its first input. + * + * @throws Exception on parsing or ontology loading problems + */ + @Test + public void testMergeRecordsStandaloneMetadata() throws Exception { + Fixtures fixtures = createFixtures(); + CommandState state = + executeManaged( + new MergeCommand(), + new String[] { + "--catalog", + fixtures.primaryCatalogPath, + "merge", + "--input", + fixtures.primaryOntologyPath, + "--collapse-import-closure", + "false" + }); + + assertEquals(fixtures.primaryCatalogPath, state.getCatalogPath()); + assertEquals(fixtures.primaryOntologyPath, state.getOntologyPath()); + } + + /** + * Test that standalone unmerge records metadata for its first input. + * + * @throws Exception on parsing or ontology loading problems + */ + @Test + public void testUnmergeRecordsStandaloneMetadata() throws Exception { + Fixtures fixtures = createFixtures(); + CommandState state = + executeManaged( + new UnmergeCommand(), + new String[] { + "--catalog", + fixtures.primaryCatalogPath, + "unmerge", + "--input", + fixtures.primaryOntologyPath + }); + + assertEquals(fixtures.primaryCatalogPath, state.getCatalogPath()); + assertEquals(fixtures.primaryOntologyPath, state.getOntologyPath()); + } + + /** + * Test that merge records the first wildcard input path for later catalog guessing. + * + * @throws Exception on parsing, ontology loading, or query update problems + */ + @Test + public void testMergeInputsPatternRecordsMetadataForQueryUpdate() throws Exception { + Fixtures fixtures = createFixtures(); + CommandState state = + new MergeCommand() + .execute( + null, + new String[] { + "--inputs", fixtures.primaryInputsPattern, "--collapse-import-closure", "false" + }); + + assertNull(state.getCatalogPath()); + assertEquals(fixtures.primaryOntologyPath, state.getOntologyPath()); + new QueryCommand().execute(state, new String[] {"--update", fixtures.updatePath}); + } + + /** + * Test that wildcard inputs still honor explicit input format. + * + * @throws Exception on parsing, ontology loading, or query update problems + */ + @Test + public void testInputsPatternPreservesInputFormat() throws Exception { + Fixtures fixtures = createFixtures("primary-target.data", "primary*.data"); + CommandState state = + new MergeCommand() + .execute( + null, + new String[] { + "--input-format", + "owl", + "--inputs", + fixtures.primaryInputsPattern, + "--collapse-import-closure", + "false" + }); + + assertNull(state.getCatalogPath()); + assertEquals(fixtures.primaryOntologyPath, state.getOntologyPath()); + new QueryCommand().execute(state, new String[] {"--update", fixtures.updatePath}); + } + + /** + * Test that unmerge records the first wildcard input path for later catalog guessing. + * + * @throws Exception on parsing, ontology loading, or query update problems + */ + @Test + public void testUnmergeInputsPatternRecordsMetadataForQueryUpdate() throws Exception { + Fixtures fixtures = createFixtures(); + CommandState state = + new UnmergeCommand() + .execute(null, new String[] {"--inputs", fixtures.primaryInputsPattern}); + + assertNull(state.getCatalogPath()); + assertEquals(fixtures.primaryOntologyPath, state.getOntologyPath()); + new QueryCommand().execute(state, new String[] {"--update", fixtures.updatePath}); + } + + /** + * Test that chained merge preserves metadata for the target ontology used by query update. + * + * @throws Exception on parsing, ontology loading, or query update problems + */ + @Test + public void testMergePreservesChainedMetadataForQueryUpdate() throws Exception { + Fixtures fixtures = createFixtures(); + MergeCommand mergeCommand = new MergeCommand(); + CommandState state = + mergeCommand.execute( + null, + new String[] { + "--catalog", + fixtures.primaryCatalogPath, + "--input", + fixtures.primaryOntologyPath, + "--collapse-import-closure", + "false" + }); + + state = + mergeCommand.execute( + state, + new String[] { + "--catalog", + fixtures.badCatalogPath, + "--input", + fixtures.auxiliaryOntologyPath, + "--collapse-import-closure", + "false" + }); + + assertEquals(fixtures.primaryCatalogPath, state.getCatalogPath()); + assertEquals(fixtures.primaryOntologyPath, state.getOntologyPath()); + new QueryCommand().execute(state, new String[] {"--update", fixtures.updatePath}); + } + + /** + * Test that chained unmerge preserves metadata for the target ontology used by query update. + * + * @throws Exception on parsing, ontology loading, or query update problems + */ + @Test + public void testUnmergePreservesChainedMetadataForQueryUpdate() throws Exception { + Fixtures fixtures = createFixtures(); + UnmergeCommand unmergeCommand = new UnmergeCommand(); + CommandState state = + unmergeCommand.execute( + null, + new String[] { + "--catalog", fixtures.primaryCatalogPath, "--input", fixtures.primaryOntologyPath + }); + + state = + unmergeCommand.execute( + state, + new String[] { + "--catalog", fixtures.badCatalogPath, "--input", fixtures.auxiliaryOntologyPath + }); + + assertEquals(fixtures.primaryCatalogPath, state.getCatalogPath()); + assertEquals(fixtures.primaryOntologyPath, state.getOntologyPath()); + new QueryCommand().execute(state, new String[] {"--update", fixtures.updatePath}); + } + + /** + * Create ontology, catalog, and update files for testing catalog preservation. + * + * @return fixture file paths + * @throws Exception on file creation problems + */ + private Fixtures createFixtures() throws Exception { + return createFixtures("primary-target.owl", "primary*.owl"); + } + + /** + * Create ontology, catalog, and update files for testing catalog preservation. + * + * @param primaryOntologyName name of the primary ontology file + * @param primaryInputsPattern wildcard pattern for the primary ontology + * @return fixture file paths + * @throws Exception on file creation problems + */ + private Fixtures createFixtures(String primaryOntologyName, String primaryInputsPattern) + throws Exception { + File directory = temporaryFolder.newFolder(); + File primaryImport = new File(directory, "imported-resource.owl"); + File invalidImport = new File(directory, "invalid-import.owl"); + File primaryOntology = new File(directory, primaryOntologyName); + File auxiliaryOntology = new File(directory, "auxiliary.owl"); + File primaryCatalog = new File(directory, "catalog-v001.xml"); + File badCatalog = new File(directory, "bad-catalog.xml"); + File update = new File(directory, "update.ru"); + String importIRI = invalidImport.toURI().toString(); + + write( + primaryImport, + ontology(importIRI, "", "")); + write(invalidImport, "not an ontology"); + write( + primaryOntology, + ontology( + "http://example.org/primary.owl", + "", + "")); + write( + auxiliaryOntology, + ontology( + "http://example.org/auxiliary.owl", + "", + "")); + write(primaryCatalog, catalog(importIRI, primaryImport)); + write(badCatalog, catalog(importIRI, invalidImport)); + write( + update, + "PREFIX rdfs: \n" + + "INSERT DATA { rdfs:comment \"updated\" . }\n"); + + Fixtures fixtures = new Fixtures(); + fixtures.primaryCatalogPath = primaryCatalog.getPath(); + fixtures.badCatalogPath = badCatalog.getPath(); + fixtures.primaryOntologyPath = primaryOntology.getPath(); + fixtures.primaryInputsPattern = new File(directory, primaryInputsPattern).getPath(); + fixtures.auxiliaryOntologyPath = auxiliaryOntology.getPath(); + fixtures.updatePath = update.getPath(); + return fixtures; + } + + /** + * Execute one command through CommandManager to test global options. + * + * @param command command to register + * @param args command-line arguments + * @return the resulting state + * @throws Exception on command problems + */ + private CommandState executeManaged(Command command, String[] args) throws Exception { + CommandManager manager = new CommandManager(); + manager.addCommand(command.getName(), command); + return manager.execute(null, args); + } + + /** + * Write content to a file. + * + * @param file the target file + * @param content the content to write + * @throws Exception on write problems + */ + private void write(File file, String content) throws Exception { + FileUtils.writeStringToFile(file, content, StandardCharsets.UTF_8); + } + + /** + * Return a minimal RDF/XML ontology. + * + * @param ontologyIRI the ontology IRI + * @param ontologyContent content inside the ontology element + * @param entityContent content inside the RDF document + * @return ontology document content + */ + private String ontology(String ontologyIRI, String ontologyContent, String entityContent) { + return "\n" + + "\n" + + " \n" + + ontologyContent + + "\n \n" + + entityContent + + "\n\n"; + } + + /** + * Return an XML catalog that maps the primary import IRI to a file. + * + * @param importIRI the import IRI to map + * @param importFile the mapped import file + * @return XML catalog content + */ + private String catalog(String importIRI, File importFile) { + return "\n" + + "\n" + + " \n" + + "\n"; + } +}