Hacker News new | ask | show | jobs
by Capricorn2481 21 days ago
My experience with Pathom, and other graph query libraries, is it feels like a deliberately confusing way to reason about a program. I'd like to know your thoughts on it.

From what I hear, the main draw is separating what you want from how you get it, so your calling code can just focus on what it needs. But you can use regular functions to do that. What libraries like Pathom do is leave it open to the caller what shape of data they need.

But I think letting the caller do subtle query changes that can completely change which resolvers are triggered and how something is fetched is kinda leaky. How do you write the perfect resolver for all situations? How do you keep them from accidentally exploding their fetches? Is it not better to have things be explicit through function calls instead of chasing down disjointed call graphs?

2 comments

You can use regular functions, but there are several things you lose:

- intermediate keys are not recalculated if they're used across different resolvers. This means you basically never need to manage caches of precomputed results. So if you're calling `my-func` on `input-a` everywhere, you don't need to do all the ceremony of computing it once, storing it somewhere, and then passing it around to everyone that needs it. It's all just handled automatically. Code simplifies greatly

- It's much easier to "inject" lower-level steps b/c resolvers are essentially declaring an interface. If you suddenly don't like your interface and want a new interface, then you make a new interface and a bridging resolver. Refactoring is much easier. If you want to introduce an entirely new input format that usually just involves adding a single new resolver that outputs the inputs to your system (at whatever part of the pipeline you want). While with a pipeline of function calls it's generally more messy. It hard to make a generalization here b/c it depends on how your functions are organized.

- With the async engine you can automatically resolve branches concurrently without having to manage or think about it. You get a lot less stalls in the code.

I haven't really hit an "exploding their fetches" scenario personally. Things like optional inputs and resolvers that rely on precedence rules are generally a bit of a code smell and are usually points where I start to think about how to reorganize my code

> intermediate keys are not recalculated if they're used across different resolvers. This means you basically never need to manage caches of precomputed results. So if you're calling `my-func` on `input-a` everywhere, you don't need to do all the ceremony of computing it once, storing it somewhere, and then passing it around to everyone that needs it. It's all just handled automatically. Code simplifies greatly

I think what I'm suggesting is, if you can, avoiding intermediate keys at all can be helpful for performance, and graph querying encourages people to break their queries into small, atomic units that can fire in any order. Which is good for composability, but you don't want to run multiple queries if you don't have to. Using functions encourages writing what happens explicitly.

I'm coming at that not as someone who uses graph querying a lot, but someone who has worked on code bases where a single api call was dozens of DB calls. If people do that with regular function convenience, I imagine it happens even more often with libraries like Pathom, but that is speculation.

> It's much easier to "inject" lower-level steps b/c resolvers are essentially declaring an interface. If you suddenly don't like your interface and want a new interface, then you make a new interface and a bridging resolver

Is that exceptionally harder to do with regular functions though? I feel the same way about this as I do above. It sounds like this is only useful when your data topology is unknown and you can't be sure of the access pattern to begin with. I'm used to codebases where access patterns need to be documented and flexibility is not a huge concern. We just have repository interfaces, and we substitute the ones that we need.

I'll be honest, I work in a very different area (scientific computing) so my code is a lot more exploratory and I don't ever deal with DB access for instance. If you have a very stable interface and clear objectives then coupling isn't really a concern b/c there won't be anything to refactor and extend.

> avoiding intermediate keys at all can be helpful for performance, and graph querying encourages people to break their queries into small, atomic units that can fire in any order

EDIT: Reading the other comments, I realize here query is a DB query and not a EQL query.. so nevermind :)

I'm a bit fuzzy on what you're saying, but I think you may be misunderstanding an aspect (I could be wrong here). You typically have one complex top-level query and the engine builds the sequence/graph of resolvers that need to be run to derive the requested query. In that graph key values can be reused and branches can be run in parallel. You don't run a series of small queries and manually build up anything.

In my limited experience the order in which the resolvers are run is pretty clear (unless they're independent branches of the graph being run concurrently) and if you have a non-branching pipeline there isn't really any incentive to break it up. From a performance perspective I'm guessing you mean in terms of DB access? Because calling a series of functions or a series of resolvers is going to be quite similar performance wise - though you have some engine and destructuring overhead (can be significant in tight loop situations).

> Is that exceptionally harder to do with regular functions though?

It's hard to make a general statement here b/c it really depends on how you've set up your functions. But yes, generally if you are just playing with functions it can be harder to plug in a different "backend" or step in the middle unless you've somehow planned for it. Is it very hard? Generally not super difficult - but you generally need to refactor code to make it happen and explicitly handle the branching logic - so the code usually gets uglier

If you want to do a mock or try injecting some step, with resolvers you can do that without touching your code

> Reading the other comments, I realize here query is a DB query and not a EQL query.. so nevermind :)

