Skip to content

Commit

Permalink
fix: coerce to Array in map and where filter
Browse files Browse the repository at this point in the history
  • Loading branch information
harttle committed Mar 31, 2020
1 parent d65ed40 commit c923598
Show file tree
Hide file tree
Showing 12 changed files with 187 additions and 284 deletions.
9 changes: 5 additions & 4 deletions src/builtin/filters/array.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isArray, last as arrayLast } from '../../util/underscore'
import { toArray } from '../../util/collection'
import { isTruthy } from '../../render/boolean'
import { FilterImpl } from '../../template/filter/filter-impl'

Expand All @@ -10,11 +11,11 @@ export const sort = <T>(v: T[], arg: (lhs: T, rhs: T) => number) => v.sort(arg)
export const size = (v: string | any[]) => (v && v.length) || 0

export function map<T1, T2> (arr: {[key: string]: T1}[], arg: string): T1[] {
return arr.map(v => v[arg])
return toArray(arr).map(v => v[arg])
}

export function concat<T1, T2> (v: T1[], arg: T2[] | T2): (T1 | T2)[] {
return Array.prototype.concat.call(v, arg)
return toArray(v).concat(arg)
}

export function slice<T> (v: T[], begin: number, length = 1): T[] {
Expand All @@ -23,8 +24,8 @@ export function slice<T> (v: T[], begin: number, length = 1): T[] {
}

export function where<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): T[] {
return arr.filter(obj => {
const value = this.context.getFromScope(obj, property.split('.'))
return toArray(arr).filter(obj => {
const value = this.context.getFromScope(obj, String(property).split('.'))
return expected === undefined ? isTruthy(value) : value === expected
})
}
Expand Down
4 changes: 2 additions & 2 deletions src/builtin/tags/for.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assert, Tokenizer, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { toCollection } from '../../util/collection'
import { toEnumerable } from '../../util/collection'
import { ForloopDrop } from '../../drop/forloop-drop'
import { Hash } from '../../template/tag/hash'

Expand Down Expand Up @@ -36,7 +36,7 @@ export default {
},
render: function * (ctx: Context, emitter: Emitter) {
const r = this.liquid.renderer
let collection = toCollection(evalToken(this.collection, ctx))
let collection = toEnumerable(evalToken(this.collection, ctx))

if (!collection.length) {
yield r.renderTemplates(this.elseTemplates, ctx, emitter)
Expand Down
4 changes: 2 additions & 2 deletions src/builtin/tags/render.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { assert } from '../../util/assert'
import { ForloopDrop } from '../../drop/forloop-drop'
import { toCollection } from '../../util/collection'
import { toEnumerable } from '../../util/collection'
import { evalQuotedToken, TypeGuards, Tokenizer, evalToken, Hash, Emitter, TagToken, Context, TagImplOptions } from '../../types'

export default {
Expand Down Expand Up @@ -60,7 +60,7 @@ export default {
if (this['for']) {
const { value, alias } = this['for']
let collection = evalToken(value, ctx)
collection = toCollection(collection)
collection = toEnumerable(collection)
scope['forloop'] = new ForloopDrop(collection.length)
for (const item of collection) {
scope[alias] = item
Expand Down
4 changes: 2 additions & 2 deletions src/builtin/tags/tablerow.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { toCollection } from '../../util/collection'
import { toEnumerable } from '../../util/collection'
import { assert, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types'
import { TablerowloopDrop } from '../../drop/tablerowloop-drop'
import { Tokenizer } from '../../parser/tokenizer'
Expand Down Expand Up @@ -30,7 +30,7 @@ export default {
},

render: function * (ctx: Context, emitter: Emitter) {
let collection = toCollection(evalToken(this.collection, ctx))
let collection = toEnumerable(evalToken(this.collection, ctx))
const hash = yield this.hash.render(ctx)
const offset = hash.offset || 0
const limit = (hash.limit === undefined) ? collection.length : hash.limit
Expand Down
1 change: 0 additions & 1 deletion src/template/value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export class Value {
const tokenizer = new Tokenizer(str)
this.initial = tokenizer.readValue()
this.filters = tokenizer.readFilters().map(({ name, args }) => new Filter(name, this.filterMap.get(name), args))
tokenizer.skipBlank()
}
public * value (ctx: Context) {
let val = yield evalToken(this.initial, ctx)
Expand Down
7 changes: 6 additions & 1 deletion src/util/collection.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { isString, isObject, isArray } from './underscore'

export function toCollection (val: any) {
export function toEnumerable (val: any) {
if (isArray(val)) return val
if (isString(val) && val.length > 0) return [val]
if (isObject(val)) return Object.keys(val).map((key) => [key, val[key]])
return []
}

export function toArray (val: any) {
if (isArray(val)) return val
return [ val ]
}
214 changes: 97 additions & 117 deletions test/integration/builtin/filters/array.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import { test } from '../../../stub/render'
import { Liquid } from '../../../../src/liquid'
import { test, render } from '../../../stub/render'
import { expect, use } from 'chai'
import * as chaiAsPromised from 'chai-as-promised'
use(chaiAsPromised)

describe('filters/array', function () {
let liquid: Liquid
beforeEach(function () {
liquid = new Liquid()
})
describe('index', function () {
it('should support index', function () {
const src = '{% assign beatles = "John, Paul, George, Ringo" | split: ", " %}' +
Expand All @@ -30,109 +25,78 @@ describe('filters/array', function () {
it('should throw when comma missing', async () => {
const src = '{% assign beatles = "John, Paul, George, Ringo" | split: ", " %}' +
'{{ beatles | join " and " }}'
return expect(liquid.parseAndRender(src)).to.be.rejectedWith('unexpected token at "\\" and \\"", line:1, col:65')
return expect(render(src)).to.be.rejectedWith('unexpected token at "\\" and \\"", line:1, col:65')
})
})
it('should support split/last', function () {
const src = '{% assign my_array = "zebra, octopus, giraffe, tiger" | split: ", " %}' +
'{{ my_array|last }}'
return test(src, 'tiger')
})
it('should support map', function () {
return test('{{posts | map: "category"}}', 'foo,bar')
describe('map', () => {
it('should support map', function () {
const posts = [{ category: 'foo' }, { category: 'bar' }]
return test('{{posts | map: "category"}}', { posts }, 'foo,bar')
})
it('should normalize non-array input', function () {
const post = { category: 'foo' }
return test('{{post | map: "category"}}', { post }, 'foo')
})
})
describe('reverse', function () {
it('should support reverse', async function () {
const html = await liquid.parseAndRender('{{ "Ground control to Major Tom." | split: "" | reverse | join: "" }}')
expect(html).to.equal('.moT rojaM ot lortnoc dnuorG')
})
it('should be pure', async function () {
it('should support reverse', () => test(
'{{ "Ground control to Major Tom." | split: "" | reverse | join: "" }}',
'.moT rojaM ot lortnoc dnuorG'
))
it('should be pure', async () => {
const scope = { arr: ['a', 'b', 'c'] }
await liquid.parseAndRender('{{ arr | reverse | join: "" }}', scope)
const html = await liquid.parseAndRender('{{ arr | join: "" }}', scope)
await render('{{ arr | reverse | join: "" }}', scope)
const html = await render('{{ arr | join: "" }}', scope)
expect(html).to.equal('abc')
})
})
describe('size', function () {
it('should return string length', async () => {
const html = await liquid.parseAndRender('{{ "Ground control to Major Tom." | size }}')
expect(html).to.equal('28')
})
it('should return array size', async () => {
const html = await liquid.parseAndRender(
'{% assign my_array = "apples, oranges, peaches, plums" | split: ", " %}{{ my_array | size }}')
expect(html).to.equal('4')
})
it('should be respected with <string>.size notation', async () => {
const html = await liquid.parseAndRender('{% assign my_string = "Ground control to Major Tom." %}{{ my_string.size }}')
expect(html).to.equal('28')
})
it('should be respected with <array>.size notation', async () => {
const html = await liquid.parseAndRender('{% assign my_array = "apples, oranges, peaches, plums" | split: ", " %}{{ my_array.size }}')
expect(html).to.equal('4')
})
it('should return 0 for false', async () => {
const html = await liquid.parseAndRender('{{ false | size }}')
expect(html).to.equal('0')
})
it('should return 0 for nil', async () => {
const html = await liquid.parseAndRender('{{ nil | size }}')
expect(html).to.equal('0')
})
it('should return 0 for undefined', async () => {
const html = await liquid.parseAndRender('{{ foo | size }}')
expect(html).to.equal('0')
})
it('should return string length', () => test(
'{{ "Ground control to Major Tom." | size }}',
'28'
))
it('should return array size', () => test(
'{% assign my_array = "apples, oranges, peaches, plums" | split: ", " %}{{ my_array | size }}',
'4'
))
it('should be respected with <string>.size notation', () => test(
'{% assign my_string = "Ground control to Major Tom." %}{{ my_string.size }}',
'28'
))
it('should be respected with <array>.size notation', () => test(
'{% assign my_array = "apples, oranges, peaches, plums" | split: ", " %}{{ my_array.size }}',
'4'
))
it('should return 0 for false', () => test('{{ false | size }}', '0'))
it('should return 0 for nil', () => test('{{ nil | size }}', '0'))
it('should return 0 for undefined', () => test('{{ foo | size }}', '0'))
})
describe('first', function () {
it('should support first', async () => {
const html = await liquid.parseAndRender(
'{{arr | first}}',
{ arr: [ 'zebra', 'tiger' ] }
)
expect(html).to.equal('zebra')
})
it('should return empty for nil', async () => {
const html = await liquid.parseAndRender('{{nil | first}}')
expect(html).to.equal('')
})
it('should return empty for undefined', async () => {
const html = await liquid.parseAndRender('{{foo | first}}')
expect(html).to.equal('')
})
it('should return empty for false', async () => {
const html = await liquid.parseAndRender('{{false | first}}')
expect(html).to.equal('')
})
it('should return empty for string', async () => {
const html = await liquid.parseAndRender('{{"zebra" | first}}')
expect(html).to.equal('')
})
it('should support first', () => test(
'{{arr | first}}',
{ arr: [ 'zebra', 'tiger' ] },
'zebra'
))
it('should return empty for nil', () => test('{{nil | first}}', ''))
it('should return empty for undefined', () => test('{{foo | first}}', ''))
it('should return empty for false', () => test('{{false | first}}', ''))
it('should return empty for string', () => test('{{"zebra" | first}}', ''))
})
describe('last', function () {
it('should support last', async () => {
const html = await liquid.parseAndRender(
'{{arr | last}}',
{ arr: [ 'zebra', 'tiger' ] }
)
expect(html).to.equal('tiger')
})
it('should return empty for nil', async () => {
const html = await liquid.parseAndRender('{{nil | last}}')
expect(html).to.equal('')
})
it('should return empty for undefined', async () => {
const html = await liquid.parseAndRender('{{foo | last}}')
expect(html).to.equal('')
})
it('should return empty for false', async () => {
const html = await liquid.parseAndRender('{{false | last}}')
expect(html).to.equal('')
})
it('should return empty for string', async () => {
const html = await liquid.parseAndRender('{{"zebra" | last}}')
expect(html).to.equal('')
})
it('should support last', () => test(
'{{arr | last}}',
{ arr: [ 'zebra', 'tiger' ] },
'tiger'
))
it('should return empty for nil', () => test('{{nil | last}}', ''))
it('should return empty for undefined', () => test('{{foo | last}}', ''))
it('should return empty for false', () => test('{{false | last}}', ''))
it('should return empty for string', () => test('{{"zebra" | last}}', ''))
})
describe('slice', function () {
it('should slice first char by 0', () => test('{{ "Liquid" | slice: 0 }}', 'L'))
Expand Down Expand Up @@ -161,44 +125,60 @@ describe('filters/array', function () {
})
})
describe('where', 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 filter by property value', function () {
return test(`{% assign kitchen_products = products | where: "type", "kitchen" %}
Kitchen products:
{% for product in kitchen_products -%}
- {{ product.title }}
{% endfor %}`, `
Kitchen products:
- Spatula
- Garlic press
`)
Kitchen products:
{% for product in kitchen_products -%}
- {{ product.title }}
{% endfor %}`, { products }, `
Kitchen products:
- Spatula
- Garlic press
`)
})
it('should support filter truthy property', function () {
return test(`{% assign available_products = products | where: "available" %}
Available products:
{% for product in available_products -%}
- {{ product.title }}
{% endfor %}`, `
Available products:
- Coffee mug
- Boring sneakers
`)
Available products:
{% for product in available_products -%}
- {{ product.title }}
{% endfor %}`, { products }, `
Available products:
- Coffee mug
- Boring sneakers
`)
})
it('should support nested property', async function () {
const authors = [
{ name: 'Alice', books: { year: 2019 } },
{ name: 'Bob', books: { year: 2018 } }
]
const html = await liquid.parseAndRender(
return test(
`{% assign recentAuthors = authors | where: 'books.year', 2019 %}
Recent Authors:
{%- for author in recentAuthors %}
- {{author.name}}
{%- endfor %}`,
{ authors }
)
expect(html).to.equal(`
Recent Authors:
- Alice`)
Recent Authors:
{%- for author in recentAuthors %}
- {{author.name}}
{%- endfor %}`,
{ authors }, `
Recent Authors:
- Alice`)
})
it('should apply to string', async () => {
await test('{{"abc" | where: 1, "b" }}', 'abc')
await test('{{"abc" | where: 1, "a" }}', '')
})
it('should normalize non-array input', async () => {
const scope = { obj: { foo: 'FOO' } }
await test('{{obj | where: "foo", "FOO" }}', scope, '[object Object]')
await test('{{obj | where: "foo", "BAR" }}', scope, '')
})
})
})
Loading

0 comments on commit c923598

Please sign in to comment.