32 comments

[ 2.6 ms ] story [ 108 ms ] thread
There are a few great talks on YouTube by the creator of Pathom explaining the value of the graph: https://m.youtube.com/watch?v=IS3i3DTUnAI
I've been rewriting a codebase using Pathom resolvers and it has been extremely fun and has made me really reexamine how I organize code. Without being hyperbolic, it's really a new coding paradigm. You get some extreme decoupling and it allows the engine to automatically maximizes concurrency.
Yeah, it's a big eye-opener. I'd like to see if I can figure out an ergonomic way to do it in Python since I do a fair amount of work in that, and passing ORM objects around isn't great.
I have an old repo that explores the concept at bmritz/datajet. I’ve also toyed around with the idea of using type hints and type aliases in python as the “data key” (equivalent to :user/id). Would love to have something equivalent in python.
Thanks for mentioning datajet, I'll be taking a look at that for sure...
The name suggests it plots graphs related to incoming email.
> biff.graph is basically a lightweight version of Pathom. It implements only a subset of Pathom's functionality with the intention of being easier to understand.

I’ve heard of pathom but I’ve never actually dove in and tried it out. It sounds super neato though.

I have a bunch of microservice DB’s (that should really just be on DB, but I think we’re created in the peak of microservices hype). I def need a better way to explore the data. “Easier to understand” sounds sick and I think I’ll check it out this week!

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?

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 want to introduce an entirely new input format that usually just involves adding a single new resolver that outputs the inputs to your system. 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

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 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. If you have behavior that's dependent on priority, this is a code-smell and feels brittle. 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, but I can illustrate one

(Do bear in mind that I'm still learning the ropes here, so I can't guarantee this is the best solution..)

But you can isolate behavior using "nested inputs"

So in your example you can think of three keys

- :employee-id

- :company-id

- :employee-company-id-pair

And they go with three resolver

- :employee-id -> :employee

- :company-id -> :company

- :employee-company-id-pair -> :employee-company-pair

At this point, as you illustrate, you have a priority issue. But with nested input you can disambiguate things.

You have a resolver that packs a wrapper:

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

The "consumer" resolver that wants that efficient db call accepts a :packed-request and just "unpacks" the request using nested inputs. On input it has {:packed-request [::employee-company-pair]} and the engine handles the :employee-company-id-pair -> :employee-company-pair conversion. This nested input scope doesn't have :employee-id and :company-id keys, so the request is unambiguous.

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, than 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.

It doesn't seem petty at all. These are the fundamental primitives of how you want to decouple and organize code. You have to look at them through small examples.

In the first case, the situation looks largely the same. I mean you can either uses the same nested inputs strategy but have a special key that triggers the fat-query resolver. Something like :employed-user-id.

The other alternative is using nested outputs. You have the resolver returned a keyed bundle. Something like {:fat-request [:users/email :company/phone_number]}. The downstream resolver then consumers a :fat-request and unpacks it using nested inputs.

As for the second example. I'm a little confused on some of the specifics. Writing out the resolver mappings more explicitly.. I'm inferring this is what's going on:

- GetUser - :users/id -> :users/email :company/id

- GetCompany - :company/id -> :company/phone_number

- GetUserWithCompany - :users/id -> :users/email :company/phone_number (<- this is a shortcircuit bypassing :company/id)

From this I can see why the first request triggers number 3. You could of course make the third resolver return a bundled output which may simplify things.

The second request.. I get a bit confused here.

1. I'm a bit confused about the bridge. Maybe there's a typo? Or are you saying `customer` is some completely separate namespace that's interfering with the behavior? In the text you seem to imply you're aliasing company/id and user/id.. but that would be a bit crazy :)

2. You then say "and use the cached result to get the rest of the data". So you have cached resolvers and there is memory from previous requests?

Is it internally, after the first request, remembering the :company/id associated with this :users/id? So it triggers the first two resolvers instead of the third one (but why was the :company/phone_number and users/email not in the cache?)

> Imagine working with 12 people and having hundreds of tables.