I have been using them interchangeably and it's confusing. I am talking about how differing queries to the Pathom environment may trigger different resolvers, but the resolvers themselves also have DB queries.

In Pathom, as far as I have seen, their query planner will try to fulfill the requested keys with the least amount of resolvers. That means if you have the following resolvers, each their own DB query

- GetEmployee

- GetCompany

- GetEmployeesAndTheirCompanies

Then querying Pathom for a users general information + their company data should only trigger the third resolver, preventing a redundant DB fetch from happening. So as your schema evolves and new entities emerge, when you find your routes do not have optimal resolvers, you can try to make a new one that fulfills a previously unexpected combination of keys.

But that, to me, feels like undoing the things that make graphs appealing. Instead of just querying whatever you want, you now have to remember if the resolvers you've written up to this point can meet the query efficiently, or if it's an non-optimal combination of resolvers. That feels kinda leaky to me, and I'd rather just explicitly code the flow for each route than write Pathom queries and hope the key combination is performant enough. Caching absolutely helps, but doesn't eliminate this.

I also don't like that your only tool to guide which resolver is chosen is just priority. You don't know which two resolvers might be competing against each other, so giving any one resolver a single number for its priority feels very wrong. I can't guarantee how it shakes out without tracing the wrong code. When I am writing explicit procedural code, I do have to write a lot more, but I can just go to definition and see where everything is being used.

I do think this would be less important if your resolvers aren't particularly expensive, or if they are in memory DB calls.

> It's hard to make a general statement here b/c it really depends on how you've set up your functions. But yes, generally if you are just playing with functions it can be harder to plug in a different "backend" or step in the middle unless you've somehow planned for it. Is it very hard? Generally not super difficult - but you generally need to refactor code to make it happen and explicitly handle the branching logic - so the code usually gets uglier

I think I see what you're saying. You compared resolvers to an interface. I would use an interface in procedural code, like for a repository that gets users, and I can plug in a different implementation if I want to replace it. But if I wanted to change the interface itself, that would require refactoring code. You're saying this would be as simple as making the resolver in Pathom, and it can plug in anywhere now.

great example.

Some of this is out of my bailiwick, but on a high level I agree with you. I think your intuition is right. If you have behavior that's dependent on priority, this is a code-smell. It feels like you're just sort of #yolo'ing and hoping the right resolver is called. So far.. In these situations I usually pause and reconsider my architecture. There are probably several solutions here.

(Do bear in mind that I'm still learning the Pathom kungfu here, so I can't guarantee these are the best solutions..)

1.

So in your example the first step in isolating the behavior would be to re-think of it as three keys

- ::employee-ID

- ::company-ID

- ::employee-company-id-pair

and make more resolvers

- ::employee-ID -> ::employee

- ::company-ID -> ::company

- ::employee-ID + ::company-ID -> ::employee-company-ID-pair

- ::employee-company-ID-pair -> ::employee-company-pair

You can then just request an ::employee-company-pair and it should be disambigious. The problem is that you've now have a dense pair that doesn't hook back up with the rest of your resolvers. But this can be addressed with ...

2.

isolating behavior using "nested inputs/outputs". They allow you to go from a soup of keys to a narrow subset

Again:

- ::employee-ID

- ::company-ID

- ::employee-company-id-pair

first you can just have the original three 1-to-1 resolvers

- ::employee-ID -> ::employee

- ::company-ID -> ::company

- ::employee-company-ID-pair -> ::employee + ::company

At this point, as you illustrated, you have a bit of a priority issue. With a ::employee-ID and ::company-ID keys it's unclear which path is taken.

The trick is to now use nested input to disambiguate things.

You make a resolver that returns the results wrapped in a key (nested output):

- ::employee-ID + ::company-ID -> {::packed-request [::employee-company-ID-pair]}

The "consumer" resolver that only wants that efficient db call has on input a ::packed-request and just "unpacks" the request using nested inputs. Furthermore on input it will directly requests {::packed-request [::employee ::company]} and the engine handles the ::employee-company-ID-pair -> ::employee + ::company conversion. This nested input scope (ie. the inside of ::packed-request) doesn't have ::employee-ID and ::company-ID keys, so the request is always unambiguous.

