How to create custom Comparator to rank search results?

Zephyr :

This is a followup to a prior question I posted here.

In the MCVE below, I have a TableView displaying a list of Person objects. Above the list, I have a single TextField which I use to filter the listed items in the TableView.

The Person class contains 4 fields, but I have my search field only checking for matches in 3 of them: userId, lastName, and emailAddress.

The filtering function works as expected.

However, I now need to rank the results based on which fields were matched and the user Type.

MCVE CODE

Person.java:

import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public final class Person {

    private StringProperty userType = new SimpleStringProperty();
    private IntegerProperty userId = new SimpleIntegerProperty();
    private StringProperty firstName = new SimpleStringProperty();
    private StringProperty lastName = new SimpleStringProperty();
    private StringProperty emailAddress = new SimpleStringProperty();

    public Person(String type, int id, String firstName, String lastName, String emailAddress) {
        this.userType.set(type);
        this.userId.set(id);
        this.firstName.set(firstName);
        this.lastName.set(lastName);
        this.emailAddress.set(emailAddress);
    }

    public String getUserType() {
        return userType.get();
    }

    public void setUserType(String userType) {
        this.userType.set(userType);
    }

    public StringProperty userTypeProperty() {
        return userType;
    }

    public int getUserId() {
        return userId.get();
    }

    public void setUserId(int userId) {
        this.userId.set(userId);
    }

    public IntegerProperty userIdProperty() {
        return userId;
    }

    public String getFirstName() {
        return firstName.get();
    }

    public void setFirstName(String firstName) {
        this.firstName.set(firstName);
    }

    public StringProperty firstNameProperty() {
        return firstName;
    }

    public String getLastName() {
        return lastName.get();
    }

    public void setLastName(String lastName) {
        this.lastName.set(lastName);
    }

    public StringProperty lastNameProperty() {
        return lastName;
    }

    public String getEmailAddress() {
        return emailAddress.get();
    }

    public void setEmailAddress(String emailAddress) {
        this.emailAddress.set(emailAddress);
    }

    public StringProperty emailAddressProperty() {
        return emailAddress;
    }
}

Main.java:

import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

import java.util.Comparator;

public class Main extends Application {

    TableView<Person> tableView;
    private TextField txtSearch;

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage primaryStage) {

        // Simple Interface
        VBox root = new VBox(10);
        root.setAlignment(Pos.CENTER);
        root.setPadding(new Insets(10));

        // Create the TableView of data
        tableView = new TableView<>();
        TableColumn<Person, Integer> colId = new TableColumn<>("ID");
        TableColumn<Person, String> colFirstName = new TableColumn<>("First Name");
        TableColumn<Person, String> colLastName = new TableColumn<>("Last Name");
        TableColumn<Person, String> colEmailAddress = new TableColumn<>("Email Address");

        // Set the ValueFactories
        colId.setCellValueFactory(new PropertyValueFactory<>("userId"));
        colFirstName.setCellValueFactory(new PropertyValueFactory<>("firstName"));
        colLastName.setCellValueFactory(new PropertyValueFactory<>("lastName"));
        colEmailAddress.setCellValueFactory(new PropertyValueFactory<>("emailAddress"));

        // Add columns to the TableView
        tableView.getColumns().addAll(colId, colFirstName, colLastName, colEmailAddress);

        // Create the filter/search TextField
        txtSearch = new TextField();
        txtSearch.setPromptText("Search ...");

        addSearchFilter(getPersons());

        // Add the controls to the layout
        root.getChildren().addAll(txtSearch, tableView);

        // Show the stage
        primaryStage.setScene(new Scene(root));
        primaryStage.setTitle("Sample");
        primaryStage.show();
    }

    private void addSearchFilter(ObservableList<Person> list) {

        FilteredList<Person> filteredList = new FilteredList<Person>(list);

        txtSearch.textProperty().addListener(((observable, oldValue, newValue) ->
                filteredList.setPredicate(person -> {

                    // Clear any currently-selected item from the TableView
                    tableView.getSelectionModel().clearSelection();

                    // If search field is empty, show everything
                    if (newValue == null || newValue.trim().isEmpty()) {
                        return true;
                    }

                    // Grab the trimmed search string
                    String query = newValue.trim().toLowerCase();

                    // Convert the query to an array of individual search terms
                    String[] keywords = query.split("[\\s]+");

                    // Create a single string containing all the data we will match against
                    // BONUS QUESTION: Is there a better way to do this?
                    String matchString =
                            String.valueOf(person.getUserId())
                                    + person.getLastName().toLowerCase()
                                    + person.getEmailAddress().toLowerCase();

                    // Check if ALL the keywords exist in the matchString; if any are absent, return false;
                    for (String keyword : keywords) {
                        if (!matchString.contains(keyword)) return false;
                    }

                    // All entered keywords exist in this Person's searchable fields
                    return true;

                })));

        SortedList<Person> sortedList = new SortedList<>(filteredList);

        // Create the Comparator to allow ranking of search results
        Comparator<Person> comparator = new Comparator<Person>() {
            @Override
            public int compare(Person person, Person t1) {
                return 0;

            }
        };

        // Set the comparator and bind list to the TableView
        sortedList.setComparator(comparator);
        tableView.setItems(sortedList);

    }

    private ObservableList<Person> getPersons() {

        ObservableList<Person> personList = FXCollections.observableArrayList();

        personList.add(new Person("DECEASED", 123, "Chrissie", "Watkins", "[email protected]"));
        personList.add(new Person("VET", 342, "Matt", "Hooper", "[email protected]"));
        personList.add(new Person("VET", 526, "Martin", "Brody", "[email protected]"));
        personList.add(new Person("NEW", 817, "Larry", "Vaughn", "[email protected]"));

        return personList;
    }
}

