ES version is available. Content is displayed in original English for accuracy.
Advertisement
Advertisement
⚡ Community Insights
Discussion Sentiment
81% Positive
Analyzed from 2183 words in the discussion.
Trending Topics
#pathom#resolvers#pet#resolver#graph#biff#code#query#person#functions

Discussion (17 Comments)Read Original on HackerNews
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 ever resolver.
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 sub-systems 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 not-toy program will be basically all your queries)
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...
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.
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...
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!
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?
- 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
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:
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".