diff --git a/spring-core/src/main/java/org/springframework/core/CollectionFactory.java b/spring-core/src/main/java/org/springframework/core/CollectionFactory.java index 4923464910d0e3ff65b30f4cb5a35846c49a4438..6df7b4daac1339a3fb6ea2c29aa92efc5552b8a8 100644 --- a/spring-core/src/main/java/org/springframework/core/CollectionFactory.java +++ b/spring-core/src/main/java/org/springframework/core/CollectionFactory.java @@ -346,6 +346,50 @@ public final class CollectionFactory { }; } + /** + * Create a variant of {@link java.util.Properties} that sorts properties + * alphanumerically based on their keys. + * + *

This can be useful when storing the {@link Properties} instance in a + * properties file, since it allows such files to be generated in a repeatable + * manner with consistent ordering of properties. Comments in generated + * properties files can also be optionally omitted. + * + * @param omitComments {@code true} if comments should be omitted when + * storing properties in a file + * @return a new {@code Properties} instance + * @since 5.2 + * @see #createSortedProperties(Properties, boolean) + */ + public static Properties createSortedProperties(boolean omitComments) { + return new SortedProperties(omitComments); + } + + /** + * Create a variant of {@link java.util.Properties} that sorts properties + * alphanumerically based on their keys. + * + *

This can be useful when storing the {@code Properties} instance in a + * properties file, since it allows such files to be generated in a repeatable + * manner with consistent ordering of properties. Comments in generated + * properties files can also be optionally omitted. + * + *

The returned {@code Properties} instance will be populated with + * properties from the supplied {@code properties} object, but default + * properties from the supplied {@code properties} object will not be copied. + * + * @param properties the {@code Properties} object from which to copy the + * initial properties + * @param omitComments {@code true} if comments should be omitted when + * storing properties in a file + * @return a new {@code Properties} instance + * @since 5.2 + * @see #createSortedProperties(boolean) + */ + public static Properties createSortedProperties(Properties properties, boolean omitComments) { + return new SortedProperties(properties, omitComments); + } + /** * Cast the given type to a subtype of {@link Enum}. * @param enumType the enum type, never {@code null} diff --git a/spring-core/src/main/java/org/springframework/core/SortedProperties.java b/spring-core/src/main/java/org/springframework/core/SortedProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..f1c2e8417b6b961f9c14c65087d288a36c39e363 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/SortedProperties.java @@ -0,0 +1,161 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.StringWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.Map.Entry; +import java.util.Properties; +import java.util.Set; +import java.util.TreeSet; + +import org.springframework.util.StringUtils; + +/** + * Specialization of {@link Properties} that sorts properties alphanumerically + * based on their keys. + * + *

This can be useful when storing the {@link Properties} instance in a + * properties file, since it allows such files to be generated in a repeatable + * manner with consistent ordering of properties. + * + *

Comments in generated properties files can also be optionally omitted. + * + * @author Sam Brannen + * @since 5.2 + * @see java.util.Properties + */ +@SuppressWarnings("serial") +class SortedProperties extends Properties { + + static final String EOL = System.getProperty("line.separator"); + + private static final Comparator keyComparator = // + (key1, key2) -> String.valueOf(key1).compareTo(String.valueOf(key2)); + + private static final Comparator> entryComparator = // + Entry.comparingByKey(keyComparator); + + private final boolean omitComments; + + + /** + * Construct a new {@code SortedProperties} instance that honors the supplied + * {@code omitComments} flag. + * + * @param omitComments {@code true} if comments should be omitted when + * storing properties in a file + */ + SortedProperties(boolean omitComments) { + this.omitComments = omitComments; + } + + /** + * Construct a new {@code SortedProperties} instance with properties populated + * from the supplied {@link Properties} object and honoring the supplied + * {@code omitComments} flag. + * + *

Default properties from the supplied {@code Properties} object will + * not be copied. + * + * @param properties the {@code Properties} object from which to copy the + * initial properties + * @param omitComments {@code true} if comments should be omitted when + * storing properties in a file + */ + SortedProperties(Properties properties, boolean omitComments) { + this(omitComments); + putAll(properties); + } + + @Override + public void store(OutputStream out, String comments) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + super.store(baos, (this.omitComments ? null : comments)); + String contents = new String(baos.toByteArray(), StandardCharsets.ISO_8859_1); + for (String line : StringUtils.tokenizeToStringArray(contents, EOL)) { + if (!this.omitComments || !line.startsWith("#")) { + out.write((line + EOL).getBytes(StandardCharsets.ISO_8859_1)); + } + } + } + + @Override + public void store(Writer writer, String comments) throws IOException { + StringWriter stringWriter = new StringWriter(); + super.store(stringWriter, (this.omitComments ? null : comments)); + String contents = stringWriter.toString(); + for (String line : StringUtils.tokenizeToStringArray(contents, EOL)) { + if (!this.omitComments || !line.startsWith("#")) { + writer.write(line + EOL); + } + } + } + + @Override + public void storeToXML(OutputStream out, String comments) throws IOException { + super.storeToXML(out, (this.omitComments ? null : comments)); + } + + @Override + public void storeToXML(OutputStream out, String comments, String encoding) throws IOException { + super.storeToXML(out, (this.omitComments ? null : comments), encoding); + } + + /** + * Return a sorted enumeration of the keys in this {@link Properties} object. + * @see #keySet() + */ + @Override + public synchronized Enumeration keys() { + return Collections.enumeration(keySet()); + } + + /** + * Return a sorted set of the keys in this {@link Properties} object. + *

The keys will be converted to strings if necessary using + * {@link String#valueOf(Object)} and sorted alphanumerically according to + * the natural order of strings. + */ + @Override + public Set keySet() { + Set sortedKeys = new TreeSet<>(keyComparator); + sortedKeys.addAll(super.keySet()); + return Collections.synchronizedSet(sortedKeys); + } + + /** + * Return a sorted set of the entries in this {@link Properties} object. + *

The entries will be sorted based on their keys, and the keys will be + * converted to strings if necessary using {@link String#valueOf(Object)} + * and compared alphanumerically according to the natural order of strings. + */ + @Override + public Set> entrySet() { + Set> sortedEntries = new TreeSet<>(entryComparator); + sortedEntries.addAll(super.entrySet()); + return Collections.synchronizedSet(sortedEntries); + } + +} diff --git a/spring-core/src/test/java/org/springframework/core/SortedPropertiesTests.java b/spring-core/src/test/java/org/springframework/core/SortedPropertiesTests.java new file mode 100644 index 0000000000000000000000000000000000000000..8cd96facb6b8ed084e6cd5a04719883d824e91e9 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/SortedPropertiesTests.java @@ -0,0 +1,213 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Properties; + +import org.junit.Test; + +import static java.util.Arrays.stream; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +/** + * Unit tests for {@link SortedProperties}. + * + * @author Sam Brannen + * @since 5.2 + */ +public class SortedPropertiesTests { + + @Test + public void keys() { + assertKeys(createSortedProps()); + } + + @Test + public void keysFromPrototype() { + assertKeys(createSortedPropsFromPrototype()); + } + + @Test + public void keySet() { + assertKeySet(createSortedProps()); + } + + @Test + public void keySetFromPrototype() { + assertKeySet(createSortedPropsFromPrototype()); + } + + @Test + public void entrySet() { + assertEntrySet(createSortedProps()); + } + + @Test + public void entrySetFromPrototype() { + assertEntrySet(createSortedPropsFromPrototype()); + } + + @Test + public void sortsPropertiesUsingOutputStream() throws IOException { + SortedProperties sortedProperties = createSortedProps(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + sortedProperties.store(baos, "custom comment"); + + String[] lines = lines(baos); + assertThat(lines).hasSize(7); + assertThat(lines[0]).isEqualTo("#custom comment"); + assertThat(lines[1]).as("timestamp").startsWith("#"); + + assertPropsAreSorted(lines); + } + + @Test + public void sortsPropertiesUsingWriter() throws IOException { + SortedProperties sortedProperties = createSortedProps(); + + StringWriter writer = new StringWriter(); + sortedProperties.store(writer, "custom comment"); + + String[] lines = lines(writer); + assertThat(lines).hasSize(7); + assertThat(lines[0]).isEqualTo("#custom comment"); + assertThat(lines[1]).as("timestamp").startsWith("#"); + + assertPropsAreSorted(lines); + } + + @Test + public void sortsPropertiesAndOmitsCommentsUsingOutputStream() throws IOException { + SortedProperties sortedProperties = createSortedProps(true); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + sortedProperties.store(baos, "custom comment"); + + String[] lines = lines(baos); + assertThat(lines).hasSize(5); + + assertPropsAreSorted(lines); + } + + @Test + public void sortsPropertiesAndOmitsCommentsUsingWriter() throws IOException { + SortedProperties sortedProperties = createSortedProps(true); + + StringWriter writer = new StringWriter(); + sortedProperties.store(writer, "custom comment"); + + String[] lines = lines(writer); + assertThat(lines).hasSize(5); + + assertPropsAreSorted(lines); + } + + @Test + public void storingAsXmlSortsPropertiesAndOmitsComments() throws IOException { + SortedProperties sortedProperties = createSortedProps(true); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + sortedProperties.storeToXML(baos, "custom comment"); + + String[] lines = lines(baos); + + assertThat(lines).containsExactly( // + "", // + "", // + "", // + "blue", // + "sweet", // + "apple", // + "medium", // + "car", // + "" // + ); + } + + private SortedProperties createSortedProps() { + return createSortedProps(false); + } + + private SortedProperties createSortedProps(boolean omitComments) { + SortedProperties sortedProperties = new SortedProperties(omitComments); + populateProperties(sortedProperties); + return sortedProperties; + } + + private SortedProperties createSortedPropsFromPrototype() { + Properties properties = new Properties(); + populateProperties(properties); + return new SortedProperties(properties, false); + } + + private void populateProperties(Properties properties) { + properties.setProperty("color", "blue"); + properties.setProperty("fragrance", "sweet"); + properties.setProperty("fruit", "apple"); + properties.setProperty("size", "medium"); + properties.setProperty("vehicle", "car"); + } + + private String[] lines(ByteArrayOutputStream baos) { + return lines(new String(baos.toByteArray(), StandardCharsets.ISO_8859_1)); + } + + private String[] lines(StringWriter writer) { + return lines(writer.toString()); + } + + private String[] lines(String input) { + return input.trim().split(SortedProperties.EOL); + } + + private void assertKeys(Properties properties) { + assertThat(Collections.list(properties.keys())) // + .containsExactly("color", "fragrance", "fruit", "size", "vehicle"); + } + + private void assertKeySet(Properties properties) { + assertThat(properties.keySet()).containsExactly("color", "fragrance", "fruit", "size", "vehicle"); + } + + private void assertEntrySet(Properties properties) { + assertThat(properties.entrySet()).containsExactly( // + entry("color", "blue"), // + entry("fragrance", "sweet"), // + entry("fruit", "apple"), // + entry("size", "medium"), // + entry("vehicle", "car") // + ); + } + + private void assertPropsAreSorted(String[] lines) { + assertThat(stream(lines).filter(s -> !s.startsWith("#"))).containsExactly( // + "color=blue", // + "fragrance=sweet", // + "fruit=apple", // + "size=medium", // + "vehicle=car"// + ); + } + +}