Yeah, I think at this point in time there is no sense of best-practices or common programming patterns. From reading the docs and issues, I don't even get the sense Pathom's author has a good sense of how best to hook things up. So we're in the `goto` era of using Pathom and you can end up with a messy web of resolvers. Playing around with the system.. I'm left feeling like there is some emergent logic and ways to organize code. But maybe there are corner cases where it all breaks down and you start to miss imperative programming. My gut reaction is that if I see two separate paths on the same inputs/outputs .. then I'm immediately thinking - "can I redesign my system to avoid this?"

As a simple thought experiment. Say you want to inject stuff in to a pipeline (So some A -> B -> C -> D becomes A -> B -> ZZ -> D). Pathom's author suggests using the Priority attribute. https://pathom3.wsscode.com/docs/resolvers/#prioritization

But you have other alternatives...

- You can also add a dummy key :take-zz-branch. You have resolverC that takes :B and you have resolverZZ that takes :B and :take-zz-branch. Precedence rules .. should .. make it take the branch (unless it for some reason requires fewer inputs?).

- You can make resolverZZ output some dummy key :zz-was-run. If you request :zz-was-run then I think it should also take the branch? (or maybe it runs both branches).

Maybe there are other methods I've not considered. But at this point I'm not clear which method is best!

On local it's customer, but I renamed it Company for you and forgot to rename it on the bridge. In our example, it's just company.

When I say cached result I misspoke, I mean Pathom, rather than running the one resolver that gets everything, will first see the resolver that gets only the user and grab that. Then it will consider :user/email satisfied and look for other resolvers. It seems to not look for the resolver that solves all the requested data, and instead goes first come first serve.

> In the first case, the situation looks largely the same. I mean you can either uses the same nested inputs strategy but have a special key that triggers the fat-query resolver. Something like :employed-user-id.

> The other alternative is using nested outputs. You have the resolver returned a keyed bundle. Something like {:fat-request [:users/email :company/phone_number]}. The downstream resolver then consumers a :fat-request and unpacks it using nested inputs.

> You can make resolverZZ output some dummy key :zz-was-run. If you request :zz-was-run then I think it should also take the branch? (or maybe it runs both branches)

I think that these tricks can work sometimes, but you might be surprised what odd behavior can come up if you rely on this. In your pathom query, the order of your requested data matters.

    (pco/defresolver get-user [{:users/keys [id]}]
      {::pco/output [:users/id :users/email :users/customer_id :users/budget]}
      (println "get-user triggered")
      (-> (jdbc/execute-one! ds ["SELECT * FROM users WHERE id = ?" id])))
    
    (pco/defresolver get-customer [{:customers/keys [id]}]
      {::pco/output [:customers/id :customers/billing_number :customers/phone_number]}
      (println "get-customer triggered")
      (-> (jdbc/execute-one! ds ["SELECT * FROM customers WHERE id = ?" id])))

    (pco/defresolver get-user-with-customer [{:users/keys [id]}]
      {::pco/output [:users/id :users/email :users/customer_id :fat-key
                     :customers/id :customers/billing_number :customers/phone_number :fat-key]}
      (println "get-user-with-customer triggered")
      (let [user (jdbc/execute-one! ds ["SELECT * FROM users WHERE id = ?" id])
            customer (jdbc/execute-one! ds ["SELECT * FROM customers WHERE id = ?" (:users/customer_id user)])]
        (merge user {:customers/id (:customers/id customer)
                     :customers/billing_number (:customers/billing_number customer)
                     :customers/phone_number (:customers/phone_number customer)
                     :fat-key true})))

    (def user-customer-bridge
  (pbir/alias-resolver :users/customer_id :customers/id))
    
    ;; ---- test1
    (p.eql/process env
                   {:users/id 1000}
                   [:fat-key
                    :customers/phone_number
                    :users/email]) 
    
    ;; ---- get-user-with-customer triggered
 

    ;; ---- test2 
    (p.eql/process env
                   {:users/id 1000}
                   [:customers/phone_number
                    :fat-key
                    :users/email])
    
    ;; ---- get-user triggered, 
    ;; ---- get-user-with-customer triggered
