Skip to content

Commit 168c3a6

Browse files
Copilotxenova
andauthored
[jinja] Implement sort filter with parameters (#1852)
- [x] Explore repository structure and understand codebase - [x] Implement enhanced sort filter with `reverse`, `case_sensitive`, and `attribute` parameters - [x] Add comprehensive tests for the new sort filter functionality - [x] Add dot notation support to map filter - [x] Fix reverse filter mutation issue - [x] Extract shared getAttributeValue helper function - [x] Add positional argument support for sort filter (matching Jinja2 API) - [x] Add unit tests for positional arguments - [x] Consolidate sort and dictsort with shared compareRuntimeValues helper - [x] Handle edge cases: null/null, undefined/undefined, and bool/number comparisons - [x] Fix tojson/string filter handling of undefined values - [x] Fix direct output `{{ value }}` to use proper JSON-like formatting for arrays and objects - [x] Consolidate `runtimeValueToString` and `toJSON` into a single helper function - [x] Run tests to validate implementation (515 passed, 1 network error expected) - [x] Run linting to ensure code quality ## Summary Created a shared `compareRuntimeValues` helper function that: - Allows `None` values to be compared with `None` values (they are equal) - Allows `Undefined` values to be compared with `Undefined` values (they are equal) - Allows booleans to be compared with numbers (false=0, true=1) - Allows integers and floats to be compared (both are numeric) - Throws errors for incompatible type comparisons - Handles case_sensitive option for string comparisons - Returns -1, 0, or 1 for use with sort functions Both `sort` and `dictsort` filters now use this shared helper for consistent comparison behavior. ### Output formatting: - Consolidated `runtimeValueToString` and `toJSON` into a single `toJSON` helper function - `toJSON(value, null, 0, false)` - outputs "undefined" for UndefinedValue (for direct output) - `toJSON(value)` - outputs "null" for UndefinedValue (for JSON serialization) - `ArrayValue` and `ObjectValue` override `toString()` to use `toJSON(this, null, 0, false)` ### Edge cases supported: - `{{ [None, None] | sort }}` → `[null, null]` - `{{ [a, b, c] | sort }}` (undefined vars) → `[undefined, undefined, undefined]` - `{{ [0, true, false, 1, 0.5] | sort }}` → `[0, false, 0.5, true, 1]` <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > Improve and fulfil the implementation of the sort filter for arrays, allowing you to pass various optional arguments, like `attribute`. > > Here is the documentation for the sort filter: > ``` > jinja-filters.sort(value: 't.Iterable[V]', reverse: bool = False, case_sensitive: bool = False, attribute: str | int | NoneType = None) → 't.List[V]' > Sort an iterable using Python’s sorted(). > > {% for city in cities|sort %} > ... > {% endfor %} > Parameters: > reverse – Sort descending instead of ascending. > > case_sensitive – When sorting strings, sort upper and lower case separately. > > attribute – When sorting objects or dicts, an attribute or key to sort by. Can use dot notation like "address.city". Can be a list of attributes like "age,name". > > The sort is stable, it does not change the relative order of elements that compare equal. This makes it is possible to chain sorts on different attributes and ordering. > > {% for user in users|sort(attribute="name") > |sort(reverse=true, attribute="age") %} > ... > {% endfor %} > As a shortcut to chaining when the direction is the same for all attributes, pass a comma separate list of attributes. > > {% for user in users|sort(attribute="age,name") %} > ... > {% endfor %} > ``` > > Be sure to add tests for your implementation. </details> <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/huggingface/huggingface.js/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: xenova <26504141+xenova@users.noreply.github.com> Co-authored-by: Joshua Lochner <admin@xenova.com>
1 parent a6e200a commit 168c3a6

File tree

2 files changed

+641
-100
lines changed

2 files changed

+641
-100
lines changed

packages/jinja/src/runtime.ts

Lines changed: 209 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,58 @@ export class BooleanValue extends RuntimeValue<boolean> {
272272
override type = "BooleanValue";
273273
}
274274

275+
/**
276+
* Converts a runtime value to its JSON string representation.
277+
* @param input The runtime value to convert
278+
* @param indent Optional indentation for pretty-printing
279+
* @param depth Current recursion depth (used for indentation)
280+
* @param convertUndefinedToNull If true, undefined becomes "null" (JSON-safe). If false, undefined becomes "undefined".
281+
*/
282+
function toJSON(
283+
input: AnyRuntimeValue,
284+
indent?: number | null,
285+
depth?: number,
286+
convertUndefinedToNull: boolean = true
287+
): string {
288+
const currentDepth = depth ?? 0;
289+
switch (input.type) {
290+
case "NullValue":
291+
return "null";
292+
case "UndefinedValue":
293+
return convertUndefinedToNull ? "null" : "undefined";
294+
case "IntegerValue":
295+
case "FloatValue":
296+
case "StringValue":
297+
case "BooleanValue":
298+
return JSON.stringify(input.value);
299+
case "ArrayValue":
300+
case "ObjectValue": {
301+
const indentValue = indent ? " ".repeat(indent) : "";
302+
const basePadding = "\n" + indentValue.repeat(currentDepth);
303+
const childrenPadding = basePadding + indentValue; // Depth + 1
304+
305+
if (input.type === "ArrayValue") {
306+
const core = (input as ArrayValue).value.map((x) =>
307+
toJSON(x, indent, currentDepth + 1, convertUndefinedToNull)
308+
);
309+
return indent
310+
? `[${childrenPadding}${core.join(`,${childrenPadding}`)}${basePadding}]`
311+
: `[${core.join(", ")}]`;
312+
} else {
313+
// ObjectValue
314+
const core = Array.from((input as ObjectValue).value.entries()).map(([key, value]) => {
315+
const v = `"${key}": ${toJSON(value, indent, currentDepth + 1, convertUndefinedToNull)}`;
316+
return indent ? `${childrenPadding}${v}` : v;
317+
});
318+
return indent ? `{${core.join(",")}${basePadding}}` : `{${core.join(", ")}}`;
319+
}
320+
}
321+
default:
322+
// e.g., FunctionValue
323+
throw new Error(`Cannot convert to JSON: ${input.type}`);
324+
}
325+
}
326+
275327
/**
276328
* Represents an Object value at runtime.
277329
*/
@@ -346,43 +398,11 @@ export class ObjectValue extends RuntimeValue<Map<string, AnyRuntimeValue>> {
346398
.map(([key, value]) => new ArrayValue([new StringValue(key), value]))
347399
.sort((a, b) => {
348400
const index = by.value === "key" ? 0 : 1;
401+
const aVal = a.value[index];
402+
const bVal = b.value[index];
349403

350-
let aValue: unknown = a.value[index].value;
351-
let bValue: unknown = b.value[index].value;
352-
353-
// Handle null/undefined values - put them at the end
354-
if (aValue == null && bValue == null) return 0;
355-
if (aValue == null) return reverse.value ? -1 : 1;
356-
if (bValue == null) return reverse.value ? 1 : -1;
357-
358-
// For case-insensitive string comparison
359-
if (!caseSensitive.value && typeof aValue === "string" && typeof bValue === "string") {
360-
aValue = aValue.toLowerCase();
361-
bValue = bValue.toLowerCase();
362-
}
363-
364-
// Ensure comparable types:
365-
// This is only an potential issue when `by='value'` and the dictionary has mixed value types
366-
const isPrimitive = (val: unknown) =>
367-
typeof val === "string" || typeof val === "number" || typeof val === "boolean";
368-
const firstNonPrimitive = isPrimitive(aValue) ? (isPrimitive(bValue) ? null : bValue) : aValue;
369-
if (firstNonPrimitive !== null) {
370-
throw new Error(
371-
`Cannot sort dictionary with non-primitive value types (found ${typeof firstNonPrimitive})`
372-
);
373-
} else if (typeof aValue !== typeof bValue) {
374-
throw new Error("Cannot sort dictionary with mixed value types");
375-
}
376-
377-
const a1 = aValue as string | number | boolean;
378-
const b1 = bValue as string | number | boolean;
379-
380-
if (a1 < b1) {
381-
return reverse.value ? 1 : -1;
382-
} else if (a1 > b1) {
383-
return reverse.value ? -1 : 1;
384-
}
385-
return 0;
404+
const result = compareRuntimeValues(aVal, bVal, caseSensitive.value);
405+
return reverse.value ? -result : result;
386406
});
387407

388408
return new ArrayValue(items);
@@ -401,6 +421,9 @@ export class ObjectValue extends RuntimeValue<Map<string, AnyRuntimeValue>> {
401421
values(): ArrayValue {
402422
return new ArrayValue(Array.from(this.value.values()));
403423
}
424+
override toString(): string {
425+
return toJSON(this, null, 0, false);
426+
}
404427
}
405428

406429
/**
@@ -428,6 +451,9 @@ export class ArrayValue extends RuntimeValue<AnyRuntimeValue[]> {
428451
override __bool__(): BooleanValue {
429452
return new BooleanValue(this.value.length > 0);
430453
}
454+
override toString(): string {
455+
return toJSON(this, null, 0, false);
456+
}
431457
}
432458

433459
/**
@@ -613,6 +639,101 @@ export function setupGlobals(env: Environment): void {
613639
env.set("None", null);
614640
}
615641

642+
/**
643+
* Helper function to get a nested attribute value from an object using dot notation.
644+
* Supports both object properties and array indices.
645+
* @param item The item to get the value from
646+
* @param attributePath The attribute path (e.g., "details.priority" or "items.0")
647+
* @returns The value at the attribute path, or UndefinedValue if not found
648+
*/
649+
function getAttributeValue(item: AnyRuntimeValue, attributePath: string): AnyRuntimeValue {
650+
const parts = attributePath.split(".");
651+
let value: AnyRuntimeValue = item;
652+
653+
for (const part of parts) {
654+
if (value instanceof ObjectValue) {
655+
value = value.value.get(part) ?? new UndefinedValue();
656+
} else if (value instanceof ArrayValue) {
657+
const index = parseInt(part, 10);
658+
if (!isNaN(index) && index >= 0 && index < value.value.length) {
659+
value = value.value[index];
660+
} else {
661+
return new UndefinedValue();
662+
}
663+
} else {
664+
return new UndefinedValue();
665+
}
666+
}
667+
668+
return value;
669+
}
670+
671+
/**
672+
* Helper function to compare two runtime values for sorting.
673+
* Enforces strict type checking, i.e., types must match exactly (with some exceptions):
674+
* - Integers and floats can be compared (both are numeric)
675+
* - Booleans can be compared with numbers (false=0, true=1)
676+
* - Null values can be compared with null values (they are equal)
677+
* - Undefined values can be compared with undefined values (they are equal)
678+
* @param a The first value to compare
679+
* @param b The second value to compare
680+
* @param caseSensitive Whether string comparisons should be case-sensitive (default: false)
681+
* @returns -1 if a < b, 1 if a > b, 0 if equal
682+
*/
683+
function compareRuntimeValues(a: AnyRuntimeValue, b: AnyRuntimeValue, caseSensitive: boolean = false): number {
684+
// Handle null values (can only compare null with null)
685+
if (a instanceof NullValue && b instanceof NullValue) {
686+
return 0; // All null values are equal
687+
}
688+
if (a instanceof NullValue || b instanceof NullValue) {
689+
throw new Error(`Cannot compare ${a.type} with ${b.type}`);
690+
}
691+
692+
// Handle undefined values (can only compare undefined with undefined)
693+
if (a instanceof UndefinedValue && b instanceof UndefinedValue) {
694+
return 0; // All undefined values are equal
695+
}
696+
if (a instanceof UndefinedValue || b instanceof UndefinedValue) {
697+
throw new Error(`Cannot compare ${a.type} with ${b.type}`);
698+
}
699+
700+
const isNumericLike = (v: AnyRuntimeValue): boolean =>
701+
v instanceof IntegerValue || v instanceof FloatValue || v instanceof BooleanValue;
702+
703+
const getNumericValue = (v: AnyRuntimeValue): number => {
704+
if (v instanceof BooleanValue) {
705+
return v.value ? 1 : 0;
706+
}
707+
return (v as IntegerValue | FloatValue).value;
708+
};
709+
710+
// Allow comparing numeric-like types (integers, floats, and booleans)
711+
if (isNumericLike(a) && isNumericLike(b)) {
712+
const aNum = getNumericValue(a);
713+
const bNum = getNumericValue(b);
714+
return aNum < bNum ? -1 : aNum > bNum ? 1 : 0;
715+
}
716+
717+
// Strict type checking for non-numeric types
718+
if (a.type !== b.type) {
719+
throw new Error(`Cannot compare different types: ${a.type} and ${b.type}`);
720+
}
721+
722+
switch (a.type) {
723+
case "StringValue": {
724+
let aStr = (a as StringValue).value;
725+
let bStr = (b as StringValue).value;
726+
if (!caseSensitive) {
727+
aStr = aStr.toLowerCase();
728+
bStr = bStr.toLowerCase();
729+
}
730+
return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
731+
}
732+
default:
733+
throw new Error(`Cannot compare type: ${a.type}`);
734+
}
735+
}
736+
616737
export class Interpreter {
617738
global: Environment;
618739

@@ -803,28 +924,15 @@ export class Interpreter {
803924
case "length":
804925
return new IntegerValue(operand.value.length);
805926
case "reverse":
806-
return new ArrayValue(operand.value.reverse());
807-
case "sort":
808-
return new ArrayValue(
809-
operand.value.sort((a, b) => {
810-
if (a.type !== b.type) {
811-
throw new Error(`Cannot compare different types: ${a.type} and ${b.type}`);
812-
}
813-
switch (a.type) {
814-
case "IntegerValue":
815-
case "FloatValue":
816-
return (a as IntegerValue | FloatValue).value - (b as IntegerValue | FloatValue).value;
817-
case "StringValue":
818-
return (a as StringValue).value.localeCompare((b as StringValue).value);
819-
default:
820-
throw new Error(`Cannot compare type: ${a.type}`);
821-
}
822-
})
823-
);
927+
return new ArrayValue(operand.value.slice().reverse());
928+
case "sort": {
929+
// Default to case-insensitive sort
930+
return new ArrayValue(operand.value.slice().sort((a, b) => compareRuntimeValues(a, b, false)));
931+
}
824932
case "join":
825933
return new StringValue(operand.value.map((x) => x.value).join(""));
826934
case "string":
827-
return new StringValue(toJSON(operand));
935+
return new StringValue(toJSON(operand, null, 0, false));
828936
case "unique": {
829937
const seen = new Set();
830938
const output: AnyRuntimeValue[] = [];
@@ -995,6 +1103,49 @@ export class Interpreter {
9951103

9961104
if (operand instanceof ArrayValue) {
9971105
switch (filterName) {
1106+
case "sort": {
1107+
// https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.sort
1108+
// Optional parameters:
1109+
// - reverse: Sort descending instead of ascending
1110+
// - case_sensitive: When sorting strings, sort upper and lower case separately
1111+
// - attribute: When sorting objects or dicts, an attribute or key to sort by
1112+
const [args, kwargs] = this.evaluateArguments(filter.args, environment);
1113+
1114+
const reverse = args.at(0) ?? kwargs.get("reverse") ?? new BooleanValue(false);
1115+
if (!(reverse instanceof BooleanValue)) {
1116+
throw new Error("reverse must be a boolean");
1117+
}
1118+
1119+
const caseSensitive = args.at(1) ?? kwargs.get("case_sensitive") ?? new BooleanValue(false);
1120+
if (!(caseSensitive instanceof BooleanValue)) {
1121+
throw new Error("case_sensitive must be a boolean");
1122+
}
1123+
1124+
const attribute = args.at(2) ?? kwargs.get("attribute") ?? new NullValue();
1125+
if (
1126+
!(attribute instanceof StringValue || attribute instanceof IntegerValue || attribute instanceof NullValue)
1127+
) {
1128+
throw new Error("attribute must be a string, integer, or null");
1129+
}
1130+
1131+
// Helper function to get the value to sort by
1132+
const getSortValue = (item: AnyRuntimeValue): AnyRuntimeValue => {
1133+
if (attribute instanceof NullValue) {
1134+
return item;
1135+
}
1136+
const attrPath = attribute instanceof IntegerValue ? String(attribute.value) : attribute.value;
1137+
return getAttributeValue(item, attrPath);
1138+
};
1139+
1140+
return new ArrayValue(
1141+
operand.value.slice().sort((a, b) => {
1142+
const aVal = getSortValue(a);
1143+
const bVal = getSortValue(b);
1144+
const result = compareRuntimeValues(aVal, bVal, caseSensitive.value);
1145+
return reverse.value ? -result : result;
1146+
})
1147+
);
1148+
}
9981149
case "selectattr":
9991150
case "rejectattr": {
10001151
const select = filterName === "selectattr";
@@ -1045,7 +1196,9 @@ export class Interpreter {
10451196
if (!(item instanceof ObjectValue)) {
10461197
throw new Error("items in map must be an object");
10471198
}
1048-
return item.value.get(attr.value) ?? defaultValue ?? new UndefinedValue();
1199+
1200+
const value = getAttributeValue(item, attr.value);
1201+
return value instanceof UndefinedValue ? defaultValue ?? new UndefinedValue() : value;
10491202
});
10501203
return new ArrayValue(mapped);
10511204
} else {
@@ -1629,47 +1782,3 @@ function convertToRuntimeValues(input: unknown): AnyRuntimeValue {
16291782
throw new Error(`Cannot convert to runtime value: ${input}`);
16301783
}
16311784
}
1632-
1633-
/**
1634-
* Helper function to convert runtime values to JSON
1635-
* @param {AnyRuntimeValue} input The runtime value to convert
1636-
* @param {number|null} [indent] The number of spaces to indent, or null for no indentation
1637-
* @param {number} [depth] The current depth of the object
1638-
* @returns {string} JSON representation of the input
1639-
*/
1640-
function toJSON(input: AnyRuntimeValue, indent?: number | null, depth?: number): string {
1641-
const currentDepth = depth ?? 0;
1642-
switch (input.type) {
1643-
case "NullValue":
1644-
case "UndefinedValue": // JSON.stringify(undefined) -> undefined
1645-
return "null";
1646-
case "IntegerValue":
1647-
case "FloatValue":
1648-
case "StringValue":
1649-
case "BooleanValue":
1650-
return JSON.stringify(input.value);
1651-
case "ArrayValue":
1652-
case "ObjectValue": {
1653-
const indentValue = indent ? " ".repeat(indent) : "";
1654-
const basePadding = "\n" + indentValue.repeat(currentDepth);
1655-
const childrenPadding = basePadding + indentValue; // Depth + 1
1656-
1657-
if (input.type === "ArrayValue") {
1658-
const core = (input as ArrayValue).value.map((x) => toJSON(x, indent, currentDepth + 1));
1659-
return indent
1660-
? `[${childrenPadding}${core.join(`,${childrenPadding}`)}${basePadding}]`
1661-
: `[${core.join(", ")}]`;
1662-
} else {
1663-
// ObjectValue
1664-
const core = Array.from((input as ObjectValue).value.entries()).map(([key, value]) => {
1665-
const v = `"${key}": ${toJSON(value, indent, currentDepth + 1)}`;
1666-
return indent ? `${childrenPadding}${v}` : v;
1667-
});
1668-
return indent ? `{${core.join(",")}${basePadding}}` : `{${core.join(", ")}}`;
1669-
}
1670-
}
1671-
default:
1672-
// e.g., FunctionValue
1673-
throw new Error(`Cannot convert to JSON: ${input.type}`);
1674-
}
1675-
}

0 commit comments

Comments
 (0)