Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce new experimental DSL for Postgrest Columns #761

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

jan-tennert
Copy link
Collaborator

@jan-tennert jan-tennert commented Oct 18, 2024

What kind of change does this PR introduce?

Feature.

What is the new behavior?

Introduces a new type-safe DSL for specifying columns when making a postgrest request. Example:

val columns = Columns {
    //Basic columns
    named("name", "id" withType "text") // name, id::text
    //Foreign columns
    foreign("actors") { // actors(name,total:count(),salary.avg())
        named("name")
        named(count() withAlias "total", "salary" withFunction avg())
    }
    foreign("studios" withAlias "studio") { // studio:studios(name)
        named("name")
    }
    //Or spread within the response
    foreign("studios") { // ...studios(studio_name:name)
        spread = true
        named("name" withAlias "studio_name")
        foreign("nested") {} // also possible
    }
    //JSON columns
    json("json_data", "key") // jsonData->key
    json("json_data", "list", "0", returnAsText = true) // jsonData->list->>0
}
supabase.from("movies").select(columns)

@jan-tennert jan-tennert self-assigned this Oct 18, 2024
Copy link

@github-advanced-security github-advanced-security bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detekt found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@jan-tennert jan-tennert added the enhancement New feature or request label Oct 19, 2024
Comment on lines +92 to +98
infix fun String.withFunction(name: String) = "$this.$name"

/**
* Casts a column to the given [type]
* @param type The type to cast the column to
*/
infix fun String.withType(type: String) = "$this::$type"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about the names here, maybe applyFunction or useFunction and castAs would be better

@grdsdev
Copy link
Contributor

grdsdev commented Oct 25, 2024

Hey Jan, that is an awesome feature.

I was thinking of improving this also on the Swift library, for Swift building a DSL as that is also possible, but I was thinking of another possibility, which is using Macro (I think that would be a custom annotation on Kotlin).

So we could have the columns definition also be the model returned by the request, such as:

@Selectable
struct Movie {
  let id: String
  let name: String

  @Foreign(table: "studios")
  let studio: Studio

  // Still need to think about how to express the other cases
}

Then on Swift we could use it as:

let movies: [Movie] = try await supabase.from("movies").select(Movie.self).execute.value

This was my initial thought, what do you think of it?

@jan-tennert
Copy link
Collaborator Author

jan-tennert commented Oct 25, 2024

That is also a great idea! Upon quick thinking, something like this could be done via KSP.
So similarly as with your example, you have a model which defines the relations and types:

@Selectable
@Serializable
data class Movie(
    val id: Int, //no ColumnName annotation means the property name will be used

    @ColumnName(name = "movie_title")
    val title: String, //"title" is basically the alias and "movie_title" is the actual column name

    @Foreign(table = "studios")
    val studio: Studio,

    @ApplyFunction(function = AggregateFunction.AVG)
    @ColumnName("column_name")
    val someAverage: Double, //apply "avg()" on "column_name" and use "someAverage" as the alias

    @JsonPath("json_data", "array", "0", "key", returnAsText = true) // jsonData->array->0->>key
    val jsonKey: String
)

And the compiler generates a MoveColumns property / object which you can use when selecting:

val movie: Movie = supabase.from("movies").select(MovieColumns).decodeSingle()

For casts, we could do some automatic casts, e.g. if the property type is a String, the column value gets cast to a text. For custom types, there could be something like a @ColumnTransformer(transformer = MyCustomTypeTransformer) annotation.

@grdsdev
Copy link
Contributor

grdsdev commented Oct 25, 2024

Yeah, you got it.

Would it be possible to call select with the Movie itself, instead of having another type MovieColumn?

@jan-tennert
Copy link
Collaborator Author

jan-tennert commented Oct 25, 2024

Well, KSP can only generate code. Maybe we can get the generated type via the declared Movie type, so that everything happens in the background

@jan-tennert jan-tennert marked this pull request as draft October 26, 2024 10:51
@jan-tennert
Copy link
Collaborator Author

I'm going to try out how good this works with KSP, making this PR a draft for now.

@jan-tennert
Copy link
Collaborator Author

jan-tennert commented Oct 27, 2024

@grdsdev So I think it works pretty well, but as I said the limitation of KSP (in comparison to e.g. a Compiler Plugin) is that it can only generate code (a Compiler Plugin basically way more complex and not really documented well, KSP is recommended). What I did now is to force the creation of a companion object, so that the KSP compiler generates an extension property:

@Selectable
@Serializable
data class Movie(
    val id: Int,

    @ColumnName(name = "movie_title")
    val title: String,

    @Foreign
    @ColumnName("studios")
    val studio: Studio,

    @ApplyFunction(function = ApplyFunction.AVG)
    @ColumnName("column_name")
    val someAverage: Double, 

    @ColumnName("json_data")
    @JsonPath("array", "0", "key", returnAsText = true)
    val jsonKey: String
) {
    companion object // Required, also forced at compile-time
}

Usage:

val movies: List<Movie> = supabase.from("movies").select(Movie.columns).decodeList()

What do you think?

@grdsdev
Copy link
Contributor

grdsdev commented Oct 28, 2024

Can KSP add an implementation of an interface to the type?

If so, you can create a Selectable interface that defines the companion object, and then make an overload of the select method that accepts a Selectable type.

In Swift would be:

protocol Selectable {
  static var columns: String { get }
}

@Selectable
struct Movie {
    // columns...
}

// Macro generated code:
extension Movie: Selectable {
    static var columns: String {
       """
        id,
        title,
        studio:studios(\(Studio.columns))
       """
    }

Then select overload would be:

func select<S: Selectable>(_ select: S.Type) {
    self.select(S.columns)
}

@jan-tennert
Copy link
Collaborator Author

jan-tennert commented Oct 28, 2024

No, in Kotlin you cannot implement an interface for an external type. I had another idea:

  • The Postgrest module provides an interface, something like a ColumnRegistry which has one method:
fun getColumnsFor(type: KType): String
  • The KSP compiler generates columns for annotated data classes, generates a SupabasePlugin which implements this interface and stores all the columns within.
  • Devs now only need to install this plugin once in the SupabaseClientBuilder:
val supabase = createSupabaseClient(url, key) {
    install(Postgrest)
    install(PostgrestColumnRegistry) //this plugin is generated by the KSP compiler
}

And for selecting, we just add a new generic variant for select:

supabase.from("movies").select<Movie>()

behind the scenes, Postgrest will get the columns via the installed registry plugin.
This would remove the requirement for a companion object and would only need a single line of additional code.

I don't think there is any way to completely make this process invisible (with KSP and without reflection, due to MP support).

@jan-tennert
Copy link
Collaborator Author

jan-tennert commented Oct 28, 2024

Alternatively, the KSP compiler could also generate an extension function for the Postgrest config (instead of a whole plugin):

install(Postgrest) {
    addSelectableTypes()
}

But the rest would be the same

@grdsdev
Copy link
Contributor

grdsdev commented Oct 28, 2024

extension function on Postgres config is better I think, not a fan of having to install a plugin for this

@jan-tennert
Copy link
Collaborator Author

Alright, going to implement and PR it, there we can discuss annotation names etc.

@jan-tennert
Copy link
Collaborator Author

An early draft can be seen in #769. I'm still not sure how we should handle spreading embedded resources.

@grdsdev
Copy link
Contributor

grdsdev commented Oct 31, 2024

Awesome @jan-tennert will review it. I think we can just mark spreading as unsupported for now, not sure how we could support it, in Swift I think is possible due the macro system being a bit more powerful, but I need to make a few tests first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request postgrest
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants