Ktor server for beginners – MongoDB (Part 2 – queries)

Level: Beginner

Welcome back!, in the previous lesson we learned how to setup our mongoDB and how to add, read, update and delete documents from our collections, in this lesson we are going to learn how to make queries to filter and sort our documents!.

Sorting data

To sort the list of documents returned by MongoDB we can use ascendingSort and descendingSort and we pass what value we would like to use for sorting, for example here we do descendingSort on the name of the fruit.

suspend fun getFruits(): List<Fruit> {
    return fruits.find().descendingSort(Fruit::name).toList()
}

to allow the client to specify how it wants the items to be sorted let’s introduce a variable to the function and let it determine how to sort items

suspend fun getFruits(sortField: KProperty1<Fruit, String>, sortDirection: Int = 1): List<Fruit> {
    return if (sortDirection < 0) {
        fruits.find().descendingSort(sortField).toList()
    }else{
        fruits.find().ascendingSort(sortField).toList()
    }
}

above the sortField allows us to send what field we would like to sort by we give it a data type of KProperty1 with our data class Fruit and the data type witch is a string in our case, the sort direction is an integer we will use -1 for descending and 1 for ascending.

KProperty1 is a Kotlin class that represents a read-only property of a class or interface. It is often used to get or set the value of a property through reflection.

Note that KProperty1 is a generic class with two type parameters. The first type parameter is the type of the class that contains the property (Fruit in our example), and the second type parameter is the type of the property (String in our example).

lets modify our route to work with theses changes

    get("/fruits/{sort_by?}/{sort_direction?}") {
        // read the sort_by from the parameters and assign it to sortBy variable
        val sortBy = when(call.parameters["sort_by"] ?: "name") {
            "name" -> Fruit::name
            "id" -> Fruit::id
            // if the parameter sent by client does not match any values from our model return a bad request
            else -> return@get call.respond(
                HttpStatusCode.BadRequest,
                SimpleResponse(success = false, message = "invalid parameter for sort_by")
            )
        }

        val sortDirection = when (call.parameters["sort_direction"] ?: "asc") {
            "dec" -> -1
            "asc" -> 1
            else -> return@get call.respond(
                HttpStatusCode.BadRequest,
                SimpleResponse(success = false, message = "invalid parameter for sort_direction")
            )
        }
        // get fruits from the database
        call.respond(HttpStatusCode.OK, getFruits(sortField = sortBy,sortDirection = sortDirection))
    }

Now we can sort our results!

Filtering documents

To filter results we need to pass our filters to the find function, we can use Kmongo’s type safe queries to make this easier, but first let’s add a season field to our Fruit data class

@Serializable
data class Fruit(
    val name: String,
    val season: Season = Season.Unknown,
    val countries: List<String> = emptyList(),
    @BsonId
    val id: String = ObjectId().toString()
){
    enum class Season {
        Spring, Winter, Summer, Autumn, Unknown
    }
}

let’s add some summer fruits …

Now if we would like to return all summer fruits we can do that by passing the following to the find function, here the eq infix function will give a BSON filter that returns all season values that are equal to Season.Summer

In MongoDB, a BSON filter is a document that specifies criteria used to select documents from a collection. It is used to query the database and retrieve documents that match certain conditions.

fruits.find(Fruit::season eq Fruit.Season.Summer).toList()

lets modify our getFruits function to take in a season that we want to filter

suspend fun getFruits(
    sortField: KProperty1<Fruit, String>,
    sortDirection: Int = 1,
    filter: Fruit.Season? = null
): List<Fruit> {
    // if season is null set it as EMPTY_BSON witch means we won't filter the results
    val seasonFilters = if (filter == null) EMPTY_BSON else Fruit::season eq filter
    return if (sortDirection < 0) {
        fruits.find(seasonFilters).descendingSort(sortField).toList()
    } else {
        fruits.find(seasonFilters).ascendingSort(sortField).toList()
    }
}

in our routes file we will need to receive the season parameter

        val season = when (call.parameters["season"]) {
            "summer" -> Fruit.Season.Summer
            "winter" -> Fruit.Season.Winter
            "autumn" -> Fruit.Season.Autumn
            "spring" -> Fruit.Season.Spring
            null -> null
            else -> return@get call.respond(
                HttpStatusCode.BadRequest,
                SimpleResponse(success = false, message = "invalid parameter for season")
            )
        }
