Hacker News new | ask | show | jobs
by geokon 15 days ago
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!

1 comments

On local it's customer, but I renamed it Company for you and forgot to rename it on the bridge. I will call it Customer below.

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]}
      (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 resolved paths part of the test suite. Although, I still think it could be a powerful combination with something like Sqlite or Datalevin where many reads don't matter as much.
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 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

- 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 FAT 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"}}
I think you can see you get the same results, but there is no way for it to go wrong. Admittedly here I have the EQL request "unpacking" it, but it can also be unpacked transparently from a different resolver using nested inputs.

    (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 "fat-eater 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"}
This is very explicit and about equivalent to your original thought of "why not just have explicit imperative function calls". The consumer (the `fat-eater` or the user making the EQL request) knows a priori that he wants the fat call.

EDIT: At the bottom of the .clj I added a bunch of other tests for the pipeline branching (the A->B->C thing)

- Adding an XXX resolver to override step B worked when "forced" with an extra input key.

- Adding an YYY resolver to override step B by outputting an extra key ends up always overriding B (whether you request the new dummy output key or not!)

I'm guessing here.. but it's probably b/c YYY has the same input requirements as B, but YYY outputs two keys instead of one - so it's preferred.

In a way both seem useful. The first lets you use a flag to select when you take a branch. The second make you always override the branch

> 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?)

Sorry for the delay, I've been thinking about how to respond :)

> For me it is.

if you can have a running example I'd love to see it

> I want some good guidelines

Yeah, I totally understand. I don't have a very clear mental model myself as of yet.

> try to pack related entities into their own nested collections

I'd try to just be wary of trying to map OO concepts to Pathom. I get the impulse, but my gut feeling is that this won't be fruitful or will make things muddled.

> you can make bridges between that resolver

tbh I haven't had to really use bridges. The few times I did, I then rearchitected till they disappeared. I do sometimes have different ns's using keys from each other though .. which feels wrong. No clear picture here yet.

My mental model at the moment is a bit fuzzy but goes like this:

Pathom is fundamentally a web of resolvers. The resolvers are best thought of as functions that can only be run once. This is the fundamental constraint you're working under - but you get a bunch of benefits - and I wouldn't try to fight this fundamental paradigm

1.

How do we deal with resolvers that conceptually have the same I/O keys? Say we have two resolvers:

    :filename -> [readFileJPG] -> :image
    :filename -> [readFilePNG] -> :image
(I'm going to use this much simpler example b/c it's sufficient to illustrate the problems)

You have two choices/branches. Pathom can't make that choice for you:

In your systems either the provider has to decide:

    :filenameJPG -> [readFileJPG] -> :image
    :filenamePNG -> [readFilePNG] -> :image
.. or the consumer has to decide:

    :filename -> [readFileJPG] -> :imageJPG
    :filename -> [readFilePNG] -> :imagePNG
I think this is the part where you really have to think hard about who knows which branch should be taken. Who knows you need a fat db request or a skinny one. Is that the part providing the :id and whatnot, or is it the one receiving the result of the db query?

But bear in mind that Pathom is a "compile time" tool in a sense. If it's something determined from a db query.. then it's not part of the program control flow and needs to be decide at "runtime". It's not a Pathom problem! It has to happen inside the resolver code and it can't be part of the web of resolvers.

In that previous :fat-pack example, the equivalents would been :id-FAT (provider) or :customers/phone_number-FAT (consumer)

2.

Okay, but say I have readFileJPG and readFilePNG provided by some library/module/ns. I can't modify it's I/O keys. Then hopefully the keys are namespaced:

    ::jpg/filename -> [jpg/readFile] -> ::jpg/imageJPG
    ::png/filename -> [png/readFile] -> ::png/imagePNG
Now you can bridge keys either on one side or the other, effectively re-creating either a provider or consumer driver flow. But you can't bridge both sides, cus then you're back to the original problem

3.

Okay, but say I have a pipeline that is filetype-agnostic and for some reason :filename are coming in from "outside" (maybe user is typing it into a text box) and the output really just wants :image keys for some subsequent processing. What are your options?

- let the provider/upstream decide: The :filename is coming in from somewhere, but it know the file type a priori. So it can supply a flag that the resolvers require - ex: :is-jpg , :is-png

    ::filename + :is-jpg -> [jpg/readFile] -> :image
    ::filename + :is-png -> [png/readFile] -> :image
- let the consumer/downstream decide: nested outputs {:jpg [:image]}

    ::filename -> [jpg/readFile] -> {:jpg [:image]}
    ::filename -> [png/readFile] -> {:png [:image]}

Both options leave a bad taste the mouth.

- The first feels fragile (you live in fear of it taking a wrong branch), esp if you have "default no-flag" case like in your db request example.

- The second creates a packed thing that needs to be unpacked. (this is what I showed with :fat-pack). I actually think this is the worse solution b/c it breaks composability. The packed space is completely isolated from the wider soup of keys, which can severely limit what you can do (as I'll illustrate).

EDIT: Part 6 I show the better solution for the consumer

4.

The second solution also conceptually clashes with another tool in the toolbelt. Nested inputs and outputs have a fundamental, completely unrelated, different usecase. They allow you to create a mini-context in which you can re-execute your Pathom resolvers. So if you have two images in your application, you can read them both in. These two transformation can co-exist:

    {:profile-pic [:filename]} => {:profile-pic [:image]}
    {:background-pic [:filename]} => {:background-pic [:image]}
You can of course create mini-contexts arbitrarily if you want. The downside of these nested mini-contexts is that they are aggressively isolated and completely oblivious of the larger world, so you need to shove in all the keys you may use. If you have a resolver somewhere else that processes the image:

    :image + :username -> [addUsernameOverlay] -> :image-with-username
Then you'll need to jam all the necessary keys in to the mini-context:

     {:profile-pic [:filename :username]} => {:profile-pic [:image-with-username]}
5.

So for the consumer side solution:

    ::filename -> [jpg/readFile] -> {:jpg [:image]}
You're a bit screwed. You've lost the natural extensibility of Pathom. You now CANNOT do

    ::filename + ::username => :image-with-username [XXXX - doesn't work!]
Nor can you do

    ::filename + ::username => {:jpg [:image-with-username]}
One hack is to have jpg/readFile pack the :jpg container (ie. forward the :username into it)

    ::filename + :username -> [jpg/readFile] -> {:jpg [:image + :username]}
This makes the second call work, but this is a bit gross.. B/c now the jpg/readFile resolver requires a :username that it actually doesn't use. The resolver is now coupled to downstream requirements. Eww

6.

After chewing it over, I think the best solution is to pack everything when possible:

    {:jpg [:filename]} -> [jpg/readfile] -> {:jpg [:image]}
Now the jpg/readfile resolver doesn't take a :filename, but instead takes a :jpg as {:jpg [:filename]} and unpacks it. Here.. I think technically only consumer must know the type.. But now you can add the username transparently and this transformation works without jpg/readfile needing to forward anything.

    {:jpg [:filename + :username]} -> {:jpg [:image-with-username]}
The downside is that you may end up having to shove in a lot of variables in to the mini-context. If it's all using resolvers from a library/ns that are kinda doing an isolated set of stuff then this can be very viable though!

When to create these mini-contexts is not something I have a clear mental model for. It's related to the fundamental downstream output