Skip to content

Commit

Permalink
feat: Remove the implicit inside partialFilter to find existing index
Browse files Browse the repository at this point in the history
When CouchDB creates an index with a partialFilter, it adds explicit
operators when they are implicit, typically the $and and $eq operators.
This causes a mismatch when comparing the partialFilter from the
request definition with the partialFilter from the index. To address
this, we added a step to make the operators in the partialFilter from
the request definition explicit.

This makes it possible to migrate indexes after they have been
renamed rather than re-creating them, which has a non-negligible cost.
  • Loading branch information
cballevre committed Jul 8, 2024
1 parent ec6eb46 commit 5da0d55
Show file tree
Hide file tree
Showing 3 changed files with 261 additions and 2 deletions.
33 changes: 33 additions & 0 deletions docs/api/cozy-stack-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ query to work</p>
<dt><a href="#isMatchingIndex">isMatchingIndex</a> ⇒ <code>boolean</code></dt>
<dd><p>Check if an index is matching the given fields</p>
</dd>
<dt><a href="#makeOperatorsExplicit">makeOperatorsExplicit</a> ⇒ <code>object</code></dt>
<dd><p>Transform a query to make all operators explicit</p>
</dd>
<dt><a href="#getPermissionsFor">getPermissionsFor</a> ⇒ <code>object</code></dt>
<dd><p>Build a permission set</p>
</dd>
Expand Down Expand Up @@ -132,6 +135,10 @@ See <a href="https://docs.cozy.io/en/cozy-stack/sharing-design/#description-of-a
<dd><p>Get Icon URL using blob mechanism if OAuth connected
or using preloaded url when blob not needed</p>
</dd>
<dt><a href="#handleNorOperator">handleNorOperator(conditions)</a> ⇒ <code>Array</code></dt>
<dd><p>Handle the $nor operator in a query
CouchDB transforms $nor into $and with $ne operators</p>
</dd>
<dt><a href="#garbageCollect">garbageCollect()</a></dt>
<dd><p>Delete outdated results from cache</p>
</dd>
Expand Down Expand Up @@ -2224,6 +2231,19 @@ Check if an index is matching the given fields
| fields | <code>Array</code> | The fields that the index must have |
| partialFilter | <code>object</code> | An optional partial filter |

<a name="makeOperatorsExplicit"></a>

## makeOperatorsExplicit ⇒ <code>object</code>
Transform a query to make all operators explicit

**Kind**: global constant
**Returns**: <code>object</code> - - The transformed query with all operators explicit

| Param | Type | Description |
| --- | --- | --- |
| query | <code>object</code> | The query to transform |
| reverseEq | <code>boolean</code> | If true, $eq will be transformed to $ne (useful for manage $nor) |

<a name="getPermissionsFor"></a>

## getPermissionsFor ⇒ <code>object</code>
Expand Down Expand Up @@ -2312,6 +2332,19 @@ Get Icon URL using blob mechanism if OAuth connected
or using preloaded url when blob not needed

**Kind**: global function
<a name="handleNorOperator"></a>

## handleNorOperator(conditions) ⇒ <code>Array</code>
Handle the $nor operator in a query
CouchDB transforms $nor into $and with $ne operators

**Kind**: global function
**Returns**: <code>Array</code> - - The reversed conditions

| Param | Type | Description |
| --- | --- | --- |
| conditions | <code>Array</code> | The conditions inside the $nor operator |

<a name="garbageCollect"></a>

## garbageCollect()
Expand Down
67 changes: 66 additions & 1 deletion packages/cozy-stack-client/src/mangoIndex.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import head from 'lodash/head'
import get from 'lodash/get'
import isEqual from 'lodash/isEqual'
import isObject from 'lodash/isObject'

/**
* @typedef {Object} MangoPartialFilter
Expand Down Expand Up @@ -148,9 +149,73 @@ export const isMatchingIndex = (index, fields, partialFilter) => {
if (!partialFilter && !partialFilterInIndex) {
return true
}
if (isEqual(partialFilter, partialFilterInIndex)) {

const explicitPartialFilter = makeOperatorsExplicit(partialFilter ?? {})
if (isEqual(explicitPartialFilter, partialFilterInIndex)) {
return true
}
}

return false
}

/**
* Handle the $nor operator in a query
* CouchDB transforms $nor into $and with $ne operators
*
* @param {Array} conditions - The conditions inside the $nor operator
* @returns {Array} - The reversed conditions
*/
const handleNorOperator = conditions => {
return conditions.map(condition =>
Object.entries(condition).reduce((acc, [key, value]) => {
if (typeof value === 'string') {
acc[key] = { $ne: value }
} else {
acc[key] = makeOperatorsExplicit(value, true)
}
return acc
}, {})
)
}

/**
* Transform a query to make all operators explicit
*
* @param {object} query - The query to transform
* @param {boolean} reverseEq - If true, $eq will be transformed to $ne (useful for manage $nor)
* @returns {object} - The transformed query with all operators explicit
*/
export const makeOperatorsExplicit = (query, reverseEq = false) => {
const explicitQuery = Object.entries(query).reduce((acc, [key, value]) => {
if (key === '$nor') {
acc['$and'] = handleNorOperator(value)
} else if (value['$or']?.every(v => typeof v === 'string')) {
acc['$or'] = value['$or'].map(v =>
makeOperatorsExplicit({ [key]: v }, reverseEq)
) // To manage $or with list of strings
} else if (Array.isArray(value) && value.every(isObject)) {
acc[key] = value.map(v => makeOperatorsExplicit(v, reverseEq)) // To manage $and and $or with multiple conditions inside
} else if (isObject(value) && !Array.isArray(value)) {
acc[key] = makeOperatorsExplicit(value, reverseEq) // To manage nested objects
} else if (reverseEq && key === '$eq') {
acc['$ne'] = value
} else if (!key.startsWith('$')) {
acc[key] = { $eq: value } // To manage implicit $eq
} else {
acc[key] = value // To manage explicit operators
}
return acc
}, {})

const explicitQueryKeys = Object.keys(explicitQuery)
if (explicitQueryKeys.length === 1) {
return explicitQuery
}

return {
$and: explicitQueryKeys.map(key => ({
[key]: explicitQuery[key]
}))
}
}
163 changes: 162 additions & 1 deletion packages/cozy-stack-client/src/mangoIndex.spec.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
isMatchingIndex,
getIndexFields,
getIndexNameFromFields
getIndexNameFromFields,
makeOperatorsExplicit
} from './mangoIndex'

const buildDesignDoc = (fields, { partialFilter, id } = {}) => {
Expand Down Expand Up @@ -262,3 +263,163 @@ describe('getIndexNameFromFields', () => {
)
})
})

describe('makeOperatorsExplicit', () => {
it('Transforms implicit $eq operator to explicit', () => {
const query = { name: 'test' }
const expected = { name: { $eq: 'test' } }
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Transforms implicit $and operator to explicit', () => {
const query = { name: 'test', age: 42 }
const expected = { $and: [{ name: { $eq: 'test' } }, { age: { $eq: 42 } }] }
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Maintains explicit $eq operator', () => {
const query = { name: { $eq: 'test' } }
const expected = { name: { $eq: 'test' } }
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Maintains explicit $and operator', () => {
const query = { $and: [{ name: 'test' }, { age: 42 }] }
const expected = { $and: [{ name: { $eq: 'test' } }, { age: { $eq: 42 } }] }
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Handles nested implicit $eq operators', () => {
const query = { user: { name: 'test', age: 42 } }
const expected = {
user: { $and: [{ name: { $eq: 'test' } }, { age: { $eq: 42 } }] }
}
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Handles nested explicit operators', () => {
const query = { user: { $or: [{ name: 'test' }, { age: { $ne: 42 } }] } }
const expected = {
user: { $or: [{ name: { $eq: 'test' } }, { age: { $ne: 42 } }] }
}
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Handles mixed implicit and explicit operators', () => {
const query = { name: 'test', age: { $ne: 42 } }
const expected = { $and: [{ name: { $eq: 'test' } }, { age: { $ne: 42 } }] }
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Handles operator with string array', () => {
const query = {
_id: {
$nin: ['id123', 'id456']
},
type: 'file'
}
const expected = {
$and: [
{
_id: {
$nin: ['id123', 'id456']
}
},
{ type: { $eq: 'file' } }
]
}
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Handles $or operator with string array', () => {
const query = {
type: {
$or: ['konnector', 'worker']
}
}
const expected = {
$or: [{ type: { $eq: 'konnector' } }, { type: { $eq: 'worker' } }]
}
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Handles $or operator with object array', () => {
const query = {
type: 'file',
$or: [
{
trashed: {
$exists: false
}
},
{
trashed: false
}
]
}
const expected = {
$and: [
{ type: { $eq: 'file' } },
{ $or: [{ trashed: { $exists: false } }, { trashed: { $eq: false } }] }
]
}
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Handles explicit $and operator with nested object to make explicit', () => {
const query = {
type: 'file',
trashed: true,
'metadata.notifiedAt': {
$exists: false
}
}
const expected = {
$and: [
{ type: { $eq: 'file' } },
{ trashed: { $eq: true } },
{ 'metadata.notifiedAt': { $exists: false } }
]
}
expect(makeOperatorsExplicit(query)).toEqual(expected)
})

it('Handles explicit $nor operator', () => {
const query = {
$nor: [
{
type: {
$eq: 'directory'
}
},
{ dir_id: 'id1234' },
{
'metadata.notifiedAt': {
$exists: false
}
}
]
}
const expected = {
$and: [
{
type: {
$ne: 'directory'
}
},
{
dir_id: {
$ne: 'id1234'
}
},
{
'metadata.notifiedAt': {
$exists: false
}
}
]
}

expect(makeOperatorsExplicit(query)).toEqual(expected)
})
})

0 comments on commit 5da0d55

Please sign in to comment.