Absinthe without Ecto

I’m currently struggling to figure out the best way to use Absinthe without it having direct knowledge of Ecto. Specifically with things like Absinthe.Relay.Connection.from_query.

My concern (and it could well be completely unfounded :)) is that interactions with the DB should be handled by my service layer, and that layer only.

Is this something that others have tackled? Or am I thinking too much about this?!

As I’m typing this, 2 things occur;

  1. Refactoring in Elixir is easy, so just crack on and change it later if it becomes as issue
  2. I could just create a paginate() function on the service that gets passed to from_query instead, so that Ecto.all etc is not used in the GrahphQL layer
1 Like

I’m using Absinthe right now, and my resolvers all look like…

defmodule MyApp.Web.MyResolver do

  def get(%{id: id}, info) do
    MyApp.MyContext.get_object(id)
  end

end

…without aliasing Repo or using Ecto at all. Is that what you mean?

2 Likes

The same here, my context’s query functions all take a host of arguments for what to return and what to refine on as well.

1 Like

Ultimately the most difficult part of the Absinthe / Phoenix 1.3 combo is that it requires your context functions to be compossible and lazy in a way that is not the default way of writing Ecto queries.

In some sense this issue would go away entirely if Elixir had something like Data Loader, which wouldn’t be an Absinthe specific thing but rather just a general way of getting information exposed by a context that permits lazy and batched evaluation. Absinthe would then just tie into that.

As it is, the only available lazy / batching mechanism available is Absinthe itself, so it can feel like it’s invading your contexts. I’m gonna argue however that it isn’t violating boundaries, it’s just giving you a mechanism you actually want in your contexts in order to make them more flexible.

Middleware provides a good way to extract out code from your resolvers that might be particularly reliant upon manipulating Absinthe specific constructs.

FWIW I am working on a Batching 2.0 API that will hopefully be something one would feel comfortable using in context functions without feeling like Absinthe is creeping into the underlayers of your application.

1 Like

Heh, I often do this horror of stuff (this is a short one compared to most!):

Huge!