I couldn't tell you why it shakes out this way. Something about the bridge and requesting `:customers/phone_number` first must be coercing Pathom to take the longer path to :customers/phone_number, even though the other resolver can get everything in one shot. This is the kind of subtle behavior that is unintuitive and could blow up production if you trigger the wrong resolver in the wrong hot path. So I don't see a reasonable argument for using Pathom in an application that needs reliability unless you make the resolv...
Okay - actual code is good :))

But I can't replicate the behavior. Here is the code:

https://github.com/kxygk/ednless/blob/master/pathom-prectest...

I didn't know what jdbc was so I just plugged in dummy values - but I think I'm matching your logic one2one

Let me know if you can tweak it to have the weird results trigger

At a high level I'd say a couple of things stand out.

- Your resolvers take in an id and return id.. That sets off a bunch of alarm bells for me. I wouldn't ever do that. There is no good reason to have that, even if the values are identical (if the values change then that's even more dangerous). I can't point to exactly what will go wrong, but you're exacerbating the problem of having multiple resolvers providing an input.

- The :fat-key isn't doing anything here. If you wanted to do the nested request, it'd be a solid way to guarantee the fat branch is always taken:

    (pco/defresolver get-user-with-customer
      [{:users/keys [id]}]
      {::pco/output [{:fat-pack [:users/id
                                 :users/email
                                 :users/customer_id
                                 :customers/id
                                 :customers/billing_number
                                 :customers/phone_number]}]}
      (println "get-user-with-customer triggered")
      {:fat-pack {:users/id                 66
                  :users/email              "66@66.com"
                  :users/customer_id        id
                  :customers/id             id
                  :customers/billing_number 666
                  :customers/phone_number   6666}})

    (def env
      (pci/register [get-user
                     get-customer
                     get-user-with-customer
                     user-customer-bridge]))

    (p.eql/process env
                   {:users/id 1000}
                   [{:fat-pack [:customers/phone_number
                                :users/email]}])
    ;;{:fat-pack {:customers/phone_number 6666, :users/email "66@66.com"}}
Here the EQL request unpacks it, but it can also be unpacked with nested inputs from a different resolver.

    (pco/defresolver fat-eater
      [{:keys [fat-pack]}]
      {::pco/input[{:fat-pack [:users/id
                               :users/email
                               :users/customer_id
                               :customers/id
                               :customers/billing_number
                               :customers/phone_number]}]
       ::pco/output [:response]}
      (println "get-user-with-customer triggered")
      {:response (str "yum, just ate: "
                      (:users/id fat-pack))})

    (def env
      (pci/register [get-user
                     get-customer
                     get-user-with-customer
                     user-customer-bridge
                     fat-eater]))

    (p.eql/process env
                   {:users/id 1000}
                   [:response])
    ;; {:response "yum, just ate: 66"}
> Your resolvers take in an id and return an id.. That sets off a bunch of alarm bells for me. I wouldn't ever do that. There is no good reason to have that, even if the values are identical (if the values change then that's even more dangerous). I can't point to exactly what will go wrong, but you're exacerbating the problem of having multiple resolvers providing an input. Worse yet, in this example you are guaranteed that they are all running. So where is it going to take the ID from..? I don't even know

You mean don't return :users/id when that's what was passed in? That makes sense, but I don't think I ran into issues with it.

> The :fat-key isn't doing anything here

For me it is. The absence of it means only one of the resolvers is run regardless of data order.

> This is very explicit and about equivalent to your original thought of "why not just have explicit imperative function calls"

I've been playing with this more today. My big thing is I want some good guidelines for writing resolvers to begin with so query time is not an exercise in auditing the whole codebase. Here are some guidelines I played around with, let me know what you think.

- When a resolver gets an entity and related data (like `get-user-with-customer`), try to pack related entities into their own nested collections. I.E. don't return `[:users/email :customer/name]`, return [:users/email {:users/customer [name]}`. This makes it less ambiguous, as putting the customer information flat with the other user data can complicate queries later on. Like if you select a user and their last order, what does `:customer/name` refer to? The customer of the user or the customer of the order?

- If a resolver is really expecting to operate on a users customer id, make that explicit in the input with `{:users/customer [id]}`.