You'll see I have an empty Comparator in my Main class. This is what I need help with. I have created comparators in the past that are able to sort based on one field (from my previous question):

    Comparator<DataItem> byName = new Comparator<DataItem>() {
        @Override
        public int compare(DataItem o1, DataItem o2) {
            String searchKey = txtSearch.getText().toLowerCase();
            int item1Score = findScore(o1.getName().toLowerCase(), searchKey);
            int item2Score = findScore(o2.getName().toLowerCase(), searchKey);

            if (item1Score > item2Score) {
                return -1;
            }

            if (item2Score > item1Score) {
                return 1;
            }

            return 0;
        }

        private int findScore(String item1Name, String searchKey) {
            int sum = 0;
            if (item1Name.startsWith(searchKey)) {
                sum += 2;
            }

            if (item1Name.contains(searchKey)) {
                sum += 1;
            }
            return sum;
        }
    };

I am not sure how to adapt this for multiple fields, though. Specifically, I want to be able to choose which fields should be ranked "higher."

For this example, what I want to accomplish is to sort the list in this order:

  1. userId starts with a keyword
  2. lastName starts with a keyword
  3. emailAddress starts with a keyword
  4. lastName contains a keyword
  5. emailAddress contains a keyword
  6. Within matches any userType = "VET" should be listed first

I am not looking for Google-level algorithms, but just some way to prioritize matches. I am not very familiar with the Comparator class and have a hard time understanding the JavaDocs for it, as it applies to my needs.


There are several posts on StackOverflow that deal with sorting by multiple fields, but all those I've found are comparing Person to Person. Here, I need to compare Person fields to the txtSearch.getText() value.

How would I go about refactoring this Comparator to set up custom sorting of this nature?

Will Hartung :

Your scoring concept is close, you just need to come up with factors and follow the rules.

So, here's a simple example:

public int score(Item item, String query) {
    int score = 0;

    if (item.userId().startsWith(query) {
        score += 2000;
    }
    if (item.lastName().startsWith(query) {
        score += 200;
    } else if (item.lastName().contains(query) {
        score += 100;
    }
    if (item.email().startsWith(query) {
        score += 20;
    } else if (item.email().contains(query) {
        score += 10;
    }
    if (item.userType().equals("VET")) {
        score += 5;
    }

    return score;
}

So as you can see, I took each of your criteria and turned them in to different digits within the score, and for the distinction within each criteria, I had different values (10 vs 20, for example). Finally I tacked on 5 for the "VET" type.

The assumption is that the scoring rules are not exclusive (i.e. that each rule refines the scoring, rather than stops it), and the the VET types were tie breakers within each criteria, vs to the top of the list. If VET needs to go to the top of the list (i.e. all VETs will be show before all non-VET), you can change the 5 to 10000, giving it it's own order of magnitude.

Now, using decimal numbers is just easy, but you'll run out of magnitudes after 9 (you'll overflow the int) -- you could also use other bases (base 3 in this example), giving you access to more "bits" in the integer. You could use a long, Or you could use a BigDecimal value and have as many criteria as you like.

But the basics are the same.

Once you have the score, just compare the scores of the two values in your comparator.

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=102935&siteId=1