Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid exceptions in JS translations, add support First, Last #1800

Merged
merged 2 commits into from Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);
}