I don’t have many monolithic functions, but this is one, mostly because it is a fairly direct post/rewrite of an older function in another language that is 20 years old…
And it would be larger if ecto was capable enough to support cross-schema joins… >.>

  def query_classes(selected \\ :processed, refine) do
    squery =
      from course in DB.Banner.SCBCRSE,
      join: section in DB.Banner.SSBSECT, on: section.ssbsect_subj_code == course.scbcrse_subj_code and section.ssbsect_crse_numb == course.scbcrse_crse_numb and section.ssbsect_ssts_code == "A",
      join: dept in DB.Banner.STVDEPT, on: dept.stvdept_code == course.scbcrse_dept_code

    squery =
      Enum.reduce(refine, squery, fn
        ({:pidm, true}, squery) ->
          join(squery, :inner, [course, section, dept],
            student_course in DB.Banner.SFRSTCR,
            student_course.sfrstcr_term_code == section.ssbsect_term_code and
            student_course.sfrstcr_crn == section.ssbsect_crn
          )
        ({:pidm, pidm}, squery) when is_integer(pidm) ->
          join(squery, :inner, [course, section, dept],
            student_course in DB.Banner.SFRSTCR,
            student_course.sfrstcr_pidm == ^pidm and
            student_course.sfrstcr_term_code == section.ssbsect_term_code and
            student_course.sfrstcr_crn == section.ssbsect_crn
          )
        ({:pidm, pidms}, squery) when is_list(pidms) ->
          join(squery, :inner, [course, section, dept],
            student_course in DB.Banner.SFRSTCR,
            student_course.sfrstcr_pidm in ^pidms and
            student_course.sfrstcr_term_code == section.ssbsect_term_code and
            student_course.sfrstcr_crn == section.ssbsect_crn
          )
        ({:registered, true}, squery) ->
          where(squery, [course, section, dept, student_course], student_course.sfrstcr_rsts_code in ["RA", "RE", "RW"])
        ({:withdrawn, true}, squery) ->
          where(squery, [course, section, dept, student_course], student_course.sfrstcr_rsts_code == "WD")
        ({:department, dept_code}, squery) when is_binary(dept_code) ->
          where(squery, [course, section, dept], course.scbcrse_dept_code == ^dept_code)
        ({:department, dept_code}, squery) when is_list(dept_code) ->
          where(squery, [course, section, dept], course.scbcrse_dept_code in ^dept_code)
        ({:subject, subject_code}, squery) when is_binary(subject_code) ->
          where(squery, [course, section, dept], section.ssbsect_subj_code == ^subject_code)
        ({:subject, subject_code}, squery) when is_list(subject_code) ->
          where(squery, [course, section, dept], section.ssbsect_subj_code in ^subject_code)
        ({:course, course_number}, squery) when is_binary(course_number) ->
          where(squery, [course, section, dept], section.ssbsect_crse_numb == ^course_number)
        ({:course, course_number}, squery) when is_list(course_number) ->
          where(squery, [course, section, dept], section.ssbsect_crse_numb in ^course_number)
        ({semester, year}, squery) when semester in [:spring, :summer, :fall] and is_integer(year) and year>=1900 and year<=9999 -> squery # Handled below in `dyn`
        ({:course_group, %DB.Course.Group{}=course_group}, squery) ->
          squery = if(course_group.dept_codes, do: where(squery, [course, section, dept], course.scbcrse_dept_code in ^course_group.dept_codes), else: squery)
          squery = if(course_group.subject_codes, do: where(squery, [course, section, dept], section.ssbsect_subj_code in ^course_group.subject_codes), else: squery)
          squery = if(course_group.course_numbers, do: where(squery, [course, section, dept], section.ssbsect_crse_numb in ^course_group.course_numbers), else: squery)
          # TODO:  Test the term codes and such too once they are needed
          squery
      end)

    dyn =
      Enum.reduce(refine, false, fn
        ({semester, year}, dyn) when semester in [:spring, :summer, :fall] and is_integer(year) and year>=1900 and year<=9999 ->
          term_code =
            case semester do
              :spring -> "#{year}10"
              :summer -> "#{year}20"
              :fall -> "#{year}30"
            end
          case dyn do
            false -> dynamic([course, section, dept], section.ssbsect_term_code == ^term_code)
            dyn ->  dynamic([course, section, dept], ^dyn or section.ssbsect_term_code == ^term_code)
          end
        (_, dyn) -> dyn
      end)

    squery =
      case dyn do
        false -> squery
        dyn -> where(squery, ^dyn)
      end

    squery =
      case selected do
        :all -> squery
        :processed ->
          case Keyword.get(refine, :pidm) do
            v when v == true or is_list(v) ->
              select(squery, [course, section, dept, student_course], %{
                pidm: student_course.sfrstcr_pidm,
                department_code: course.scbcrse_dept_code,
                department_description: dept.stvdept_desc,
                crn: section.ssbsect_crn,
                subject: section.ssbsect_subj_code,
                course_number: section.ssbsect_crse_numb,
                section_number: section.ssbsect_seq_numb,
                title: fragment("coalesce(?, ?)", section.ssbsect_crse_title, course.scbcrse_title),
                section_begins: section.ssbsect_ptrm_start_date,
                section_ends: section.ssbsect_ptrm_end_date,
                registration_code: student_course.sfrstcr_rsts_code,
                effective_term: course.scbcrse_eff_term,
                _effective_term_rank: fragment("rank() OVER (PARTITION BY ?, ? ORDER BY ? DESC)", section.ssbsect_subj_code, section.ssbsect_crse_numb, course.scbcrse_eff_term),
              })
            _ ->
              select(squery, [course, section, dept], %{
                department_code: course.scbcrse_dept_code,
                department_description: dept.stvdept_desc,
                crn: section.ssbsect_crn,
                subject: section.ssbsect_subj_code,
                course_number: section.ssbsect_crse_numb,
                section_number: section.ssbsect_seq_numb,
                title: fragment("coalesce(?, ?)", section.ssbsect_crse_title, course.scbcrse_title),
                section_begins: section.ssbsect_ptrm_start_date,
                section_ends: section.ssbsect_ptrm_end_date,
                effective_term: course.scbcrse_eff_term,
                _effective_term_rank: fragment("rank() OVER (PARTITION BY ?, ? ORDER BY ? DESC)", section.ssbsect_subj_code, section.ssbsect_crse_numb, course.scbcrse_eff_term),
              })
          end
      end

    query =
      from s in subquery(squery),
      where: s._effective_term_rank == 1

    query
  end

But I can use it like:

# Get all classes of subject "blah" with all pidm information associated with that are fully registered (I.E. not Dropped) of a given term
Queries.Banner.query_classes([subject: "blah", pidm: true, registered: true] ++ [term])
# Get all registered (non-dropped) classes of a specific person (or persons)
Queries.Banner.query_classes(pidm: pidm, registered: true)
# Get low-level non-processed classes for a specific person(s) of registered classes:
Queries.Banner.query_classes(:all, pidm: pidm, registered: true)

This is one of the older ones, the more recent ones are a bit more clear and unified in comparison, but even larger (though with more helpers inside, which make them more clear).

(EDIT: Apparently Discourse fails on collapsible blocks that have code?)

Which happens to fit very well in to Absinthe. ^.^

Hmm, could Elixir support such a thing well? What is such a thing?

Thanks guys :slight_smile:

I think I was just getting myself tangled up by trying to enforce a complete separation between the GraphQL side of things and the main application code. I’d hoped that I could have the same code in the backend and then have different front entry points - GraphQL & REST - calling the same code, but returning things for Relay pagination is completly different for the pagination for REST for instance.

I’ve ended up having my services return either a Ecto.Multi or Ecto.Query depending if its a command or query, and then using those in the resolvers. That way the actual code is still in the ‘core’ and I just use Ecto.transaction() / Ecto.all() etc from the API entry points.

I’d rather that none of the API entry points had any knowledge of Ecto, but that’s something that can maybe happen further down the line :slight_smile:

This seems to be working well, and I don’t feel as ‘uncomfortable’ with it as I did when I started, at the end of the day it is all part of the same application really, so it may have been a bit of ‘premature optimization’ as well :wink: