Skip to content

Commit

Permalink
Merge pull request #1800 from riganti/js-translation-avoid-exceptions
Browse files Browse the repository at this point in the history
Avoid exceptions in JS translations, add support First, Last
  • Loading branch information
tomasherceg committed Mar 22, 2024
2 parents a04a580 + 72c1cb8 commit 3bfa2ea
Show file tree
Hide file tree
Showing 9 changed files with 113 additions and 88 deletions.
Expand Up @@ -562,18 +562,24 @@ string GetDelegateReturnTypeHash(Type type)
AddMethodTranslator(() => ImmutableArrayExtensions.ElementAtOrDefault(default(ImmutableArray<Generic.T>), 0),
new GenericMethodCompiler((args, method) => BuildIndexer(args[1], args[2], method)));

AddMethodTranslator(() => Enumerable.Empty<Generic.T>().FirstOrDefault(), new GenericMethodCompiler((args, m) => BuildIndexer(args[1], new JsLiteral(0), m)));
AddMethodTranslator(() => ImmutableArrayExtensions.FirstOrDefault(default(ImmutableArray<Generic.T>)), new GenericMethodCompiler((args, m) => BuildIndexer(args[1], new JsLiteral(0), m)));
var firstOrDefault = new GenericMethodCompiler((args, m) => BuildIndexer(args[1], new JsLiteral(0), m).WithAnnotation(MayBeNullAnnotation.Instance));
AddMethodTranslator(() => Enumerable.Empty<Generic.T>().FirstOrDefault(), firstOrDefault);
AddMethodTranslator(() => Enumerable.Empty<Generic.T>().First(), firstOrDefault);
AddMethodTranslator(() => ImmutableArrayExtensions.FirstOrDefault(default(ImmutableArray<Generic.T>)), firstOrDefault);
AddMethodTranslator(() => ImmutableArrayExtensions.First(default(ImmutableArray<Generic.T>)), firstOrDefault);

var firstOrDefaultPred = new GenericMethodCompiler(args =>
new JsIdentifierExpression("dotvvm").Member("translations").Member("array").Member("firstOrDefault").Invoke(args[1], args[2]).WithAnnotation(MayBeNullAnnotation.Instance));
args[1].Member("find").Invoke(args[2]).WithAnnotation(MayBeNullAnnotation.Instance));
AddMethodTranslator(() => Enumerable.Empty<Generic.T>().FirstOrDefault(_ => true), firstOrDefaultPred);
AddMethodTranslator(() => Enumerable.Empty<Generic.T>().First(_ => true), firstOrDefaultPred);
AddMethodTranslator(() => ImmutableArrayExtensions.FirstOrDefault(default(ImmutableArray<Generic.T>), _ => true), firstOrDefaultPred);
AddMethodTranslator(() => ImmutableArrayExtensions.First(default(ImmutableArray<Generic.T>), _ => true), firstOrDefaultPred);

var lastOrDefault = new GenericMethodCompiler(args => args[1].Member("at").Invoke(new JsLiteral(-1)).WithAnnotation(MayBeNullAnnotation.Instance));
AddMethodTranslator(() => Enumerable.Empty<Generic.T>().LastOrDefault(), lastOrDefault);
AddMethodTranslator(() => ImmutableArrayExtensions.LastOrDefault(default(ImmutableArray<Generic.T>)), lastOrDefault);
var lastOrDefaultPred = new GenericMethodCompiler(args =>
new JsIdentifierExpression("dotvvm").Member("translations").Member("array").Member("lastOrDefault").Invoke(args[1], args[2]).WithAnnotation(MayBeNullAnnotation.Instance));
args[1].Member("findLast").Invoke(args[2]).WithAnnotation(MayBeNullAnnotation.Instance));
AddMethodTranslator(() => Enumerable.Empty<Generic.T>().LastOrDefault(_ => false), lastOrDefaultPred);
AddMethodTranslator(() => ImmutableArrayExtensions.LastOrDefault(default(ImmutableArray<Generic.T>), _ => false), lastOrDefaultPred);

Expand Down Expand Up @@ -631,12 +637,16 @@ private void AddDefaultNumericEnumerableTranslations()
if (m.Name is "Max" or "Min" && parameters.Length == 1 && itemType.UnwrapNullableType().IsNumericType())
{
AddMethodTranslator(m, new GenericMethodCompiler(args =>
new JsIdentifierExpression("dotvvm").Member("translations").Member("array").Member(m.Name is "Min" ? "min" : "max").Invoke(args[1], selectIdentityFunc.Clone(), new JsLiteral(!itemType.IsNullable()))));
new JsIdentifierExpression("dotvvm").Member("translations").Member("array").Member(m.Name is "Min" ? "min" : "max").Invoke(args[1], selectIdentityFunc.Clone())
.WithAnnotation(MayBeNullAnnotation.Instance)
));
}
else if (m.Name is "Max" or "Min" && parameters.Length == 2 && selectorResultType?.UnwrapNullableType().IsNumericType() == true)
{
AddMethodTranslator(m, new GenericMethodCompiler(args =>
new JsIdentifierExpression("dotvvm").Member("translations").Member("array").Member(m.Name is "Min" ? "min" : "max").Invoke(args[1], args[2], new JsLiteral(!selectorResultType.IsNullable()))));
new JsIdentifierExpression("dotvvm").Member("translations").Member("array").Member(m.Name is "Min" ? "min" : "max").Invoke(args[1], args[2])
.WithAnnotation(MayBeNullAnnotation.Instance)
));
}

else if (m.Name is "Sum" && parameters.Length == 1 && itemType.UnwrapNullableType().IsNumericType())
Expand Down
5 changes: 4 additions & 1 deletion src/Framework/Framework/Resources/Scripts/metadata/enums.ts
Expand Up @@ -2,8 +2,11 @@ import { CoerceError } from "../shared-classes"
import { isNumber } from "../utils/isNumber"
import { keys } from "../utils/objects"

export function enumStringToInt(value: number | string, type: EnumTypeMetadata): number | null {
export function enumStringToInt(value: number | string | null | undefined, type: EnumTypeMetadata): number | null {
// if it's number already, just return it
if (value == null) {
return null
}
if (isNumber(value)) {
return +value
}
Expand Down
74 changes: 52 additions & 22 deletions src/Framework/Framework/Resources/Scripts/tests/arrayHelper.test.ts
Expand Up @@ -140,43 +140,27 @@ test("ListExtensions::RemoveLast", () => {
expect(vm.Array()[3]().Id()).toBe(4);
})

test("Enumerable::FirstOrDefault", () => {
prepareArray();
expect(arrayHelper.firstOrDefault(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) % 2 !== 0 }).Id()).toBe(1);
expect(arrayHelper.firstOrDefault(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) % 2 === 0 }).Id()).toBe(2);
})

test("Enumerable::LastOrDefault", () => {
prepareArray();
expect(arrayHelper.lastOrDefault(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) % 2 !== 0 }).Id()).toBe(5);
expect(arrayHelper.lastOrDefault(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) % 2 === 0 }).Id()).toBe(4);
})

test("Enumerable::Max", () => {
prepareArray();
expect(arrayHelper.max(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, true)).toBe(5);
expect(arrayHelper.max(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, false)).toBe(5);
expect(arrayHelper.max(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) })).toBe(5);

arrayHelper.clear(vm.Array);
s.doUpdateNow();
expect(() => arrayHelper.max(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, true)).toThrowError("Source is empty! Max operation cannot be performed.");
expect(arrayHelper.max(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, false)).toBe(null);
expect(arrayHelper.max(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) })).toBe(null);
})

test("Enumerable::Min", () => {
prepareArray();
expect(arrayHelper.min(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, true)).toBe(1);
expect(arrayHelper.min(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, false)).toBe(1);
expect(arrayHelper.min(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) })).toBe(1);

arrayHelper.clear(vm.Array);
s.doUpdateNow();
expect(() => arrayHelper.min(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, true)).toThrowError("Source is empty! Min operation cannot be performed.");
expect(arrayHelper.min(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, false)).toBe(null);
expect(arrayHelper.min(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) })).toBe(null);
})

test("Enumerable::OrderBy", () => {
prepareArray();
const result = arrayHelper.orderBy(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, "t2");
const result = arrayHelper.orderBy(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, null);
expect(result.length).toBe(5);
expect(result[0]().Id()).toBe(1);
expect(result[1]().Id()).toBe(2);
Expand All @@ -187,7 +171,7 @@ test("Enumerable::OrderBy", () => {

test("Enumerable::OrderByDescending", () => {
prepareArray();
const result = arrayHelper.orderByDesc(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, "t2");
const result = arrayHelper.orderByDesc(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) }, null);
expect(result.length).toBe(5);
expect(result[0]().Id()).toBe(5);
expect(result[1]().Id()).toBe(4);
Expand All @@ -196,6 +180,52 @@ test("Enumerable::OrderByDescending", () => {
expect(result[4]().Id()).toBe(1);
})

test("Enumerable::OrderBy with nulls", () => {
vm.Array.setState([ { Id: -1 }, { Id: 4 }, { Id: 0 }, { Id: 3 } ])
s.doUpdateNow()
const result = arrayHelper.orderBy(vm.Array(), function (arg: any) { return ko.unwrap(ko.unwrap(arg).Id) || null }, null);
expect(result.length).toBe(4);
expect(result[0]().Id()).toBe(0);
expect(result[1]().Id()).toBe(-1);
expect(result[2]().Id()).toBe(3);
expect(result[3]().Id()).toBe(4);
})

test("Enumerable::OrderBy with enums", () => {
vm.Enums.setState([ 'D', 'A', 0, 'A, D', 'B', 123432 ])
s.doUpdateNow()
const result = arrayHelper.orderBy(vm.Enums(), function (arg: any) { return ko.unwrap(arg) }, "e1");
expect(result.length).toBe(6);
expect(result[0]()).toBe(0);
expect(result[1]()).toBe('A');
expect(result[2]()).toBe('B');
expect(result[3]()).toBe('D');
expect(result[4]()).toBe('A,D');
expect(result[5]()).toBe(123432);
})

test("Enumerable::OrderBy stable", () => {
prepareArray()
const result = arrayHelper.orderBy(vm.Array(), function (arg: any) { return 1 }, null);
expect(result.length).toBe(5);
expect(result[0]().Id()).toBe(1);
expect(result[1]().Id()).toBe(2);
expect(result[2]().Id()).toBe(3);
expect(result[3]().Id()).toBe(4);
expect(result[4]().Id()).toBe(5);
})
test("Enumerable::OrderByDescending stable", () => {
prepareArray()
const result = arrayHelper.orderBy(vm.Array(), function (arg: any) { return 1 }, null);
expect(result.length).toBe(5);
expect(result[0]().Id()).toBe(1);
expect(result[1]().Id()).toBe(2);
expect(result[2]().Id()).toBe(3);
expect(result[3]().Id()).toBe(4);
expect(result[4]().Id()).toBe(5);
})


function prepareArray() {
arrayHelper.clear(vm.Array);
arrayHelper.add(vm.Array, { Id: 1 });
Expand Down
Expand Up @@ -9,6 +9,7 @@ initDotvvm({
$type: "t2",
Id: 1
}],
Enums: [ "A", "B", "A, B" ],
ArrayWillBe: null,
Inner: {
$type: "t3",
Expand All @@ -33,6 +34,9 @@ initDotvvm({
"t2"
]
},
Enums: {
type: [ "e1" ]
},
ArrayWillBe: {
type: [
"t5"
Expand Down Expand Up @@ -101,6 +105,16 @@ initDotvvm({
type: "String"
}
}
},
e1: {
type: "enum",
isFlags: true,
values: {
"A": 1,
"B": 2,
"C": 4,
"D": 8,
}
}
}
})
Expand Down
Expand Up @@ -7,10 +7,8 @@ export {
clear,
distinct,
contains,
firstOrDefault,
insert,
insertRange,
lastOrDefault,
max,
min,
orderBy,
Expand Down Expand Up @@ -70,16 +68,6 @@ function contains<T>(array: T[], value: T): boolean {
return array.map(e => ko.unwrap(e)).includes(value);
}

function firstOrDefault<T>(array: T[], predicate: (s: T) => boolean): T | null {
for (const item of array) {
const itemUnwrapped = ko.unwrap(item)
if (predicate(itemUnwrapped)) {
return itemUnwrapped
}
}
return null;
}

function insert<T>(observable: any, index: number, element: T): void {
let array = Array.from<T>(observable.state);
array.splice(index, 0, element);
Expand All @@ -92,21 +80,8 @@ function insertRange<T>(observable: any, index: number, elements: T[]): void {
observable.setState(array);
}

function lastOrDefault<T>(array: T[], predicate: (s: T) => boolean): T | null {
for (let i = array.length - 1; i >= 0; i--) {
const itemUnwrapped = ko.unwrap(array[i])
if (predicate(itemUnwrapped)) {
return itemUnwrapped
}
}
return null;
}

function max<T>(array: T[], selector: (item: T) => number, throwIfEmpty: boolean): number | null {
function max<T>(array: T[], selector: (item: T) => number): number | null {
if (array.length === 0) {
if (throwIfEmpty) {
throw new Error("Source is empty! Max operation cannot be performed.");
}
return null;
}

Expand All @@ -119,11 +94,8 @@ function max<T>(array: T[], selector: (item: T) => number, throwIfEmpty: boolean
return max;
}

function min<T>(array: T[], selector: (item: T) => number, throwIfEmpty: boolean): number | null {
function min<T>(array: T[], selector: (item: T) => number): number | null {
if (array.length === 0) {
if (throwIfEmpty) {
throw new Error("Source is empty! Min operation cannot be performed.");
}
return null;
}

Expand Down
Expand Up @@ -8,14 +8,10 @@ export function containsKey<Key, Value>(dictionary: Dictionary<Key, Value>, iden
return getKeyValueIndex(dictionary, identifier) !== null;
}

export function getItem<Key, Value>(dictionary: Dictionary<Key, Value>, identifier: Key, defaultValue?: Value): Value {
export function getItem<Key, Value>(dictionary: Dictionary<Key, Value>, identifier: Key, defaultValue?: Value): Value | undefined {
const index = getKeyValueIndex(dictionary, identifier);
if (index === null) {
if (defaultValue !== undefined) {
return defaultValue;
} else {
throw Error("Provided key \"" + identifier + "\" is not present in the dictionary!");
}
return defaultValue;
}

return ko.unwrap(ko.unwrap(dictionary[index]).Value);
Expand Down
@@ -1,32 +1,31 @@
import { getTypeInfo } from "../metadata/typeMap";
import { enumStringToInt } from "../metadata/enums";
import { getTypeInfo } from "../metadata/typeMap";
type ElementType = string | number | boolean;

export const orderBy = <T>(array: T[], selector: (item: T) => ElementType, typeId: string) =>
export const orderBy = <T>(array: T[], selector: (item: T) => ElementType, typeId: string | null) =>
orderByImpl(array, selector, getComparer(typeId, true))

export const orderByDesc = <T>(array: T[], selector: (item: T) => ElementType, typeId: string) =>
export const orderByDesc = <T>(array: T[], selector: (item: T) => ElementType, typeId: string | null) =>
orderByImpl(array, selector, getComparer(typeId, false))

function orderByImpl<T>(array: T[], selector: (item: T) => ElementType, compare: (first: ElementType, second: ElementType) => number): T[] {
if ((!array || array.length < 2))
return array;

return array
.map((item, index) => ({ item, index }))
.sort((first, second) => compare(ko.unwrap(selector(first.item)), ko.unwrap(selector(second.item))) || first.index - second.index)
.map(({ item }) => item);
// JS sort is stable: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#sort_stability
array = Array.from(array)
array.sort((first, second) => compare(ko.unwrap(selector(first)), ko.unwrap(selector(second))))
return array;
}

function getComparer(typeId: string | null, ascending: boolean): (first: ElementType, second: ElementType) => number {
let metadataInfo = (typeId !== null) ? getTypeInfo(typeId) : null;
if (metadataInfo !== null && metadataInfo.type === "enum") {
const metadataInfo = (typeId != null) ? getTypeInfo(typeId) : null;
if (metadataInfo?.type === "enum") {
// Enums should be compared based on their underlying primitive values
// This is the same behaviour as used by .NET
let enumMetadataInfo = metadataInfo as EnumTypeMetadata;
const enumMetadataInfo = metadataInfo as EnumTypeMetadata;
return function (first: ElementType, second: ElementType) {
let firstNumeric = (typeof (first) === "number") ? first : enumMetadataInfo.values[first as string];
let secondNumeric = (typeof (second) === "number") ? second : enumMetadataInfo.values[second as string];
return defaultPrimitivesComparer(firstNumeric, secondNumeric, ascending);
return defaultPrimitivesComparer(enumStringToInt(first as any, enumMetadataInfo), enumStringToInt(second as any, enumMetadataInfo), ascending);
}
}
else {
Expand All @@ -37,7 +36,8 @@ function getComparer(typeId: string | null, ascending: boolean): (first: Element
}
}

const defaultPrimitivesComparer = (first: ElementType, second: ElementType, ascending: boolean) => {
let comparision = (first < second) ? -1 : (first == second) ? 0 : 1;
return (ascending) ? comparision : comparision * -1;
const defaultPrimitivesComparer = (first: ElementType | null | undefined, second: ElementType | null | undefined, ascending: boolean) => {
// nulls are first in ascending order in .NET
const comparison = (first == second) ? 0 : (first == null || second != null && first < second) ? -1 : 1;
return (ascending) ? comparison : comparison * -1;
}
Expand Up @@ -3,7 +3,7 @@ export function isNumber(value: any): boolean {
// Number(value) returns NaN for anything which is not a number, except for empty/whitespace string which is converted to 0 🤦
// parseFloat correctly fails for empty string, but parseFloat("10dddd") returns 10, not NaN

// so value is number of Number(value) and parseFloat(value) return the same number
// so value is number if Number(value) and parseFloat(value) return the same number
// if both return NaN, === is false, finally this "feature" is useful for something :D
return parseFloat(value) === Number(value);
}

0 comments on commit 3bfa2ea

Please sign in to comment.