The Pathom docs could be a bit more clear on this. They just show the basics and unfortunately don't walk through these tricks. But you can be explicit about both input and output map shapes and the engine uses these to do conversions. This allows you to narrow the set of inputs. So here one resolver outputs a {:packed-request [::employee-company-ID-pair]} and another takes a {::packed-request [::employee ::company]} - and the conversion is implicit. The engine finds only one resolver that returns a ::packed-request and it sees that it internally it will have a ::employee-company-ID-pair key. It then looks for a path from ::employee-company-ID-pair to the requested ::employee + ::company pair and it finds the corresponding resolver(s). Sometimes you need to forward other keys into this inner context, but you just provide them in parallel to the ::employee-company-ID-pair - it's all explicit.

I'll admit.. this looks weird. As I said elsewhere.. it's a real paradigm shift in how to think about and organize code. But I find after some adjustment it's actually worked really nicely for me so far.

This is an interesting way to tackle it, but in my scenario, the company id is data from the users table. So without fetching the user first, you don't know what their company ID is.

If we don't have the users data, we can't pass the company id with the user ID. This is not a problem if you are doing an explicit query because you can fetch user + company data at the same time. But if you're using the three resolvers above and your only way to guarantee the path chosen is to already have the company id, that won't be possible.

Here's another thing I ran into with Pathom. Adding keys may make a previous desired path choice change. Let's say we have our three resolvers return the following.

- GetUser - provides :user/email

- GetCompany - provides :company/phone_number

- GetUserWithCompany - Provides :user/email :company/phone_number

I query the environment by giving it a user ID and asking for the phone number that belongs to that users company.

    (p.eql/process env
        {:users/id 16230}
            [:company/phone_number])
It pings the third resolver. That's great! It got the information I want in one node.

But now what if we also want the users email?

    (p.eql/process env
        {:users/id 16230}
            [:company/phone_number
             :users/email])
You would think this would just use the same resolver because it provides both of these keys. But it doesn't. It will call the other two resolvers.

The reason it does this in my example is I had made a bridge between user and company before I made the more efficient resolver that gets all of that data at once.

    (def user-customer-bridge
      (pbir/alias-resolver :users/customer_id :customers/id))
We needed this bridge before, otherwise we just had two separate user and company queries that couldn't connect at all. The bridge lets that data be joined. But when the bridge is still here, Pathom, for whatever reason, will get the users data first and use the cached result to get the rest of the data, instead of just using our new resolver that gets it all at once. The only solution here is either to set priorities on the resolvers, or to remember to remove the bridge when adding new resolvers.

You might think this is petty and arbitrary, and maybe it is. Maybe I am holding it wrong. But it is exactly the kind of thing I ran into just doing test scripts on my own with very simple schemas. Imagine working with 12 people and having hundreds of tables.

I suppose the nice thing about Pathom is if you really want to override the path, you can just fetch that stuff manually and then query the environment with the data you got manually.

> From what I hear, the main draw is separating what you want from how you get it, so your calling code can just focus on what it needs. But you can use regular functions to do that. What libraries like Pathom do is leave it open to the caller what shape of data they need.

hmmm... it would be interesting to try an approach where you make heavy use of memoization and then write your functions to take the the minimal set of inputs (e.g. just the primary key for a record). I'm not sure if that's exactly what you had in mind, but here's a strawman example:

  ;; instead of having a resolver with this input
  {:input [:person/age
           :person/name
           {:person/pet [:pet/species
                         :pet/n-legs]}]}
  
  ;; you could have this plain function which calls regular functions to get its
  ;; input, each of which only need a single entity ID for their input
  (defn get-person-stuff [db person-id]
    (let [age         (get-person-age db person-id)
          name        (get-person-name db person-id)
          pet-id      (get-person-pet db person-id)
          pet-species (get-pet-species db pet-id)
          pet-n-legs  (get-pet-n-legs db pet-id)]
      ...))
And you know, I think that would be workable, even though it feels more boilerplatey to me. It would still get you the main benefit of not having to keep track of all the data shapes that are needed by the functions you're calling etc. Some off-the-cuff thoughts:

- with this approach you have a single function for each attribute, so you don't have the situation with pathom/biff.graph where there are multiple resolvers that could be called to get a particular attribute. However note that you could always put an assertion in your codebase that ensures no two resolvers share the same output key, which would then also give you the ability to know exactly what resolvers are being called.

- my example above doesn't include optional inputs, so that's logic you'd also need to write into all your functions: don't fetch the pet data if the pet ID is nil, don't return anything if the person name is nil, etc.

