I try to improve my understanding of FaunaDB.
I have a collection that contains records like:
{
"ref": Ref(Collection("regions"), "261442015390073344"),
"ts": 1587576285055000,
"data": {
"name": "italy",
"attributes": {
"amenities": {
"camping": 1,
"swimming": 7,
"hiking": 3,
"culture": 7,
"nightlife": 10,
"budget": 6
}
}
}
}
I would like to query in a flexible way by different attributes like:
I created an index containing all attributes, but I don't know how to do greater equals filtering in an index that contains multiple terms.
My fallback would be to create an index for each attribute and use Intersection to get the records that are in all subqueries that I want to check, but this feels somehow wrong:
The query: budget >= 6 AND camping >=8 would be:
Index:
{
name: "all_regions_by_all_attributes",
unique: false,
serialized: true,
source: "regions",
terms: [],
values: [
{
field: ["data", "attributes", "amenities", "culture"]
},
{
field: ["data", "attributes", "amenities", "hiking"]
},
{
field: ["data", "attributes", "amenities", "swimming"]
},
{
field: ["data", "attributes", "amenities", "budget"]
},
{
field: ["data", "attributes", "amenities", "nightlife"]
},
{
field: ["data", "attributes", "amenities", "camping"]
},
{
field: ["ref"]
}
]
}
Query:
Map(
Paginate(
Intersection(
Range(Match(Index("all_regions_by_all_attributes")), [0, 0, 0, 6, 0, 8], [10, 10, 10, 10, 10, 10]),
)
),
Lambda(
["culture", "hiking", "swimming", "budget", "nightlife", "camping", "ref"],
Get(Var("ref"))
)
)
This approach has the following disadvantages:
Is it possible to store all values in this kind of index that would contain all the data? I know I can just add more values to the index and access them. But this would mean I have to create a new index as soon as we add more fields to the entity. But maybe this is a common thing.
thanks in advance
Thanks for your question. Ben already wrote out a complete example that shows what you can do and I'll base myself on his recommendations and try to clarify further.
FaunaDB's FQL is quite powerful which means there are multiple ways to do that, yet with such power comes a small learning curve so I'm happy to help :). The reason it took a while to answer this question is that such an elaborate answer actually deserves a complete blog post. Well, I've never written a blog post in Stack Overflow, there is a first for everything!
There are three ways to do 'compound range-like queries' but there is one way that will be most performant for your use-case and we'll see that the first approach is actually not entirely what you need. Spoiler, the third option we describe here is what you need.
I'll keep it in one collection to keep it simpler and am using the JavaScript flavour of the Fauna Query Language here. There is a good reason to separate data in a second collection though which is related to your second map/get question (see the end of this answer)
CreateCollection({ name: 'place' })
Do(
Select(
['ref'],
Create(Collection('place'), {
data: {
name: 'mullion',
focus: 'team-building',
camping: 1,
swimming: 7,
hiking: 3,
culture: 7,
nightlife: 10,
budget: 6
}
})
),
Select(
['ref'],
Create(Collection('place'), {
data: {
name: 'church covet',
focus: 'private',
camping: 1,
swimming: 7,
hiking: 9,
culture: 7,
nightlife: 10,
budget: 6
}
})
),
Select(
['ref'],
Create(Collection('place'), {
data: {
name: 'the great outdoors',
focus: 'private',
camping: 5,
swimming: 3,
hiking: 2,
culture: 1,
nightlife: 9,
budget: 3
}
})
)
)
We can put as many terms as values in an index and use Match and Range to query those. However! Range probably gives you something different than you would expect if you use multiple values. Range gives you exactly what the index does and the index sorts values lexically. If we look at the example of Range in the docs we see an example there which we can extend upon for multiple values.
Imagine we would have an index with two values and we write:
Range(Match(Index('people_by_age_first')), [80, 'Leslie'], [92, 'Marvin'])
Then the result will be what you see on the left and not what you see on the right. This is a very scalable behaviour and exposes the raw-power without overhead of the underlying index but is not exactly what you are looking for!
So let's move on to another solution!
Another quite flexible solution is to use Range and then Filter. This however is a less good idea in case you are filtering out a lot with filter since your pages will become more empty. Imagine that you have 10 items in a page after the 'Range' and use filter, then you will end up with pages of 2, 5, 4 elements depending on what is filtered out. This is a great idea however if one of these properties has such a high cardinality that it will filter out most of entities. E.g. imagine everything is timestamped, you want to first get a date range and then continue filtering something that will only eliminate a small percentage of the resultset. I believe that in your case all of these values are quite equal so this the third solution (see lower) will be the best for you.
We could in this case just throw all values in so that they all get returned which avoids a Get. For example, let's say that 'camping' is our most important filter.
CreateIndex({
name: 'all_camping_first',
source: Collection('place'),
values: [
{ field: ['data', 'camping'] },
// and the rest will not be used for filter
// but we want to return them to avoid Map/Get
{ field: ['data', 'swimming'] },
{ field: ['data', 'hiking'] },
{ field: ['data', 'culture'] },
{ field: ['data', 'nightlife'] },
{ field: ['data', 'budget'] },
{ field: ['data', 'name'] },
{ field: ['data', 'focus'] },
]
})
You can now write a query that just gets a range based on the camping value:
Paginate(Range(Match('all_camping_first'), [1], [3]))
Which should return two elements (the third has camping === 5) Now imagine that we want to filter over these and we set our pages small to avoid unnecessary work
Filter(
Paginate(Range(Match('all_camping_first'), [1], [3]), { size: 2 }),
Lambda(
['camping', 'swimming', 'hiking', 'culture', 'nightlife', 'budget', 'name', 'focus'],
And(GTE(Var('hiking'), 0), GTE(7, Var('hiking')))
)
)
Since I want to be clear on both the advantages as disadvantages of each approach, let's show exactly how filter works by adding another one that has attributes that match our query.
Create(Collection('place'), {
data: {
name: 'the safari',
focus: 'team-building',
camping: 1,
swimming: 9,
hiking: 2,
culture: 4,
nightlife: 3,
budget: 10
}
})
Running the same query:
Filter(
Paginate(Range(Match('all_camping_first'), [1], [3]), { size: 2 }),
Lambda(
['camping', 'swimming', 'hiking', 'culture', 'nightlife', 'budget', 'name', 'focus'],
And(GTE(Var('hiking'), 0), GTE(7, Var('hiking')))
)
)
Now still returns only one value but provides you with an 'after' cursor that points to the next page. You might think: "huh? My page size was 2?". Well that's because Filter works after Pagination and your page originally had two entities from which one got filtered out. So you are left with a page of 1 value and a pointer to the next page.
{
"after": [
...
],
"data": [
[
1,
7,
3,
7,
10,
6,
"mullion",
"team-building"
]
]
You could also opt to Filter directly on the SetRef as well and only paginate afterwards. In that case, the size of your pages will contain the required size. However, keep in mind that this is an O(n) operation on the amount of elements that comes back from Range. Range uses an index but from the moment you use Filter, it will loop over each of the elements.
This is the best solution for your use-case but it requires a bit more understanding and an intermediate index.
When we look at the doc examples for intersection we see this example:
Paginate(
Intersection(
Match(q.Index('spells_by_element'), 'fire'),
Match(q.Index('spells_by_element'), 'water'),
)
)
This works because it's two times the same index and that means that **the results are similar values ** (references in this case). Let's say we add a few indexes.
CreateIndex({
name: 'by_camping',
source: Collection('place'),
values: [
{ field: ['data', 'camping']}, {field: ['ref']}
]
})
CreateIndex({
name: 'by_swimming',
source: Collection('place'),
values: [
{ field: ['data', 'swimming']}, {field: ['ref']}
]
})
CreateIndex({
name: 'by_hiking',
source: Collection('place'),
values: [
{ field: ['data', 'hiking']}, {field: ['ref']}
]
})
We can intersect on them now but it will not give us the right result. For example... let's call this:
Paginate(
Intersection(
Range(Match(Index("by_camping")), [3], []),
Range(Match(Index("by_swimming")), [3], [])
)
)
The result is empty. Although we had one with swimming 3 and camping 5. That is exactly the problem. If swimming and camping were both the same value we would get a result. So it's important to notice that Intersection intersects the values, so that includes both the camping/swimming value as well as the reference. That means that we have to drop the value since we only need the reference. The way to do that before pagination is with a join, Essentially we are going to join with another index that is going to just.. return the ref (not specifying values defaults to only the ref)
CreateIndex({
name: 'ref_by_ref',
source: Collection('place'),
terms: [{field: ['ref']}]
})
This join looks as follows
Paginate(Join(
Range(Match(Index('by_camping')), [4], [9]),
Lambda(['value', 'ref'], Match(Index('ref_by_ref'), Var('ref'))
)))
Here we just took the result of Match(Index('by_camping')) and just dropped the value by joining with an index that only returns the ref. Now let's combine this and just do an AND kind of range query ;)
Paginate(Intersection(
Join(
Range(Match(Index('by_camping')), [1], [3]),
Lambda(['value', 'ref'], Match(Index('ref_by_ref'), Var('ref'))
)),
Join(
Range(Match(Index('by_hiking')), [0], [7]),
Lambda(['value', 'ref'], Match(Index('ref_by_ref'), Var('ref'))
))
))
The result is two values, and both in the same page!
Note that you can easily extend or compose FQL by just using the native language (in this case JS) to make this look much nicer (note I didn't test this piece of code)
const DropAllButRef = function(RangeMatch) {
return Join(
RangeMatch,
Lambda(['value', 'ref'], Match(Index('ref_by_ref'), Var('ref'))
))
}
Paginate(Intersection(
DropAllButRef (Range(Match(Index('by_camping')), [1], [3])),
DropAllButRef (Range(Match(Index('by_hiking')), [0], [7]))
))
And a final extension, this only returns indexes so you'll need to map get. There is of course a way around this if you really want to by.. just using another index :)
const index = CreateIndex({
name: 'all_values_by_ref',
source: Collection('place'),
values: [
{ field: ['data', 'camping'] },
{ field: ['data', 'swimming'] },
{ field: ['data', 'hiking'] },
{ field: ['data', 'culture'] },
{ field: ['data', 'nightlife'] },
{ field: ['data', 'budget'] },
{ field: ['data', 'name'] },
{ field: ['data', 'focus'] }
],
terms: [
{ field: ['ref'] }
]
})
Now you have the range query, will get everything without a map/get:
Paginate(
Intersection(
Join(
Range(Match(Index('by_camping')), [1], [3]),
Lambda(['value', 'ref'], Match(Index('all_values_by_ref'), Var('ref'))
)),
Join(
Range(Match(Index('by_hiking')), [0], [7]),
Lambda(['value', 'ref'], Match(Index('all_values_by_ref'), Var('ref'))
))
)
)
With this join approach you could even do range indexes on different collections as long as you join them to the same reference before intersecting! Pretty cool huh?
Yes you can, indexes in FaunaDB are views, so let's call them indiviews. It's a tradeoff, essentially you are exchanging compute for storage. By making a view with many values you get very fast access to a certain subset of your data. But there is another tradeoff and that is flexibility. You can not just go adding elements since that would require you to rewrite your whole index. In that case you will have to make a new index and wait for it to build if you have much data (and yes, that is quite common) and make sure that the queries you do (look at the lambda parameters in map filter) match your new index. You can always delete the other index afterwards. Just using Map/Get will be more flexible, everything in databases is a tradeoff and FaunaDB gives you both options :). I would suggest to use such an approach from the moment your datamodel is fixed and you see a specific part in your app that you want to optimise.
The second question on Map/Get requires some explanation. Separating out the values that you will search on from the places (as Ben did) is a great idea if you want to use Join to get the actual places more efficiently. This will not require a Map Get and therefore cost you far less reads but do notice that Join is rather a traverse (it'll replace the current references with the target references it joins to) so if you need both the values and the actual place data in one object at the end of your query than you will require Map/Get. Look at it from this perspective, indexes are ridiculously cheap in terms of reads and you can go quite far with those but for some operations there is just no way around Map/Get, Get is still only 1 read. Given that you get 100 000 for free per day that is still not expensive :). You could keep your pages also relatively small (size parameter in paginate) to make sure you don't do unnecessary gets unless your users or app requires more pages. For people reading this that do not know this yet:
We can and will make this easier in the future. However, note that you are working with a scalable distributed database and often these things are just not even possible in other solutions or very inefficient. FaunaDB provides you with very powerful structures and raw access to how indexes work and gives you many options. It does not try to be clever for you behind the scenes as this might result in very inefficient queries in case we get it wrong (that would be a bummer in a scalable pay-as-you-go system).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With