- If you want a resolver to stay "open" and not specify where its inputs come from (just takes `:customer/id`), you can make bridges between that resolver and the entity resolvers that translate to the right keys (A bridge that turns `{:users/customer_id}` into `:get-billing-method/customer_id)

- Careful seeding multiple entities. If you feed your query the params `{:users/id 16230 :admins/id 9}`, it's not clear what the requested data is applying to (is `:roles-and-permissions` applying to the admin or the regular user?)

> 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, ...

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.
> 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.
It's very cool you managed to make a mini Pathom - esp in so few lines of code :))

But the end result looks almost identical? Resolver declarations are a bit reorganized and look a bit cleaner - though you could do that with a wrapper around Pathom. Why not fork Pathom and just make some QOL adjustments?

If you are okay only having a subset, then I think you could streamline Pathom quite a bit more.. Off the top of my head:

- adding and "registering" resolvers in to an environment is always annoying. To me this feels like it should be abstracted away and you should never has to manage this stuff. Each time you add a resolver you have to copy the new resolver name, scroll down to the bottom of the file and paste it in to the resolver list. Stale resolvers floating around in the ns and forgetting to register resolvers regularly leads to weird scenarios where you're wondering why something isn't resolving. I think the user shouldn't really have to think about any of this.. the environment should be constructed automatically by the engine. Scan the namespace and register everything that starts with some resolver symbol (ex: My resolver names all start with a `$` symbol.).

I get that it'd be not as flexible this way.. but I've never had to make multiple environments in one namespace.

- You should be able to safely reregister resolvers. This happens when you try to decouple modules that depend on common resolvers. Ex: I have some utility resolvers that do some format conversions. If I add them to the environments of two namespaces, then those two namespaces can't be registered in a parent namespace. It's not a dealbreaker, you just register all your ns environments all the top-level and do all your queries there. But you can't add inline test queries in these lower level namespaces and it sort of breaks the decoupling (esp if it's across library boundaries).

- The Pathom errors are actually pretty good once you know how to read them (but there is a ton of visual noise). My guess is it's going to be a challenge to get to the same level in a rewrite. It feels like there is room for improvement here, but I don't have concrete ideas. Maybe a ASCII diagram of the chain of missing keys? There is Pathom-Viz, but from what I understand it doesn't handle nested queries (which in a complex setup is basically all your queries)

> It's very cool you managed to make a mini Pathom - esp in so few lines of code :))

Thanks! The possibility of doing this had been on my mind for a while... and then I finally got around to trying it since all I had to do to get started was say "try making something like pathom but without [...]". I actually have all the prompts and feedback for the initial POC over here[1] since at the time I was using github issues/comments for my LLM-driven-development workflow.

Over the past few weeks as prep for release I went over all the code manually (especially since the whole point of this thing is for the implementation to be easy to understand) and basically rewrote the whole thing, or at least that's what it felt like.

> But the end result looks almost identical? Resolver declarations are a bit reorganized and look a bit cleaner - though you could do that with a wrapper around Pathom. Why not fork Pathom and just make some QOL adjustments?

The main thing I was going for was just to reduce the implementation size; the tweaks I made to e.g. `defresolver` were really just a side thing. To give some more background on the motivations, an issue I've had sometimes with Pathom is figuring out what's going wrong when my queries don't give me the results I'd expect. A few times as part of that I've gone spelunking through the Pathom codebase but still had never built up a complete understanding of how the query planning and execution works, which has meant that my debugging has always been more trial-and-error / black-box than I'd prefer. So I wanted to see "what is the least complex way that I could take an EQL query and figure out the results, even if the way I do it is dumber than the way Pathom does it?"

i.e. I'm trying to minimize the amount of time it takes for someone to read the code and understand exactly what's going on under the hood. Hence layering more code on top of Pathom would only hinder that goal.

[1] https://github.com/jacobobryant/biff.graph/issues?q=is%3Aiss...

> the tweaks I made to e.g. `defresolver` were really just a side thing

If it weren't for those, would Pathom be a drop-in replacement? Or is there different logic?

I'm a bit of a beginner with this all myself, so yeah, I get how it's a bit of a black box :)) Think it's very cool you re-implemented it.

> To give some more background on the motivations, an issue I've had sometimes with Pathom is figuring out what's going wrong when my queries don't give me the results I'd expect

I'm curious in what scenario PathomViz is not giving enough info. I had a lot of trouble getting it working tbh (never got the nested query working) but from the docs it seems like it should give you all the information you'd need to reason back to why you get a particular output. Reimplement all this debugging stuff seems potentially a lot of work - but maybe I'm wrong. More tools around the diagnostic output https://pathom3.wsscode.com/docs/debugging/ is something I hope to explore eventually.

> If it weren't for those, would Pathom be a drop-in replacement? Or is there different logic?

I could've written biff.graph to work with actual Pathom resolvers. In fact it wouldn't be hard to write a shim that takes Pathom resolvers and returns biff.graph resolvers. Although not all resolvers would work since biff.graph doesn't support everything in EQL (e.g. union queries, attribute parameters).

The query results aren't strictly guaranteed to be the same, so even with a shim I wouldn't recommend dropping biff.graph into a large project that's already using Pathom. And then that's not even getting into all the Pathom features that biff.graph doesn't support at all (lenient mode, plugins, async mode, the graphql adapter...).

But as for the core concepts, yeah I'd say they're pretty close.

> I'm curious in what scenario PathomViz is not giving enough info. I had a lot of trouble getting it working tbh

I had that trouble too heh heh--I tried running it I know at least once but didn't succeed. I don't remember exactly what the issue was... but I probably should figure that out.

Even if I got better at debugging Pathom though, for Biff I would still prefer to have an implementation that's easier for users to understand so that ideally they don't even need extra tools to aid with debugging.

FWIW there is an example here[1] of what the biff.graph error looks like when a nested required attribute can't be resolved. That file also has examples of some additional validation logic I've thrown in, e.g. biff.graph will complain if one resolver declares an attribute as a join and another resolver declares it as a scalar. Sometime for our codebase at work I'll probably write some assertions to do those kinds of checks on our Pathom resolvers.

[1] https://github.com/jacobobryant/biff/blob/v2.x/libs/graph/do...

Oh sorry, I kind of meant it the other way around. You start with biff.graph and you'd swap in Pathom if the featureset or performance wasn't adequate. It makes sense that since you support a subset that it doesn't work the other way around! It might make sense to reinvent the wheel if there is a clear gain - but if there isn't any big innovation going on in the library interface, it's generally nice to keep the same interface if it's easy enough to do - but that's just my opinion haha

And yeah, now that I have a larger application with Pathom.. I should retry Viz too :))

And that's very cool your taking error seriously. Sorry, I missed it when I looked at the rep the first time! Thanks for your help with Pathom a few months back (kxygk on Github)

ah got it. yeah, in that case you can write a biff -> pathom resolver shim that works for everything. Again though the main thing is just the fact that they have two completely different query engines and aren't guaranteed to give the same results. e.g. off the top of my head I can think of a contrived scenario where biff.graph might not be able to resolve something but Pathom can since it can "look ahead" in the query planning step.

Maybe that kind of situation is fine and the question is really just if there are queries that biff.graph can handle which Pathom can't. If your resolvers are written correctly maybe not? But there have definitely been times with Pathom where I did something wrong that threw off the query planner in ways I didn't expect.

In any case, if I end up wanting to support migrating easily between the two as a core feature, I'd definitely want to e.g. do a bunch of generative tests to find out what kinds of queries end up with different results. Until then, a downside of supporting Pathom resolvers without a shim is that it might give people the false impression that biff.graph is a drop-in replacement for Pathom or vice-versa.

So far though the main target audience I have for biff.graph is people (biff users) who have never even heard of Pathom before, so interchangeability hasn't been a top concern. Though if many people start using biff 2 and then eventually some of the start wanting to migrate to pathom, I'd be down to explore that area.

And haha yeah nice to bump into you again--I think I remembered your username from reddit, assuming it's geokon there.

that I understand. providing that compatibility guarantee is extra work for you

It's always nice talking to you about these things. Thanks again :)

Amazing project, getting into Clojure and will no doubt have use for this as a solo dev