Recently I had to use URL request with query parameters, which were based on FIQL syntax. I’ve started with simple string concatenation, but then it began to grow and became less readable. To solve that I’ve decided to write tiny, yet useful custom DSL – and it is not that hard as it sounds.
What is a DSL?
DSL stands for a domain-specific language. It is used to express given data or behaviour in a standardized way. Most likely you already used one, even if you were not aware of it – for example, while using regular expressions or spreadsheet.
In Swift world, perhaps most popular are DSL libraries that are wrapping up the use of Autolayout, as they allow to express constraints in a more understandable way.
How to create your own?
It is not that hard. Firstly you need to think about how you would like to use it – as in the first place it should be easy and convenient to use for you. That includes defining keywords and structure of your language.
Secondly, you need to “translate” that to language which compiler will understand.
Last part is to actually use it.
That’s the theory, let’s take a look into a real-life example.
Making it work
For starters, let’s take a look at how the query we want to express looks like:
query=(object1.field1==value1;object1.field2!=value2),object2.field=value3&sort=field,asc;field2,desc&offset=0&limit=10&showHidden=true
So, not very complicated yet not really convenient to read
or modify.
Let’s break it down to smaller parts:
- query – conditions that will filter out results.
- sort – sorting of results, multiple criteria supported.
- offset & limit – support for results pagination
- showHidden – some custom flag or parameter that will be submitted to endpoint.
My idea would be to allow to write such request in the chain, don’t care about ordering of keywords, add some defaults if possible – simply make it easier and safer to use.
offset & limit
Like the first item, we will take the middle one, as it will be the simplest.
Let’s define the needed variables:
struct Builder { private var offset = 0 private var limit = 25 }
We already defined two params, with default values. Those are private, so we need to add an interface to alter them.
func limit(_ newLimit: Int) -> Self { var newSelf = self newSelf.limit = newLimit return newSelf } func offset(_ newOffset: Int) -> Self { var newSelf = self newSelf.offset = newOffset return newSelf }
Note that function is not mutating – that will allow making even nicer chains.
sort
Since our sort can be capable of sorting by multiple criteria, it will be a bit more complicated than just assigning fields and order. Lets introduce some helper type:
extension Builder { enum Sort { case asc(String), desc(String) } }
and in Builder itself:
struct Builder { private var offset = 0 private var limit = 25 private var sort: [Sort] = [] }
Then we still need to add a method to actually add a sort to Builder – it’s up to you if it will append to an internal array or override it completely.
In every case finally, we will need to express it as a string, so let’s add a computed var to Sort and array of Sort elements:
extension Builder.Sort { var toString: String { switch self { case .asc(let field): return field + ",asc" case .desc(let field): return field + ",desc" } } } extension Array where Element == Builder.Sort { var toString: Element { return map { $0.toString }.joined(separator: ";") } }
custom param
We can deal with it in different ways – a most basic example would be dictionary [String: String]. More solid solution would require one more simple helper type, like:
extension Builder { struct CustomParam { let name: String let value: String var toString: String { return name + "=" + value } } }
There is one benefit of such struct – you can add as many initializers as you want and inside initializer, you can perform a conversion from specific type to string. For example:
init(name: String, value: Bool) { self.name = name self.value = value ? "true" : "false" }
query
That will be the most complex item on our list. The idea is to express bool logic, but with support of nested conditions, like
(A && B) || C
For our simplified example we will handle the following cases:
- equal
- not equal
- logical AND
- logical OR
That will be enough to give you an idea of how to extend this for your needs.
So, how we can handle it? I bet you guessed it – we would need some helper type.
We will make use of indirect enum feature, which allows to nest enum inside of its own case.
extension Builder { indirect enum QueryItem { case equals(String, String) case notEquals(String, String) case allOfConditions(QueryItem) case anyOfConditions(QueryItem) } }
Then we need to express all of those items as a string, so let’s add a method:
var toString: String { switch self { case .equals(let field, let value): return field + "==" + value case .notEquals(let field, let value): return field + "!=" + value case .allOfConditions(let conditions): return "(" + conditions.map { $0.toString }.joined(separator: ";") + ")" case .anyOfConditions(let conditions): return "(" + conditions.map { $0.toString }.joined(separator: ",") + ")" } }
What are we missing?
Binding it all together 😉
Simplest way to get it all together will be simple string concat – however, the production-ready solution might require things like escaping certain characters or applying, for example, base64 encoding. We already touched different techniques above, so let’s go the simple way this last time:
extension Builder { var toString: String { var parts: [String] = [] parts.append("limit=\(limit)") parts.append("offset=\(offset)") parts.append("sort=\(sort.toString)") parts.append(contentsOf: customParams.map { $0.toString }) if let query = query { parts.append("query=\(query.toString)") } return parts.joined(separator: "&") } }
Finally… what we are doing actually?
Let’s recall the first version of the query we had:
query=(object1.field1==value1;object1.field2!=value2),object2.field=value3&sort=field,asc;field2,desc&offset=0&limit=10&showHidden=true
How it looks now?
Builder().query( .anyOfConditions([ .allOfConditions([ .equals("object1.field1", "value1"), .notEquals("object1.field2", "value") ]), .equals("object2.field", "value3") ])) .sort([.asc("field"), .desc("field2")]) .customParam(.init(name: "showHidden", value: true)) .limit(10)
Far longer, but what are benefits? Most important one – improved type safety.
Not perfect yet – field names could be constrained bit more, but with our DSL we won’t end with something like:
limit=Optional(10)
Additionally, our conditions are expressed in a structured way – the logic of merging that into one query will be implemented once and it can be easily unit tested.
To sum up
In a couple of minutes, we designed & implemented custom language for expressing request queries. While it might be too much work for a single one, second and every next request will benefit from it. We also added type safety in place of plain string concatenation & interpolation.
With help from such micro-frameworks, you can make your code less error-prone, type-safe & reusable, all done in a minimal amount of time. Now you don’t have any excuse to implement a similar pattern in your project.