// and pass the season to the getFruits function
call.respond(HttpStatusCode.OK, getFruits(sortField = sortBy, sortDirection = sortDirection, season = season)

Combining filters

we now need a way to filter results of a season and also of a country, for that we can use the and function.

suspend fun getFruits(
    sortField: KProperty1<Fruit, String>,
    sortDirection: Int = 1,
    season: Fruit.Season? = null,
    country: String?
): List<Fruit> {
    // if season is null set it as EMPTY_BSON witch means we won't filter the results
    val seasonFilters = if (season == null) EMPTY_BSON else Fruit::season eq season
    // country filter
    val countryFilter = if (country == null) EMPTY_BSON else Fruit::countries contains country
    return if (sortDirection < 0) {
        // we use the and function to use two BSONs in the same time
        fruits.find(and(seasonFilters, countryFilter)).descendingSort(sortField).toList()
    } else {
        fruits.find(and(seasonFilters, countryFilter)).ascendingSort(sortField).toList()
    }
}

our route should now look something like this

    get("/fruits/{sort_by?}/{sort_direction?}/{season?}/{country?}") {
        // read the sort_by from the parameters and assign it to sortBy variable
        val sortBy = when (call.parameters["sort_by"] ?: "name") {
            "name" -> Fruit::name
            "id" -> Fruit::id
            // if the parameter sent by client does not match any values from our model return a bad request
            else -> return@get call.respond(
                HttpStatusCode.BadRequest,
                SimpleResponse(success = false, message = "invalid parameter for sort_by")
            )
        }

        val sortDirection = when (call.parameters["sort_direction"] ?: "asc") {
            "dec" -> -1
            "asc" -> 1
            else -> return@get call.respond(
                HttpStatusCode.BadRequest,
                SimpleResponse(success = false, message = "invalid parameter for sort_direction")
            )
        }

        val season = when (call.parameters["season"]) {
            "summer" -> Fruit.Season.Summer
            "winter" -> Fruit.Season.Winter
            "autumn" -> Fruit.Season.Autumn
            "spring" -> Fruit.Season.Spring
            null -> null
            else -> return@get call.respond(
                HttpStatusCode.BadRequest,
                SimpleResponse(success = false, message = "invalid parameter for season")
            )
        }
        val country = call.parameters["country"]

        // get fruits from the database
        call.respond(
            HttpStatusCode.OK,
            getFruits(sortField = sortBy, sortDirection = sortDirection, season = season, country = country)
        )
    }

Filtering by multiple seasons and countries

Now we can get the list of fruits for a specific season and a specific country, but sometimes we need to pass in multiple two or more seasons and get the results for any fruit from the the provided seasons, to do this we will need to pass a list of parameters we’ll get to how to do that in postman in a moment but first lets handle the list in our server, to receive a list we need to use parameters.getAll and then we will use forEach to loop on every item and add it to our Seasons list.

we’ll do the same for our list of countries expect that we won’t use forEach as we won’t need it because countries is a list of strings unlike the list of seasons.

    get("/fruits/{sort_by?}/{sort_direction?}/{season[]?}/{country[]?}") {
        // read the sort_by from the parameters and assign it to sortBy variable
        val sortBy = when (call.parameters["sort_by"] ?: "name") {
            "name" -> Fruit::name
            "id" -> Fruit::id
            // if the parameter sent by client does not match any values from our model return a bad request
            else -> return@get call.respond(
                HttpStatusCode.BadRequest,
                SimpleResponse(success = false, message = "invalid parameter for sort_by")
            )
        }

        val sortDirection = when (call.parameters["sort_direction"] ?: "asc") {
            "dec" -> -1
            "asc" -> 1
            else -> return@get call.respond(
                HttpStatusCode.BadRequest,
                SimpleResponse(success = false, message = "invalid parameter for sort_direction")
            )
        }
        val seasons = mutableListOf<Fruit.Season>()
        call.parameters.getAll("season")?.forEach { name ->
            println("item name: $name")
            // add an item to the list of seasons
            seasons.add(
                when (name) {
                    "summer" -> Fruit.Season.Summer
                    "winter" -> Fruit.Season.Winter
                    "autumn" -> Fruit.Season.Autumn
                    "spring" -> Fruit.Season.Spring
                    else -> return@get call.respond(
                        HttpStatusCode.BadRequest,
                        SimpleResponse(success = false, message = "invalid parameter $name for season")
                    )
                }
            )
        }

        // list of countries
        val countries = call.parameters.getAll("country")

        // get fruits from the database
        call.respond(
            HttpStatusCode.OK,
            getFruits(sortField = sortBy, sortDirection = sortDirection, seasons = seasons, countries = countries)
        )
    }

now we need to modify the getFruits function, first we change the function to take a list of fruits and a list of strings, then we will change the check to also be an EMPTY_BSON if the list is empty last thing we will replace the contains keyword with ‘in’ this will check if any fruit matches with any value from the list of countries/seasons

suspend fun getFruits(
    sortField: KProperty1<Fruit, String>,
    sortDirection: Int = 1,
    seasons: List<Fruit.Season>? = null,
    countries: List<String>?
): List<Fruit> {
    // if season is null set it as EMPTY_BSON witch means we won't filter the results
    val seasonFilters = if (seasons.isNullOrEmpty()) EMPTY_BSON else Fruit::season `in` seasons
    val countryFilter = if (countries.isNullOrEmpty()) EMPTY_BSON else Fruit::countries `in` countries
    return if (sortDirection < 0) {
        fruits.find(and(seasonFilters, countryFilter)).descendingSort(sortField).toList()
    } else {
        fruits.find(and(seasonFilters, countryFilter)).ascendingSort(sortField).toList()
    }
}

Now lets try adding multiple seasons, we can do that in post man by adding the same parameter with different values as you can see in the screenshot below.

Searching the database

to search for a fruit in our legendary database we can’t use the eq function as that would return only fruits that match exactly the keyword, what we can use instead is the regex function this function.

to our getFruits function we add the following as a third argument to our and function: Fruit::name regex query where query is our search query 🙂

suspend fun getFruits(
    sortField: KProperty1<Fruit, String>,
    sortDirection: Int = 1,
    seasons: List<Fruit.Season>? = null,
    countries: List<String>?,
    query: String?
): List<Fruit> {
    // if season is null set it as EMPTY_BSON witch means we won't filter the results
    val seasonFilters = if (seasons.isNullOrEmpty()) EMPTY_BSON else Fruit::season `in` seasons
    val countryFilter = if (countries.isNullOrEmpty()) EMPTY_BSON else Fruit::countries `in` countries
    // search BSON 
    val searchQuery = if (query.isNullOrEmpty()) EMPTY_BSON else Fruit::name regex query
    return if (sortDirection < 0) {
        fruits.find(and(seasonFilters, countryFilter, searchQuery)).descendingSort(sortField).toList()
    } else {
        fruits.find(and(seasonFilters, countryFilter, searchQuery)).ascendingSort(sortField).toList()
    }
}

and off course we add the search query to our end point and pass it to the getFruits function

// search query
val query = call.parameters["query"]

// get fruits from the database
call.respond( HttpStatusCode.OK,getFruits(sortField = sortBy, sortDirection = sortDirection, seasons = seasons, countries = countries, query = query)

Now let’s try to search

I searched for “ap” but Apple didn’t come up in the results 🙁 !, this is because the search is case sensitive to make the search case insisitive we need to pass an operator to the regex function this operator is “i” we can pass it like this as a string or use the extension method provided by Kmongo and transform a Pattern from the java.util.regex to a string this way we won’t have to remember the operators as a string.

// 1st way    
// this veriosn of the regex function requires that we pass the field name as a string that's why we use .name on Fruit::name
    val searchQuery = if (query.isNullOrEmpty()) EMPTY_BSON else regex(Fruit::name.name,query,"i") 

// 2nd way
    val searchOperator = PatternUtil.getOptionsAsString(Pattern.compile("",Pattern.CASE_INSENSITIVE))
    // this veriosn of the regex function requires that we pass the field name as a string that's why we use .name on Fruit::name
    val searchQuery = if (query.isNullOrEmpty()) EMPTY_BSON else regex(Fruit::name.name,query,searchOperator)

Now we search again for “ap” and Apple shows up!

As always the source code is available on Github

Extra – Filters list

  • or – Creates a filter that preforms a logical OR of the provided list of filters. (contrary to and only one of the filters has to meet the condition)
  • ne – Creates a filter that matches all documents where the value of the field name does not equal the specified value.
  • lt – Creates a filter that matches all documents where the value of the given property is less than the specified value.
  • gt – Creates a filter that matches all documents where the value of the given property is greater than the specified value.
  • lte – Creates a filter that matches all documents where the value of the given property is less than or equal to the specified value.
  • gte – Creates a filter that matches all documents where the value of the given property is greater than or equal to the specified value.
  • nin – Creates a filter that matches all documents where the value of a property does not equal any of the specified values or does not exist.
5 1 vote
Article Rating
Subscribe
Notify of
guest

1 Comment
Oldest
Newest Most Voted
Inline Feedbacks
View all comments

[…] lesson 3 we took a look at how to save and perform different queries on our MongoDB now that we have decent […]