- if you do all that with regular code instead of dependency injection, that does mean you have more code to test, and you have to either supply a test DB (and populate it with everything the functions you're calling need) or mock out the functions. With the dependency injection approach you get plain-old-pure-functions which helps keep your unit tests nice and dumb.

- I like the readability of being able to look at the input / output queries and know exactly what shape of data I'm dealing with.

- There might be performance issues with the memoized functions approach. Pathom and biff.graph both support batch resolvers for example, and I'm not sure if you could do the equivalent as cleanly with the functions approach. And Pathom of course has its additional query planning step which does... stuff.

Going back to your comment, some thoughts:

> But I think letting the caller do subtle query changes that can completely change which resolvers are triggered and how something is fetched is kinda leaky.

This is an area where you might like biff.graph more than Pathom. Since there's no query planning step, the way that biff.graph executes your queries should be fairly predictable. It's basically just doing a depth-first traversal of your query.

(My first bullet point above is relevant too--you can always restrict yourself to having only one resolver per attribute so there's no question of what resolver is getting used.)

> How do you write the perfect resolver for all situations? How do you keep them from accidentally exploding their fetches?

Typically you write resolvers with only one level of joins/nesting and then let the query engine do the rest. so e.g. instead of writing a resolver that returns something like `{:person/pet {:pet/id 1, :pet/toys [{:toy/id 2, ...}, ...]}}`, you would have one resolver that returns `{:person/pet {:pet/id 1}}` and then another resolver that takes a pet ID and returns `{:pet/toys [{:toy/id 2}, ...]}` etc.

So there is a trade-off here in that e.g. you may end up running multiple database queries even though you could've stuffed everything you need into a single database query. That is mitigated by batch resolvers at least so you don't get N+1 query problems.

I've never needed to do this myself yet, but if you do run into any places where the performance isn't good enough, you can always write those bits the regular way (e.g. have a resolver that does a more complex query and returns nested data and/or don't even use pathom/biff.graph for this one bit). i.e. optimize where needed but stick with the default in most places.

> Is it not better to have things be explicit through function calls instead of chasing down disjointed call graphs?

There are pros and cons I think. Sometimes you want to know how an input is being computed and sometimes you want to be able to understand some logic in isolation. In practice I've acclimated quite a bit to the graph structure; I feel like it does a nice job of helping you split your code into the right "chunks".

> Typically you write resolvers with only one level of joins/nesting and then let the query engine do the rest. so e.g. instead of writing a resolver that returns something like `{:person/pet {:pet/id 1, :pet/toys [{:toy/id 2, ...}, ...]}}`, you would have one resolver that returns `{:person/pet {:pet/id 1}}` and then another resolver that takes a pet ID and returns `{:pet/toys [{:toy/id 2}, ...]}` etc.

> So there is a trade-off here in that e.g. you may end up running multiple database queries even though you could've stuffed everything you need into a single database query. That is mitigated by batch resolvers at least so you don't get N+1 query problems

This is the crux of my issue, and batch resolvers don't solve all of it. Batch resolvers solve cases where you need multiple iterations of the same query with different inputs. But in your example, that's two different resolvers that were broken down into atomic units. From what I understand, batch resolvers don't help with that. You need to write a third resolver that can get the outputs of both.

And in that case, it would be nice to have a query planner that can, at the very least, see that a single query could be done with 1 resolver and not two.

yep, so if it's important for the application you're working on that you always run the minimum number of database queries possible, biff.graph isn't a good fit. Pathom's query planner might work as you've described; I'm not sure.
Lots of great thoughts

As for function memoization, I previously tried this workflow and after scratching my head about it, I think it's just not possible to make it scale properly (in the sense of making a library of resolvers/functions where you don't know how they'll be used exactly). The memoized function has no way to know how often it's called. It can be called 2 times, or 2000 times. So it's unclear how large its cache should be and there isn't a clear mechanism for when to flush the cache. I couldn't find a good mechanism to safely use it. In the Pathom model .. as far as I understand you just don't need to worry about that since the outputs are "cached" in the context of a query (or an inner input) and discarded when you're "out of context".

Since often you have many similar requests it can make sense to add a layer of memoization a the top level to remember the last request (cache of size 1) but otherwise it should scale okay. Though I'm sure it's not difficult to create pathological cases where it probably doesn't work and you end up recomputing stuff.

I think caching is an unresolved problem

You could always introduce an explicit caching context by doing something like `(binding [cache (atom {})] ...)` whenever you start using some functions like this. If you were trying to use this approach inside a library then you could wrap the public functions with that. Not sure if that would work for the way you were trying to do it.