I have an array of Object that I need sorted by two of the object's fields.
Product.java
private String productionDate;
private String productionCountry;
private String productDescription;
Now I have an array of the Product which is populated with a bunch of products.
List<Product> productList
I am basically attempting to sort the productList first by productionDate and then by productionCountry similar to what the below query is doing.
Select * from Product Order By production_date, production_country;
I learned of a way to sort by multiple fields by doing something like this
List<Product> sortedProduct = productList.stream()
.sorted(Comparator.comparing(Product::getProductionDate)
.thenComparing(Product::getProductionCountry))
.collect(Collectors.toList());
But the issue is that productionDate is of type String so the above solution would sort by strictly comparing string instead of Date type which is what I want. What is the best possible way to achieve what I am trying to do?
EDIT: productionDate is in this format in Database table "2021-04-21"
Define a record with data types appropriate to your data.
Using appropriate data types brings multiple benefits. One benefit is data validation. Another is future flexibility enabled by the features of that type. (Ex: report by month)
LocalDate
For a date value, the appropriate type is java.time.LocalDate
.
record Product ( LocalDate date , String country , String description ) {}
Apparently your database table was poorly designed to store a date as text rather than as a date. Ideally you would refactor your database to use appropriate data types. For date-only values, the SQL standard type is DATE
, supported in every serious database engine.
Until then, we need to convert that text to a LocalDate
object. Add a static
factory method to our record for that conversion work.
record Product ( LocalDate date , String country , String description )
{
// Factory method
static Product from ( final String aDate , final String aCountry , final String aDescription )
{
LocalDate ld = LocalDate.parse ( aDate ) ; // If not in standard ISO 8601 format, use a `DateTimeFormatter`.
return new Product ( ld , aCountry , aDescription ) ;
}
}
If your input data is in standard ISO 8601 format, you can parse directly. Otherwise, define a DateTimeFormatter
to match the format of your input data.
record Product ( LocalDate date , String country , String description )
{
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern ( "MM/dd/uuuu" ) ;
// Factory method
static Product from ( final String aDate , final String aCountry , final String aDescription )
{
LocalDate ld = LocalDate.parse ( aDate , Product.formatter ) ;
return new Product ( ld , aCountry , aDescription ) ;
}
}
In real work, I would add a try-catch to that LocalDate.parse
to trap for the exception thrown when we encounter invalid input data. If thrown, throw a IllegalArgumentException
. And we could add some input validation.
public record Product( LocalDate date , String country , String description )
{
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern ( "MM/dd/uuuu" );
// Factory method
static Product from ( final String aDate , final String aCountry , final String aDescription )
{
// Input validation. (a) null check. (b) Blank string check.
if ( Objects.requireNonNull ( aCountry ).isBlank ( ) )
{
throw new IllegalArgumentException ( "Invalid country string: " + aCountry );
}
if ( Objects.requireNonNull ( aDescription ).isBlank ( ) )
{
throw new IllegalArgumentException ( "Invalid description string: " + aDescription );
}
try
{
LocalDate ld = LocalDate.parse ( aDate , Product.formatter );
return new Product (
Objects.requireNonNull ( ld ) ,
aCountry ,
aDescription );
}
catch ( DateTimeParseException e )
{
throw new IllegalArgumentException ( "Invalid date string: " + aDate );
}
}
}
To get your sorting behavior, you could define a Comparator
as you did in Question. But add a third comparing rule, in case of duplicates in both date and in country.
equals
& hashCode
We also want to use the third field so our comparison logic is consistent with the logic in our record’s default implicit implementation of Object
overrides equals
& hashCode
.
Comparator
.comparing ( Product :: date )
.thenComparing ( Product :: country )
.thenComparing ( Product :: description ) // ⬅️ Tie-breaker for same date AND same country.
By the way, no need to stream to sort your list if your list is modifiable. Just call List#sort
.
products.sort (
Comparator
.comparing ( Product :: date )
.thenComparing ( Product :: country )
.thenComparing ( Product :: description )
);
Comparable
Or you could build that behavior into the class by making our record Comparable
.
We add a compareTo
, after declaring that we implement Comparable
.
Also note that we keep a single instance of our Comparator
around in a static
var, rather than re-instantiate repeatedly (a tiny optimization).
Since our Product
objects now know how to sort among themselves, we invoke the sorting with a call to Collections.sort
.
public record Product( LocalDate date , String country , String description ) implements Comparable < Product >
{
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern ( "MM/dd/uuuu" );
private static final Comparator < Product > PRODUCT_COMPARATOR =
Comparator
.comparing ( Product :: date )
.thenComparing ( Product :: country )
.thenComparing ( Product :: description );
// Factory method
static Product from ( final String aDate , final String aCountry , final String aDescription )
{
// Input validation. (a) null check. (b) Blank string check.
if ( Objects.requireNonNull ( aCountry ).isBlank ( ) )
{
throw new IllegalArgumentException ( "Invalid country string: " + aCountry );
}
if ( Objects.requireNonNull ( aDescription ).isBlank ( ) )
{
throw new IllegalArgumentException ( "Invalid description string: " + aDescription );
}
// Instantiate a `Product` object from parsed inputs.
try
{
LocalDate ld = LocalDate.parse ( aDate , Product.formatter );
return new Product (
Objects.requireNonNull ( ld ) ,
aCountry ,
aDescription );
}
catch ( DateTimeParseException e )
{
throw new IllegalArgumentException ( "Invalid date string: " + aDate );
}
}
// Implements `Comparable`.
@Override
public int compareTo ( final Product other )
{
return PRODUCT_COMPARATOR.compare ( this , other );
}
}
Example usage:
List < Product > products =
new ArrayList <> (
List.of (
Product.from ( "01/23/2025" , "CA" , "Widget" ) ,
Product.from ( "01/23/2025" , "CA" , "Alpha" ) ,
Product.from ( "01/23/2025" , "CA" , "Beta" )
)
);
Collections.sort ( products );
products.forEach ( System.out :: println );
Product[date=2025-01-23, country=CA, description=Alpha]
Product[date=2025-01-23, country=CA, description=Beta]
Product[date=2025-01-23, country=CA, description=Widget]
In real work, I would make a type for the country field. Since you know there is a limited set of possibilities known at compile-time, create an enum.
Depending your situation, you may want to use the new super-interface SequencedCollection
rather than List
.
SequencedCollection < Product > products = … ;