Update document based on current value within that document v10

I’m trying to update a counter value within a document. I had it figured out with the old fql, but with v10 I can’t figure out a good way to simplify this.

This is what I’m currently doing in JS:

const findQuery = fql`plusses.firstWhere(.user_id == ${user.id} && .company == companies.firstWhere(.data.id == ${body.team_id}))`
const response: QuerySuccess<User> = await faunaClient.query(findQuery)
const userDoc = response.data

let updateQuery
if (userDoc) {
          updateQuery = fql`${findQuery}!.update({ plusses: ${userDoc.plusses} + 1})`
} else {
          updateQuery = fql`plusses.create({
            username: ${user.username},
            user_id: ${user.id},
            plusses: 1,
            company: companies.firstWhere(.data.id == ${body.team_id})
          })`
}

But I was wondering if there was a better way to do this using pure fql, but I can’t find a good way to reference data that already exists on the document when updating it…

if (plusses.firstWhere(.user_id == "U045MUHJD34" && .company == companies.firstWhere(.data.id == "T045JP1N51T")) {
  let user = plusses.firstWhere(.user_id == "U045MUHJD34" && .company == companies.firstWhere(.data.id == "T045JP1N51T"))
  plusses.firstWhere(.user_id == "U045MUHJD34" && .company == companies.firstWhere(.data.id == "T045JP1N51T"))!.update({ "plusses": user.data.plusses + 1 })
} else {
  plusses.create({
    username: "plusbot3",
    user_id: "U045MUHJD34",
    plusses: 1,
    company: companies.firstWhere(.data.id == "T045JP1N51T")
  })
}

It looks like your query could be made more performant using

  1. Document references to store relationships instead of custom foreign keys
  2. One or more indexes to avoid full table scans

But to answer your question more directly first, you can check for null or empty results and branch on that. firstWhere returns null if no match is found.

// use `!` null assertion operator* at the end to return an error immediately if there is no company found
let company_match = companies.firstWhere(.data.id == "T045JP1N51T")!

let plusses_doc = plusses.firstWhere(
  .user_id == "U045MUHJD34" && 
  .company == company_match
)

if (plusses_doc == null) {
  plusses.create({
    username: "plusbot3",
    user_id: "U045MUHJD34",
    plusses: 1,
    company: company_match
  })
} else {
  // Use null assertion operator for types. FQL cannot yet narrow types within the if expression.
  // You don't need to specify `data` in v10. v10 will "lift" your document's `data` field to the top level.
  user!.update({ plusses: user.plusses + 1 })
}

I have an idea of how to follow up with indexes, so will come back with that in a bit.

Using Indexes

The firstWhere method is going to require, in the worst case, reading the entire collection looking for a match. This can be fixed by using an index.

You have some options depending on your data model and other query patterns, but it looks like a plusses document should have a unique combination of user and company, so let’s build an index with those as terms.

  • enable efficient search by indexing on both key fields
  • include “plusses” field in values to avoid extra read cost for fetching document to get it
  • the reference is always covered in v10, so you can also efficiently get the document ID
// FSL
collection plusses {
  history_days 0
  index by_user_company {
    terms [.user_id, .company]
    values [asc(.plusses)]
  }
}

collection companies {
  history_days 0
  index by_company_id {
    terms [.data.id]
  }
}

And using the indexes

let company_match = companies.by_company_id("T045JP1N51T")!

let plusses_doc = plusses.by_user_company("U045MUHJD34", company_match)

if (plusses_doc == null) {
  plusses.create({
    username: "plusbot3",
    user_id: "U045MUHJD34",
    plusses: 1,
    company: company_match
  })
} else {
  plusses_doc!.update({ plusses: user.plusses + 1 })
}

Optimizing relationships

Whether you are using v4 or v10, you should try to store relationships using native references rather than foreign keys. Using direct references mean you can resolve the relationship without an index lookup (or in the case of using where or firstWhere, a full table scan).

v10 automatically handles referencing and dereferencing Documents, similar to how many languages automatically handle pointers for reference values. This means you can supply a document as a value when you create or update another document, and it will store a reference.

You are already doing this when you store the company match in the plusses document, but you didn’t for the user. I recommend also storing the user document directly.

// FSL
collection plusses {
  history_days 0
  index by_user_company {
    terms [.user_id, .company]
    values [asc(.plusses)]
  }
  index by_user {
    terms [.user]
    values [asc(.plusses), .company]
  }
  index by_company {
    terms [.company]
    values [asc(.plusses), .user]
  }
}

collection users {
  history_days 0
  index by_user_id {
    terms [.user_id]
  }
}

collection companies {
  history_days 0
  index by_company_id {
    terms [.data.id]
  }
}

Updating the original query

let user_match = users.by_user_id("U045MUHJD34")!
let company_match = companies.by_company_id("T045JP1N51T")!

let plusses_doc = plusses.by_user_company(user_match, company_match)

if (plusses_doc == null) {
  plusses.create({
    username: "plusbot3",
    user_id: "U045MUHJD34",
    user: user_match,
    company_id: "T045JP1N51T",
    company: company_match,
    plusses: 1,
  })
} else {
  plusses_doc!.update({ plusses: user.plusses + 1 })
}

And with the indexes and relationships you can run other queries.

Listing a user’s top plusses

// most efficient if you have the document id in your client
// otherwise you can do an efficient index lookup by the business id you provide
let user_match = users.byId("3691238741239045822") // do not use null assertion

// The user document is not read, because we never needed any fields from it.
// The reference is used to make the index lookup.
let top_plusses = plusses.by_user(user_match).reverse.take(5) {
  // rendering the company does not require another index lookup, because it's stored as a reference
  company {
    id,
    name,
    data: { id } // wrap in data because the field name conflicts with native top-level `id` field.
  }
}

List the top users that like a company

let company_match = company.byId("3691233879234562346") // do not use null assertion

let top_plusses = plusses.by_company(company_match).reverse.take(5) {
  // rendering the user does not require another index lookup, because it's stored as a reference
  user {
    id,
    name,
    user_id
  }
}

One more note: Writing the document directly every time a user hits a plus/like button does not scale well. Fauna is strictly serialized, which means you can think about every operation happening in order. But what happens when there are too many write transactions all happening at once? Fauna will start to fail to complete write transactions due to contention over the document.

To avoid the issue, one recommendation is to allow many processes to write events to a separate collection, and have a process periodically query all recent events and perform the updates to documents. In this way, there is only ever one process that needs to update the documents, and you avoid contention.

We have an article describing the Event Sourcing pattern here: Write throughput scaling - Fauna Documentation

For your case, you might write an event like this

plus_events.create({
  user: users.byId("3691238741239045822"),
  company: companies.byId("3691233879234562346"),
  action: "plus", // or "minus", etc.
  created_at: TransactionTime(),
  ttl: Time.now().add(1, "hour") // or some time greater than the interval you use the events
})

Then aggregate the most recent events and update the related documents

let most_recent_events = plus_events
  .all__created_at_desc({ to: Time(${TS_OF_LAST_EVENT_HANDLED}) })

let grouped_data = most_recent_events.fold({}, (accumulator, event) => {
  let user = event.user
  let company = event.company
  
  // Create unique keys for each user and company combination
  let key = "#{user.id}-#{company.id}";
  
  // Initialize the entry if it doesn't exist in the accumulator
  let existing = if (accumulator[key] == null) 0 else accumulator[key].plus_changes
  
  // Update the total based on the action type
  let new = if (event.action == "plus") {
    existing + 1
  } else if (event.action == "minus") {
    existing - 1
  }
  
  let updated_key = Object.fromEntries([[key, {
    user: user,
    company: company,
    plus_changes: new
  }]])
  
  // return the updated results
  Object.assign(accumulator, updated_key)
});

Object.entries(grouped_data).forEach(entry => {
  let data = entry[1]
  let user = data.user
  let company = data.company
  let plus_changes = data.plus_changes

  // Create or Update your `plusses` document like we did before
  // ...
})

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.