Skip to content

Instantly share code, notes, and snippets.

@androidfred
Last active February 27, 2021 01:26
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save androidfred/54e2e5ea9cd9b4dd96f5c9728f08e17a to your computer and use it in GitHub Desktop.
Save androidfred/54e2e5ea9cd9b4dd96f5c9728f08e17a to your computer and use it in GitHub Desktop.
Kotlin DSLs

Kotlin in Action: DSLs

General purpose programming language

  • imperative (sequence of stateful, ordered steps that describe how to do something)
  • wide scope
  • Java, Bash etc
  • potential for "runtime errors"

DSL (Domain Specific Language)

  • declarative (describes what something is, not how to do it)
  • deliberately limited scope
  • HTML, CSS, SQL, Maven or Gradle build files etc
  • reduced potential for "runtime errors" - invalid structures can be disallowed

"External" and "Internal" DSLs

These seem to be terms made up by the authors.

"External" DSL

Relative term. SQL is a DSL, but from the perspective of eg Kotlin, it is an "external" DSL as it's not, well, Kotlin, and thus has to be represented as String.

interface AccountDao {
    @SqlQuery("""
    SELECT
      account_id,
      user_id,
      status,
      created_timestamp,
      modified_timestamp
    FROM
      account
    WHERE
      user_id = :userId
    """)
    @RegisterRowMapper(AccountRowMapper::class)
    @Throws(JdbiException::class)
    fun get(userId: UserId): Account?
}

The IDE can still help out with syntax and indentation (either out of the box or using plugins) but it would be better if SQL wasn't represented as String.

"Internal" DSL

Again, relative term; SQL is internal to SQL

SELECT
  account_id,
  user_id,
  status,
  created_timestamp,
  modified_timestamp
FROM
  account
WHERE
  user_id = 123

But while DSL is an external DSL to Kotlin, it can be turned into an internal one! Eg

class AccountDao {
    fun get(userId: UserId) : Account? {
        //this is actual QueryDSL, a library that
        //turns SQL into an internal DSL for JVM
        queryFactory
          .selectFrom(account)
          .where(
            account
              .userId
              .eq(userId))
          .fetch() //List<Account>
          .findFirst() //Account?
    }
}

Note: the whole get function is just regular Kotlin!

It's not a String, it doesn't use any weird special syntax for replacing :userId that is neither SQL nor Kotlin.

The Kotlin compiler is leveraged to disallow invalid queries like

class AccountDao {
    fun get(userId: UserId) : Account? {
        queryFactory
          .selectFrom(account)
          .where(
            person
              .userId
              .eq(userId))
          .fetch() //List<Account>
          //^^^^ doesn't compile, types don't line up
    }
}

Importantly, while it's just regular Kotlin, it's also very close to regular SQL. So QueryDSL is an excellent example of an internalized DSL that respects both the target and the host languages.

Another very similar library is JOOQ, which looks more or less the same but as far I understand unlike QueryDSL is paid.

create
  .select(BOOK.TITLE)
  .from(BOOK)
  .where(BOOK.PUBLISHED_IN.eq(2011))
  .orderBy(BOOK.TITLE)

Techniques used

The book goes through a bunch of techniques used for achieving "clean" syntax DSLs:

Technique: Extension function

How to do it

For overachievers

Regular syntax

StringUtil.capitalized(s);

"Clean" syntax

s.capitalized();

Comment

Agree, that's a method that really should be on String and a good use of an extension function.

Technique: Infix Call

How to do it

For overachievers

Regular syntax

1.to("one")

"Clean" syntax

1 to "one"

Comment

Disagree. While 1 to "one" is actually Kotlin, to me it reads funny, which at least to me kind of defeats the purpose of the internal DSL reading like regular vanilla syntax in that language.

Technique: Operator overloading

How to do it

For overachievers

Regular syntax

set.add(2)

"Clean" syntax

set += 2

Comment

Disagree. Again, it doesn't read like Kotlin and it's not at all intuitively clear what += would do to a set.

Technique: Convention for the map.get() method

How to do it

For overachievers

Regular syntax

someMap.get("foo")

"Clean" syntax

someMap["foo"]

Comment

Disagree. For one, ["foo"] kind of looks like a JSON array with single item "foo". Or is it a boolean for whether the map contains the key "foo", or maybe whether the map contains the value "foo". Or is it a get? Again, if it is, does it get the entry or the value?

Technique: Lambda outside of parameters

How to do it

For overachievers

Regular syntax

// fun getUser(id: UserId) : Either<Failure, User>

getUser(userId)
    .fold({ failure ->
        when (failure) {
          DB_ERROR -> Response.status(500)
          NOT_FOUND -> Response.status(404)
        }
    }, { user ->
        Response.success(user)
    })

"Clean" syntax

// fun getUser(id: UserId) : Either<Failure, User>

getUser(userId)
    .fold({
        when (it) {
          DB_ERROR -> Response.status(500)
          NOT_FOUND -> Response.status(404)
        }
    }, {
        Response.success(it)
    })

Comment

Disagree. In nested such statements, it's not clear which it it refers to. The compiler does prevent unintended use of the wrong it at the wrong level of nesting IF the types are different, but it (pun intended) is a source of errors when the types are the same and the wrong thing is referred to at the wrong level.

Technique: Lambda with a receiver

How to do it

For overachievers

Regular syntax

sb.append("yes")
sb.append("no")

"Clean" syntax

with (sb) {
    append("yes")
    append("no")
}

Comment

Can't think of anything to say about this one.

Abuse

As commented, while many or all of these techniques can be used, they also lend themselves to abuse. In the book there is an example of Exposed, an internal DSL tool that competes with QueryDSL and JOOQ

val result = (Country join Customer).select { Country.name eq "USA" }
result.foreach { println(it[Customer.name]) }

It somehow manages to not only use a hodgepodge of (), {} AND [], it also doesn't read as neither Kotlin nor SQL.

The book haz the explains

val result = (Country join Customer)
                .select { Country.name eq "USA" } //Corresponds to this SQL code: WHERE country = "USA"
result.foreach { println(it[Customer.name]) }

I have no fucking idea what that does or how it does it. (OK, after a brief think, actually I do have an idea what it does, but it doesn't feel intuitive or idiomatic)

Conclusions

When done well, internal DSLs can be incredibly useful and valuable, increasing clarity and performance, and reducing bugs. When done poorly, they're worse than just biting the bullet and sticking to eg representing SQL as String.

The ability to create internal DSLs isn't unique to Kotlin- Java and countless other languages already have countless internal DSL tools. It's not even any more or less easy or hard to create internal DSLs in Kotlin compared to any other language.

The overachievers amongst you who do look into the specifics of the techniques used will find that they are complex and that internal DSLs require non-trivial effort to create and maintain. Eg, writing an internal DSL that supports trivial SQL SELECT FROM WHERE statements may not seem like that that much work, but once you take into account more advanced queries it quickly becomes a lot of work. And SQL isn't even really that big of a domain!

So internal DSL tools are projects in their own right, and it's unlikely that you will find yourself creating ad hoc internal DSLs in Kotlin, just like in any other language. The lowest hanging fruit for the common use cases has already been picked in the form of widely used, well tested and actively maintained tools that are highly likely to be much better than anything you can come up with, and are certain to require less effort. (just pull in the dependency and start using it)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment