Skip to content

Commit 88ce388

Browse files
authored
Merge pull request #146 from michaelhvisser/feat/add-shopify-standard-filters
feat: add standard Shopify Liquid filters
2 parents eb7231b + f5f040c commit 88ce388

4 files changed

Lines changed: 165 additions & 0 deletions

File tree

filters/standard_filters.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo
192192
return a[len(a)-1]
193193
})
194194
fd.AddFilter("uniq", uniqFilter)
195+
fd.AddFilter("where", whereFilter)
196+
fd.AddFilter("sum", sumFilter)
195197

196198
// date filters
197199
fd.AddFilter("date", func(t time.Time, format func(string) string) (string, error) {
@@ -279,11 +281,47 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo
279281

280282
return math.Floor(n*exp+0.5) / exp
281283
})
284+
fd.AddFilter("at_least", func(a, b any) any {
285+
if isIntegerType(a) && isIntegerType(b) {
286+
ai, bi := toInt64(a), toInt64(b)
287+
if ai < bi {
288+
return bi
289+
}
290+
return ai
291+
}
292+
af, bf := toFloat64(a), toFloat64(b)
293+
if af < bf {
294+
return bf
295+
}
296+
return af
297+
})
298+
fd.AddFilter("at_most", func(a, b any) any {
299+
if isIntegerType(a) && isIntegerType(b) {
300+
ai, bi := toInt64(a), toInt64(b)
301+
if ai > bi {
302+
return bi
303+
}
304+
return ai
305+
}
306+
af, bf := toFloat64(a), toFloat64(b)
307+
if af > bf {
308+
return bf
309+
}
310+
return af
311+
})
282312

283313
// sequence filters
284314
fd.AddFilter("size", values.Length)
285315

286316
// string filters
317+
fd.AddFilter("pluralize", func(count any, singular, plural string) string {
318+
if toFloat64(count) == 1.0 {
319+
return singular
320+
}
321+
return plural
322+
})
323+
fd.AddFilter("handleize", handleizeFilter)
324+
fd.AddFilter("handle", handleizeFilter)
287325
fd.AddFilter("append", func(s, suffix string) string {
288326
return s + suffix
289327
})
@@ -313,10 +351,24 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo
313351
fd.AddFilter("remove_first", func(s, old string) string {
314352
return strings.Replace(s, old, "", 1)
315353
})
354+
fd.AddFilter("remove_last", func(s, old string) string {
355+
i := strings.LastIndex(s, old)
356+
if i < 0 {
357+
return s
358+
}
359+
return s[:i] + s[i+len(old):]
360+
})
316361
fd.AddFilter("replace", strings.ReplaceAll)
317362
fd.AddFilter("replace_first", func(s, old, n string) string {
318363
return strings.Replace(s, old, n, 1)
319364
})
365+
fd.AddFilter("replace_last", func(s, old, n string) string {
366+
i := strings.LastIndex(s, old)
367+
if i < 0 {
368+
return s
369+
}
370+
return s[:i] + n + s[i+len(old):]
371+
})
320372
fd.AddFilter("sort_natural", sortNaturalFilter)
321373
fd.AddFilter("slice", func(v interface{}, start int, length func(int) int) interface{} {
322374
// Are we in the []byte case? Transform []byte to string
@@ -493,6 +545,66 @@ func uniqFilter(a []any) (result []any) {
493545
return
494546
}
495547

548+
var handleizeRe = regexp.MustCompile(`[^a-z0-9]+`)
549+
550+
var omittedWhereTarget = &struct{}{}
551+
552+
func handleizeFilter(s string) string {
553+
s = strings.ToLower(s)
554+
s = handleizeRe.ReplaceAllString(s, "-")
555+
s = strings.Trim(s, "-")
556+
return s
557+
}
558+
559+
func whereFilter(a []any, key string, targetValue func(any) any) (result []any) {
560+
keyValue := values.ValueOf(key)
561+
target := targetValue(omittedWhereTarget)
562+
for _, obj := range a {
563+
value := values.ValueOf(obj)
564+
prop := value.PropertyValue(keyValue).Interface()
565+
if target == omittedWhereTarget {
566+
// One-arg form: truthy check
567+
if prop != nil && prop != false {
568+
result = append(result, obj)
569+
}
570+
} else {
571+
// Two-arg form: equality check using Liquid-compatible
572+
// comparison (handles nil, mixed int/float, etc.)
573+
if values.Equal(prop, target) {
574+
result = append(result, obj)
575+
}
576+
}
577+
}
578+
return
579+
}
580+
581+
func sumFilter(a []any, key func(string) string) any {
582+
prop := key("")
583+
allInts := true
584+
var intTotal int64
585+
var floatTotal float64
586+
for _, item := range a {
587+
if prop != "" {
588+
v := values.ValueOf(item)
589+
item = v.PropertyValue(values.ValueOf(prop)).Interface()
590+
}
591+
if allInts && isIntegerType(item) {
592+
intTotal += toInt64(item)
593+
} else {
594+
if allInts {
595+
// Switch to float, carry over the int total so far
596+
floatTotal = float64(intTotal)
597+
allInts = false
598+
}
599+
floatTotal += toFloat64(item)
600+
}
601+
}
602+
if allInts {
603+
return intTotal
604+
}
605+
return floatTotal
606+
}
607+
496608
func eqItems(a, b any) bool {
497609
if reflect.TypeOf(a).Comparable() && reflect.TypeOf(b).Comparable() {
498610
return a == b

filters/standard_filters_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ var filterTests = []struct {
5959
{`dup_ints | uniq | join`, "1 2 3"},
6060
{`dup_strings | uniq | join`, "one two three"},
6161
{`dup_maps | uniq | map: "name" | join`, "m1 m2 m3"},
62+
// where
63+
{`products | where: "available" | map: "title" | join: ", "`, "Shirt, Pants"},
64+
{`products | where: "type", "Shirt" | map: "title" | join: ", "`, "Shirt"},
65+
{`products | where: "price", 10.0 | map: "title" | join: ", "`, "Shirt"},
66+
{`products | where: "price", 10 | map: "title" | join: ", "`, "Shirt"},
67+
{`products | where: "price", nil | map: "title" | join: ", "`, "Hat"},
68+
// sum
69+
{`"1,2,3" | split: "," | sum`, 6.0},
70+
{`prices | sum`, int64(30)},
71+
{`products | sum: "price"`, 30.0},
72+
6273
{`mixed_case_array | sort_natural | join`, "a B c"},
6374
{`mixed_case_hash_values | sort_natural: 'key' | map: 'key' | join`, "a B c"},
6475

@@ -97,6 +108,22 @@ var filterTests = []struct {
97108
{`"Straße" | size`, 6},
98109

99110
// string filters
111+
// pluralize
112+
{`1 | pluralize: "item", "items"`, "item"},
113+
{`2 | pluralize: "item", "items"`, "items"},
114+
{`0 | pluralize: "item", "items"`, "items"},
115+
{`1.0 | pluralize: "item", "items"`, "item"},
116+
{`1.5 | pluralize: "item", "items"`, "items"},
117+
{`"1" | pluralize: "item", "items"`, "item"},
118+
{`"2" | pluralize: "item", "items"`, "items"},
119+
120+
// handleize / handle
121+
{`"100% M & Ms!!!" | handleize`, "100-m-ms"},
122+
{`"Hello World" | handleize`, "hello-world"},
123+
{`"" | handleize`, ""},
124+
{`"---already---" | handleize`, "already"},
125+
{`"Hello World" | handle`, "hello-world"},
126+
100127
{`"Take my protein pills and put my helmet on" | replace: "my", "your"`, "Take your protein pills and put your helmet on"},
101128
{`"Take my protein pills and put my helmet on" | replace_first: "my", "your"`, "Take your protein pills and put my helmet on"},
102129
{`"/my/fancy/url" | append: ".html"`, "/my/fancy/url.html"},
@@ -112,6 +139,10 @@ var filterTests = []struct {
112139
{`"apples, oranges, and bananas" | prepend: "Some fruit: "`, "Some fruit: apples, oranges, and bananas"},
113140
{`"I strained to see the train through the rain" | remove: "rain"`, "I sted to see the t through the "},
114141
{`"I strained to see the train through the rain" | remove_first: "rain"`, "I sted to see the train through the rain"},
142+
{`"Hello Hello Hello" | remove_last: "Hello"`, "Hello Hello "},
143+
{`"Hello" | remove_last: "xyz"`, "Hello"},
144+
{`"Hello Hello Hello" | replace_last: "Hello", "Goodbye"`, "Hello Hello Goodbye"},
145+
{`"Hello" | replace_last: "xyz", "abc"`, "Hello"},
115146

116147
{`"Liquid" | slice: 0`, "L"},
117148
{`"Liquid
@@ -236,6 +267,12 @@ Liquid" | slice: 2, 4`, "quid"},
236267
{`str_int | plus: 1`, 11.0},
237268
{`str_float | plus: 1.0`, 4.5},
238269

270+
// at_least / at_most
271+
{`4 | at_least: 5`, int64(5)},
272+
{`6 | at_least: 5`, int64(6)},
273+
{`4 | at_most: 5`, int64(4)},
274+
{`6 | at_most: 5`, int64(5)},
275+
239276
{`3 | modulo: 2`, 1.0},
240277
{`24 | modulo: 7`, 3.0},
241278
// {`183.357 | modulo: 12 | `, 3.357}, // TODO test suit use inexact
@@ -290,6 +327,12 @@ var filterTestBindings = map[string]any{
290327
{"weight": 3},
291328
{"weight": nil},
292329
},
330+
"products": []map[string]any{
331+
{"title": "Shirt", "type": "Shirt", "price": 10.0, "available": true},
332+
{"title": "Pants", "type": "Pants", "price": 20.0, "available": true},
333+
{"title": "Hat", "type": "Hat", "price": nil, "available": false},
334+
},
335+
"prices": []any{10, 20},
293336
"string_with_newlines": "\nHello\nthere\n",
294337
"dup_ints": []int{1, 2, 1, 3},
295338
"dup_strings": []string{"one", "two", "one", "three"},

values/call.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ func isDefaultFunctionType(typ reflect.Type) bool {
9999

100100
func makeConstantFunction(typ reflect.Type, arg any) reflect.Value {
101101
return reflect.MakeFunc(typ, func(args []reflect.Value) []reflect.Value {
102+
if arg == nil {
103+
return []reflect.Value{reflect.Zero(typ.Out(0))}
104+
}
102105
return []reflect.Value{reflect.ValueOf(MustConvert(arg, typ.Out(0)))}
103106
})
104107
}

values/call_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ func TestCall_optional(t *testing.T) {
5353
value, err = Call(reflect.ValueOf(fn), []any{5, 10})
5454
require.NoError(t, err)
5555
require.Equal(t, "5,10.", value)
56+
57+
nilDefault := func(value func(any) any) any {
58+
return value("default")
59+
}
60+
value, err = Call(reflect.ValueOf(nilDefault), []any{nil})
61+
require.NoError(t, err)
62+
require.Nil(t, value)
5663
}
5764

5865
func TestCall_variadic(t *testing.T) {

0 commit comments

Comments
 (0)