feat: add find_index, has, and reject filters (#799)

* feat: add find_index, has, and reject filters

* Minor tweaks

* Change semantics of jekyllStyle, add more tests

* Some docs improvements
This commit is contained in:
Bruno Carvalho
2025-02-23 22:57:17 +08:00
committed by GitHub
parent b0facc71f7
commit 0deb93eeae
10 changed files with 595 additions and 23 deletions
+6
View File
@@ -51,10 +51,14 @@ filters:
escape_once: escape_once.html
find: find.html
find_exp: find_exp.html
find_index: find_index.html
find_index_exp: find_index_exp.html
first: first.html
floor: floor.html
group_by: group_by.html
group_by_exp: group_by_exp.html
has: has.html
has_exp: has_exp.html
inspect: inspect.html
join: join.html
json: json.html
@@ -72,6 +76,8 @@ filters:
push: push.html
prepend: prepend.html
raw: raw.html
reject: reject.html
reject_exp: reject_exp.html
remove: remove.html
remove_first: remove_first.html
remove_last: remove_last.html
+25
View File
@@ -0,0 +1,25 @@
---
title: find_index
---
{% since %}v10.21.0{% endsince %}
Return the 0-based index of the first object in an array for which the queried attribute has the given value or return `nil` if no item in the array satisfies the given criteria. For the following `members` array:
```javascript
const members = [
{ graduation_year: 2013, name: 'Jay' },
{ graduation_year: 2014, name: 'John' },
{ graduation_year: 2014, name: 'Jack' }
]
```
Input
```liquid
{{ members | find_index: "graduation_year", 2014 | json }}
```
Output
```text
1
```
+25
View File
@@ -0,0 +1,25 @@
---
title: find_index_exp
---
{% since %}v10.21.0{% endsince %}
Return the 0-based index of the first object in an array for which the given expression evaluates to true or return `nil` if no item in the array satisfies the evaluated expression.
```javascript
const members = [
{ graduation_year: 2013, name: 'Jay' },
{ graduation_year: 2014, name: 'John' },
{ graduation_year: 2014, name: 'Jack' }
]
```
Input
```liquid
{{ members | find_index_exp: "item", "item.graduation_year == 2014" | json }}
```
Output
```text
1
```
+25
View File
@@ -0,0 +1,25 @@
---
title: has
---
{% since %}v10.21.0{% endsince %}
Return `true` if the array includes an item for which the queried attribute has the given value or return `false` if no item in the array satisfies the given criteria. For the following `members` array:
```javascript
const members = [
{ graduation_year: 2013, name: 'Jay' },
{ graduation_year: 2014, name: 'John' },
{ graduation_year: 2014, name: 'Jack' }
]
```
Input
```liquid
{{ members | has: "graduation_year", 2014 | json }}
```
Output
```text
true
```
+25
View File
@@ -0,0 +1,25 @@
---
title: has_exp
---
{% since %}v10.21.0{% endsince %}
Return `true` if an item exists in an array for which the given expression evaluates to true or return `false` if no item in the array satisfies the evaluated expression.
```javascript
const members = [
{ graduation_year: 2013, name: 'Jay' },
{ graduation_year: 2014, name: 'John' },
{ graduation_year: 2014, name: 'Jack' }
]
```
Input
```liquid
{{ members | has_exp: "item", "item.graduation_year == 2014" | json }}
```
Output
```text
true
```
+118
View File
@@ -0,0 +1,118 @@
---
title: reject
---
{% since %}v10.21.0{% endsince %}
Creates an array excluding the objects with a given property value, or excluding [truthy][truthy] values by default when a property is not given.
In this example, assume you have a list of products and you want to filter out kitchen products. Using `reject`, you can create an array excluding only the products that have a `"type"` of `"kitchen"`.
Input
```liquid
All products:
{% for product in products %}
- {{ product.title }}
{% endfor %}
{% assign non_kitchen_products = products | reject: "type", "kitchen" %}
Kitchen products:
{% for product in non_kitchen_products %}
- {{ product.title }}
{% endfor %}
```
Output
```text
All products:
- Vacuum
- Spatula
- Television
- Garlic press
Kitchen products:
- Vacuum
- Television
```
Say instead you have a list of products and you want to exclude taxable products. You can `reject` with a property name but no target value to reject all products with a [truthy][truthy] `"taxable"` value.
Input
```liquid
All products:
{% for product in products %}
- {{ product.title }}
{% endfor %}
{% assign not_taxed_products = products | reject: "taxable" %}
Available products:
{% for product in not_taxed_products %}
- {{ product.title }}
{% endfor %}
```
Output
```text
All products:
- Vacuum
- Spatula
- Television
- Garlic press
Available products:
- Spatula
- Television
```
Additionally, `property` can be any valid Liquid variable expression as used in output syntax, except that the scope of this expression is within each item. For the following `products` array:
```javascript
const products = [
{ meta: { details: { class: 'A' } }, order: 1 },
{ meta: { details: { class: 'B' } }, order: 2 },
{ meta: { details: { class: 'B' } }, order: 3 }
]
```
Input
```liquid
{% assign selected = products | reject: 'meta.details["class"]', "B" %}
{% for item in selected -%}
- {{ item.order }}
{% endfor %}
```
Output
```text
- 1
```
## Jekyll style
{% since %}v10.21.0{% endsince %}
For Liquid users migrating from Jekyll, there's a `jekyllWhere` option to mimic the behavior of Jekyll's `where` filter. This option is set to `false` by default. When enabled, if `property` is an array, the target value is matched using `Array.includes` instead of `==`, which is particularly useful for excluding tags.
```javascript
const pages = [
{ tags: ["cat", "food"], title: 'Cat Food' },
{ tags: ["dog", "food"], title: 'Dog Food' },
]
```
Input
```liquid
{% assign selected = pages | reject: 'tags', "cat" %}
{% for item in selected -%}
- {{ item.title }}
{% endfor %}
```
Output
```text
Dog Food
```
[truthy]: ../tutorials/truthy-and-falsy.html
+37
View File
@@ -0,0 +1,37 @@
---
title: reject_exp
---
{% since %}v10.21.0{% endsince %}
Select all the objects in an array where the expression is false. In this example, assume you have a list of products and you want to hide your kitchen products. Using `reject_exp`, you can create an array that omits only the products that have a `"type"` of `"kitchen"`.
Input
```liquid
All products:
{% for product in products %}
- {{ product.title }}
{% endfor %}
{% assign non_kitchen_products = products | reject_exp: "item", "item.type == 'kitchen'" %}
Kitchen products:
{% for product in non_kitchen_products %}
- {{ product.title }}
{% endfor %}
```
Output
```text
All products:
- Vacuum
- Spatula
- Television
- Garlic press
Kitchen products:
- Vacuum
- Television
```
[truthy]: ../tutorials/truthy-and-falsy.html
+5 -3
View File
@@ -37,6 +37,7 @@ Kitchen products:
```
Say instead you have a list of products and you only want to show those that are available to buy. You can `where` with a property name but no target value to include all products with a [truthy][truthy] `"available"` value.
As a special case, the same will happen if the target value is given but evaluates to `undefined`.
Input
```liquid
@@ -70,7 +71,6 @@ The `where` filter can also be used to find a single object in an array when com
Input
```liquid
{% assign new_shirt = products | where: "type", "shirt" | first %}
Featured product: {{ new_shirt.title }}
```
@@ -105,9 +105,11 @@ Output
## Jekyll style
{% since %}v10.19.0{% endsince %}
{% since %}v10.21.0{% endsince %}
For Liquid users migrating from Jekyll, there's a `jekyllWhere` option to mimic the behavior of Jekyll's `where` filter. This option is set to `false` by default. When enabled, if `property` is an array, the target value is matched using `Array.includes` instead of `==`, which is particularly useful for filtering tags.
For Liquid users migrating from Jekyll, there's a `jekyllWhere` option to mimic the behavior of Jekyll's `where` filter. This option is set to `false` by default. When enabled, if `property` is an array, the target value is matched using `Array.includes` instead of `==`, which is particularly useful for filtering tags. Additionally, a target value of `undefined` is treated normally, entries matched are exactly those which are themselves `undefined`.
This option affects other array selection filters as well, such as `reject` and `find`.
```javascript
const pages = [
+70 -18
View File
@@ -117,7 +117,17 @@ export function slice<T> (this: FilterImpl, v: T[] | string, begin: number, leng
return v.slice(begin, begin + length)
}
export function * where<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
function expectedMatcher (this: FilterImpl, expected: any): (v: any) => boolean {
if (this.context.opts.jekyllWhere) {
return (v: any) => EmptyDrop.is(expected) ? equals(v, expected) : (isArray(v) ? arrayIncludes(v, expected) : equals(v, expected))
} else if (expected === undefined) {
return (v: any) => isTruthy(v, this.context)
} else {
return (v: any) => equals(v, expected)
}
}
function * filter<T extends object> (this: FilterImpl, include: boolean, arr: T[], property: string, expected: any): IterableIterator<unknown> {
const values: unknown[] = []
arr = toArray(arr)
this.context.memoryLimit.use(arr.length)
@@ -125,16 +135,11 @@ export function * where<T extends object> (this: FilterImpl, arr: T[], property:
for (const item of arr) {
values.push(yield evalToken(token, this.context.spawn(item)))
}
const matcher = this.context.opts.jekyllWhere
? (v: any) => EmptyDrop.is(expected) ? equals(v, expected) : (isArray(v) ? arrayIncludes(v, expected) : equals(v, expected))
: (v: any) => equals(v, expected)
return arr.filter((_, i) => {
if (expected === undefined) return isTruthy(values[i], this.context)
return matcher(values[i])
})
const matcher = expectedMatcher.call(this, expected)
return arr.filter((_, i) => matcher(values[i]) === include)
}
export function * where_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
function * filter_exp<T extends object> (this: FilterImpl, include: boolean, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
const filtered: unknown[] = []
const keyTemplate = new Value(stringify(exp), this.liquid)
const array = toArray(arr)
@@ -143,11 +148,27 @@ export function * where_exp<T extends object> (this: FilterImpl, arr: T[], itemN
this.context.push({ [itemName]: item })
const value = yield keyTemplate.value(this.context)
this.context.pop()
if (value) filtered.push(item)
if (value === include) filtered.push(item)
}
return filtered
}
export function * where<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
return yield * filter.call(this, true, arr, property, expected)
}
export function * reject<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
return yield * filter.call(this, false, arr, property, expected)
}
export function * where_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
return yield * filter_exp.call(this, true, arr, itemName, exp)
}
export function * reject_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
return yield * filter_exp.call(this, false, arr, itemName, exp)
}
export function * group_by<T extends object> (this: FilterImpl, arr: T[], property: string): IterableIterator<unknown> {
const map = new Map()
arr = toEnumerable(arr)
@@ -176,26 +197,57 @@ export function * group_by_exp<T extends object> (this: FilterImpl, arr: T[], it
return [...map.entries()].map(([name, items]) => ({ name, items }))
}
export function * find<T extends object> (this: FilterImpl, arr: T[], property: string, expected: string): IterableIterator<unknown> {
function * search<T extends object> (this: FilterImpl, arr: T[], property: string, expected: string): IterableIterator<unknown> {
const token = new Tokenizer(stringify(property)).readScopeValue()
const array = toArray(arr)
for (const item of array) {
const value = yield evalToken(token, this.context.spawn(item))
if (equals(value, expected)) return item
const matcher = expectedMatcher.call(this, expected)
for (let index = 0; index < array.length; index++) {
const value = yield evalToken(token, this.context.spawn(array[index]))
if (matcher(value)) return [index, array[index]]
}
}
export function * find_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
function * search_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
const predicate = new Value(stringify(exp), this.liquid)
const array = toArray(arr)
for (const item of array) {
this.context.push({ [itemName]: item })
for (let index = 0; index < array.length; index++) {
this.context.push({ [itemName]: array[index] })
const value = yield predicate.value(this.context)
this.context.pop()
if (value) return item
if (value) return [index, array[index]]
}
}
export function * has<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
const result = yield * search.call(this, arr, property, expected)
return !!result
}
export function * has_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
const result = yield * search_exp.call(this, arr, itemName, exp)
return !!result
}
export function * find_index<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
const result = yield * search.call(this, arr, property, expected)
return result ? result[0] : undefined
}
export function * find_index_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
const result = yield * search_exp.call(this, arr, itemName, exp)
return result ? result[0] : undefined
}
export function * find<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
const result = yield * search.call(this, arr, property, expected)
return result ? result[1] : undefined
}
export function * find_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
const result = yield * search_exp.call(this, arr, itemName, exp)
return result ? result[1] : undefined
}
export function uniq<T> (this: FilterImpl, arr: T[]): T[] {
arr = toArray(arr)
this.context.memoryLimit.use(arr.length)
+259 -2
View File
@@ -417,6 +417,32 @@ describe('filters/array', function () {
- Boring sneakers
`)
})
it('should support filter with undefined target', function () {
return test(`{% assign typed_products = products | where: "type", notdefined %}
Typed products:
{% for product in typed_products -%}
- {{ product.title }}
{% endfor %}`, { products }, `
Typed products:
- Vacuum
- Spatula
- Television
- Garlic press
`)
})
it('should support no target', function () {
return test(`{% assign typed_products = products | where: "type" %}
Typed products:
{% for product in typed_products -%}
- {{ product.title }}
{% endfor %}`, { products }, `
Typed products:
- Vacuum
- Spatula
- Television
- Garlic press
`)
})
it('should support nested property', async function () {
const authors = [
{ name: 'Alice', books: { year: 2019 } },
@@ -491,7 +517,7 @@ describe('filters/array', function () {
await test('{{objs | where: "foo", "FOO" | json}}', scope, '[]')
})
describe('jekyll style', () => {
it('should not match string with array', async () => {
it('should filter arrays by inclusion', async () => {
const scope = { objs: [{ foo: ['FOO', 'bar'] }] }
await test('{{objs | where: "foo", "FOO" | json}}', scope, '[{"foo":["FOO","bar"]}]', { jekyllWhere: true })
})
@@ -499,6 +525,14 @@ describe('filters/array', function () {
const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] }
await test('{{pages | where: "tags", empty | json}}', scope, '[{"tags":[]}]', { jekyllWhere: true })
})
it('should filter by undefined when target is omitted', async () => {
await test('{{products | where: "type" | map: "title" | join: ","}}', { products },
'Coffee mug,Limited edition sneakers,Boring sneakers', { jekyllWhere: true })
})
it('should filter plainly when target is undefined', async () => {
await test('{{products | where: "type", notdefined | map: "title" | join: ","}}', { products },
'Coffee mug,Limited edition sneakers,Boring sneakers', { jekyllWhere: true })
})
})
})
describe('where_exp', function () {
@@ -537,6 +571,89 @@ describe('filters/array', function () {
return test(tpl, scope, html)
})
})
describe('reject', function () {
const products = [
{ title: 'Vacuum', type: 'living room' },
{ title: 'Spatula', type: 'kitchen' },
{ title: 'Television', type: 'living room' },
{ title: 'Garlic press', type: 'kitchen' },
{ title: 'Coffee mug', available: true },
{ title: 'Limited edition sneakers', available: false },
{ title: 'Boring sneakers', available: true }
]
it('should support reject by property value', function () {
return test(`{% assign kitchen_products = products | reject: "type", "kitchen" %}
Kitchen products:
{% for product in kitchen_products -%}
- {{ product.title }}
{% endfor %}`, { products }, `
Kitchen products:
- Vacuum
- Television
- Coffee mug
- Limited edition sneakers
- Boring sneakers
`)
})
it('should support reject truthy property', function () {
return test(`{% assign unavailable_products = products | reject: "available" %}
Unavailable products:
{% for product in unavailable_products -%}
- {{ product.title }}
{% endfor %}`, { products }, `
Unavailable products:
- Vacuum
- Spatula
- Television
- Garlic press
- Limited edition sneakers
`)
})
it('should support reject by string property', function () {
return test(`{% assign untyped_products = products | reject: "type" %}
Untyped products:
{% for product in untyped_products -%}
- {{ product.title }}
{% endfor %}`, { products }, `
Untyped products:
- Coffee mug
- Limited edition sneakers
- Boring sneakers
`)
})
describe('jekyll style', () => {
it('should filter arrays by exclusion', async () => {
const scope = { objs: [{ foo: ['FOO', 'bar'] }, { foo: ['bar', 'baz'] }, { foo: ['FOO'] }] }
await test('{{objs | reject: "foo", "FOO" | json}}', scope, '[{"foo":["bar","baz"]}]', { jekyllWhere: true })
})
it('should filter by undefined when target is omitted', async () => {
await test('{{products | reject: "type" | map: "title" | join: ","}}', { products },
'Vacuum,Spatula,Television,Garlic press', { jekyllWhere: true })
})
})
})
describe('reject_exp', function () {
const products = [
{ title: 'Vacuum', type: 'living room' },
{ title: 'Spatula', type: 'kitchen' },
{ title: 'Television', type: 'living room' },
{ title: 'Garlic press', type: 'kitchen' },
{ title: 'Coffee mug', available: true },
{ title: 'Limited edition sneakers', available: false },
{ title: 'Boring sneakers', available: true }
]
it('should support reject by exp', function () {
return test(`{% assign kitchen_products = products | reject_exp: "item", "item.type != 'kitchen'" %}
Kitchen products:
{% for product in kitchen_products -%}
- {{ product.title }}
{% endfor %}`, { products }, `
Kitchen products:
- Spatula
- Garlic press
`)
})
})
describe('group_by', function () {
const members = [
{ graduation_year: 2003, name: 'Jay' },
@@ -616,12 +733,76 @@ describe('filters/array', function () {
JSON.stringify(expected))
})
})
describe('find', function () {
describe('has', function () {
const members = [
{ graduation_year: 2013, name: 'Jay' },
{ graduation_year: 2014, name: 'John' },
{ graduation_year: 2014, name: 'Jack', age: 13 }
]
it('should support has with no value', function () {
return test(
`{{ members | has: "age" | json }}, {{ members | has: "height" | json }}`,
{ members },
`true, false`)
})
it('should support has by property', function () {
return test(
`{{ members | has: "graduation_year", 2014 | json }}`,
{ members },
`true`)
})
it('should return false if not found', function () {
return test(
`{{ members | has: "graduation_year", 2018 | json }}`,
{ members },
`false`)
})
describe('jekyll style', () => {
it('should select array by inclusion', async () => {
const scope = { objs: [{ foo: ['FOO', 'bar'] }] }
await test('{{objs | has: "foo", "FOO" | json}}', scope, 'true', { jekyllWhere: true })
})
it('should support empty as target', async () => {
const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] }
await test('{{pages | has: "tags", empty | json}}', scope, 'true', { jekyllWhere: true })
})
it('should search plainly when target is undefined', async () => {
await test('{{members | has: "age", notdefined}}', { members }, 'true', { jekyllWhere: true })
await test('{{members | has: "name", notdefined}}', { members }, 'false', { jekyllWhere: true })
})
})
})
describe('has_exp', function () {
const members = [
{ graduation_year: 2013, name: 'Jay' },
{ graduation_year: 2014, name: 'John' },
{ graduation_year: 2014, name: 'Jack' }
]
it('should support has by expression', function () {
return test(
`{{ members | has_exp: "item", "item.graduation_year == 2014" | json }}`,
{ members },
`true`)
})
it('should return false if not found', function () {
return test(
`{{ members | has_exp: "item", "item.graduation_year == 2018" | json }}`,
{ members },
`false`)
})
})
describe('find', function () {
const members = [
{ graduation_year: 2013, name: 'Jay' },
{ graduation_year: 2014, name: 'John' },
{ graduation_year: 2014, name: 'Jack', age: 13 }
]
it('should support find with no value', function () {
return test(
`{{ members | find: "age" | json }}`,
{ members },
`{"graduation_year":2014,"name":"Jack","age":13}`)
})
it('should support find by property', function () {
return test(
`{{ members | find: "graduation_year", 2014 | json }}`,
@@ -634,6 +815,24 @@ describe('filters/array', function () {
{ members },
``)
})
describe('jekyll style', () => {
it('should select array by inclusion', async () => {
const scope = { objs: [{ foo: ['FOO', 'bar'] }] }
await test('{{objs | find: "foo", "FOO" | json}}', scope, '{"foo":["FOO","bar"]}', { jekyllWhere: true })
})
it('should support empty as target', async () => {
const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] }
await test('{{pages | find: "tags", empty | json}}', scope, '{"tags":[]}', { jekyllWhere: true })
})
it('should search plainly when target is undefined', async () => {
await test(
'{{members | find: "age", notdefined | json}}',
{ members },
'{"graduation_year":2013,"name":"Jay"}',
{ jekyllWhere: true })
await test('{{members | find: "name", notdefined | json}}', { members }, '', { jekyllWhere: true })
})
})
})
describe('find_exp', function () {
const members = [
@@ -654,4 +853,62 @@ describe('filters/array', function () {
``)
})
})
describe('find_index', function () {
const members = [
{ graduation_year: 2013, name: 'Jay' },
{ graduation_year: 2014, name: 'John' },
{ graduation_year: 2014, name: 'Jack', age: 13 }
]
it('should support find_index with no value', function () {
return test(
`{{ members | find_index: "age" | json }}`,
{ members },
`2`)
})
it('should support find_index by property', function () {
return test(
`{{ members | find_index: "graduation_year", 2014 | json }}`,
{ members },
`1`)
})
it('should render none if not found', function () {
return test(
`{{ members | find_index: "graduation_year", 2018 | json }}`,
{ members },
``)
})
describe('jekyll style', () => {
it('should select array by inclusion', async () => {
const scope = { objs: [{ foo: ['FOO', 'bar'] }] }
await test('{{objs | find_index: "foo", "FOO" | json}}', scope, '0', { jekyllWhere: true })
})
it('should support empty as target', async () => {
const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] }
await test('{{pages | find_index: "tags", empty | json}}', scope, '1', { jekyllWhere: true })
})
it('should search plainly when target is undefined', async () => {
await test('{{members | find_index: "age", notdefined | json}}', { members }, '0', { jekyllWhere: true })
await test('{{members | find_index: "name", notdefined | json}}', { members }, '', { jekyllWhere: true })
})
})
})
describe('find_index_exp', function () {
const members = [
{ graduation_year: 2013, name: 'Jay' },
{ graduation_year: 2014, name: 'John' },
{ graduation_year: 2014, name: 'Jack' }
]
it('should support find_index by expression', function () {
return test(
`{{ members | find_index_exp: "item", "item.graduation_year == 2014" | json }}`,
{ members },
`1`)
})
it('should render none if not found', function () {
return test(
`{{ members | find_index_exp: "item", "item.graduation_year == 2018" | json }}`,
{ members },
``)
})
})
})