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

Refactor util pkg #1077

Merged
merged 13 commits into from
Jun 11, 2024
39 changes: 39 additions & 0 deletions packages/util/src/at.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export type AtOptions = {
optional?: boolean // whether to allow undefined elements in the array (true == optional, false == mandatory)
noWrap?: boolean // whether to wrap the index around the bounds of the array (true == no wrap, false == wrap indices)
}
// Get an element from an array, throwing an error if it's index is out of bounds or if the element is undefined or null (can be overridden with the options)
export function at(str: string, index: number, options: AtOptions & { optional: true }): string | undefined
export function at(str: string, index: number, options?: AtOptions): string
export function at<T>(items: T[] | string, index: number, options: AtOptions & { optional: false }): T
export function at<T>(
items: (T | undefined)[] | string,
index: number,
options: AtOptions & { optional: true }
): T | undefined
export function at<T>(items: T[], index: number, options?: AtOptions): T
export function at<T>(items: T[] | string, index: number, options?: AtOptions): T {
if (items.length === 0) {
throw new Error('Array is empty')
}

if (!options?.noWrap) {
if (index > 0) {
index = index % items.length
} else {
// negative index, so index wraps in reverse
// e.g. say the index is -25 and the items length is 10
// ceil(25 / 10) = 3 * 10 = 30 + -25 = 5
index = Math.ceil(Math.abs(index) / items.length) * items.length + index
}
}

if (index >= items.length) {
throw new Error(`Index ${index} larger than array length ${items.length}`)
}
if (index < 0) {
throw new Error(`Index ${index} smaller than 0`)
}

return items[index] as unknown as T
}
8 changes: 8 additions & 0 deletions packages/util/src/checks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const isArray = (value: unknown): boolean => {
// null passes the isArray check, so manually check for it
return Array.isArray(value) && value !== null
}

export const isObject = (value: unknown): boolean => {
return value instanceof Object && !isArray(value)
}
20 changes: 20 additions & 0 deletions packages/util/src/choice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
type ChoiceOptions = {
withReplacement?: boolean
}
export function choice<T>(items: T[], n: number, random: () => number, options?: ChoiceOptions): T[] {
if (n > items.length) {
throw new Error(`Cannot choose ${n} items from array of length ${items.length}`)
}

const result: T[] = []
const indices: number[] = []
for (let i = 0; i < n; i++) {
let index: number
do {
index = Math.floor(Math.abs(random()) * items.length) % items.length
} while (options?.withReplacement === false && indices.includes(index))
indices.push(index)
result.push(items[index] as T)
}
return result
}
11 changes: 11 additions & 0 deletions packages/util/src/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function get<T>(obj: T, key: unknown, required?: true): Exclude<T[keyof T], undefined>
export function get<T>(obj: T, key: unknown, required: false): T[keyof T] | undefined
export function get<T>(obj: unknown, key: string | number | symbol, required?: true): Exclude<T, undefined>
export function get<T>(obj: unknown, key: string | number | symbol, required: false): T | undefined
export function get<T, V>(obj: T, key: unknown, required = true): V {
const value = obj[key as unknown as keyof T]
if (required && value === undefined) {
throw new Error(`Object has no property '${String(key)}': ${JSON.stringify(obj, null, 2)}`)
}
return value as V
}
13 changes: 13 additions & 0 deletions packages/util/src/hex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { isArray } from './checks.js'
import { u8aToHex } from '@polkadot/util'

export type Hash = string | number[]

export const hashToHex = (hash: Hash) => {
if (isArray(hash)) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return u8aToHex(new Uint8Array(hash))
}
return hash.toString()
}
7 changes: 7 additions & 0 deletions packages/util/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,11 @@ export * from './canvas.js'
export * from './solverService.js'
export * from './table.js'
export * from './url.js'
export * from './at.js'
export * from './get.js'
export * from './merge.js'
export * from './choice.js'
export * from './permutations.js'
export * from './version.js'
export * from './hex.js'
export * from './checks.js'
95 changes: 95 additions & 0 deletions packages/util/src/merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { isArray, isObject } from './checks.js'

export type MergeOptions = {
atomicArrays?: boolean
}

// Merge two objects or arrays together.
// Nesting can be infinitely deep.
// Arrays can be homogeneous or hetrogeneous.
// The destination object/array is mutated directly.
// Arrays can be merged in two ways:
// - update (default): replace elements as required and extend array as required, e.g. [1,2,3] + [4,5] = [4,5,3]
// - replace: treat the array as a primitive value and simply replace as-is, e.g. [1,2,3] + [4,5] = [4,5]
// The 'atomicArrays' option controls whether arrays are treated as primitives or not. E.g. atomicArrays=true is the 'replace' strategy, atomicArrays=false is the 'update' strategy.
// This method treats arrays as an object with numeric keys and merged using the object merge strategy.
export function merge<T extends object | A[], U extends object | B[], A, B>(
dest: T,
src: U,
options?: MergeOptions
): T & U {
const atomicArrays = options?.atomicArrays
// maintain a queue of object sources/destinations to merge
const queue: {
src: unknown
dest: unknown
}[] = [
{
src,
dest,
},
]
while (queue.length > 0) {
const task = queue.pop()
if (task === undefined) {
throw new Error('queue is empty')
}
if (isArray(task.dest)) {
// handling arrays
const src = task.src as unknown[]
const dest = task.dest as unknown[]
if (atomicArrays) {
// delete any items beyond the length of src
while (dest.length > src.length) {
dest.pop()
}
// treat arrays as primitives / atomic
for (let i = 0; i < src.length; i++) {
dest[i] = src[i]
}
} else {
// else not treating arrays as primitives / atomic
// so need to merge them
// copy the elements from src into dest
for (let i = 0; i < src.length; i++) {
// if the element is an array or object, then we need to merge it
if ((isArray(dest[i]) && isArray(src[i])) || (isObject(dest[i]) && isObject(src[i]))) {
// need to merge arrays or objects
queue.push({
src: src[i],
dest: dest[i],
})
} else {
// primitive, so replace
// or src[i] is array but dest[i] is not, so replace
// or src[i] is object but dest[i] is not, so replace
dest[i] = src[i]
}
}
}
} else if (isObject(task.dest)) {
const src = task.src as object
const destAny = task.dest as any

Check warning on line 72 in packages/util/src/merge.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected any. Specify a different type
// for every entry in src
for (const [key, value] of Object.entries(src)) {
// if the value in src + dest is an array or object, then we need to merge it
if ((isArray(value) && isArray(destAny[key])) || (isObject(value) && isObject(destAny[key]))) {
// need to merge arrays or objects
queue.push({
src: value,
dest: destAny[key],
})
} else {
// primitive, so replace
// or value is array but dest[key] is not, so replace
// or value is object but dest[key] is not, so replace
destAny[key] = value
}
}
} else {
throw new Error(`cannot handle type in queue: ${typeof task.dest}`)
}
}

return dest as T & U
}
47 changes: 47 additions & 0 deletions packages/util/src/permutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// create a generator that yields the permutations for a set of options
// E.g. say we have 3 chars which can take 2 values each ('a' or 'b'), then we have 2^3 = 8 permutations:
// a a a
// a a b
// a b a
// a b b
// b a a
// b a b
// b b a
// b b b
// This function yields each permutation as an array of numbers, where each number is the index of the option for that position
// E.g. for the above example, the first permutation is [0, 0, 0], the second is [0, 0, 1], the third is [0, 1, 0], etc.
//
// The bins param is an array of numbers, where each number is the number of options for that position
// E.g. for the above example, the bins param would be [2, 2, 2]
//
// Note that the bins can be differing sizes, so the first char could have 2 options whereas the second could have 3 options and the fourth char could have 6 options
//
// Optionally include the empty permutation, i.e. [] (useful for when you want to include the empty permutation in a cartesian product)
export function* permutations(
bins: number[],
options?: {
includeEmpty?: boolean
}
): Generator<number[]> {
if (options?.includeEmpty) {
yield []
}
if (bins.length === 0) {
return
}
const arr = Array.from({ length: bins.length }, () => 0)
let i = arr.length - 1
while (true) {
yield [...arr]
arr[i]++
while (arr[i] === bins[i]) {
arr[i] = 0
i--
if (i < 0) {
return
}
arr[i]++
}
i = arr.length - 1
}
}
99 changes: 99 additions & 0 deletions packages/util/src/tests/at.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2021-2023 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { at } from '../at.js'
import { describe, expect, test } from 'vitest'

describe('at', () => {
test('types', () => {
// check the types are picked up correctly by ts
const v1: number = at([1, 2, 3], 0)

Check warning on line 20 in packages/util/src/tests/at.test.ts

View workflow job for this annotation

GitHub Actions / check

'v1' is assigned a value but never used
const v2: number | undefined = at([1, 2, 3, undefined], 0)

Check warning on line 21 in packages/util/src/tests/at.test.ts

View workflow job for this annotation

GitHub Actions / check

'v2' is assigned a value but never used
const v3: string = at('abc', 0)

Check warning on line 22 in packages/util/src/tests/at.test.ts

View workflow job for this annotation

GitHub Actions / check

'v3' is assigned a value but never used
const v4: string | undefined = at('abc', 0, { optional: true })

Check warning on line 23 in packages/util/src/tests/at.test.ts

View workflow job for this annotation

GitHub Actions / check

'v4' is assigned a value but never used
const v5: number | undefined = at([1, 2, 3], 0, { optional: true })

Check warning on line 24 in packages/util/src/tests/at.test.ts

View workflow job for this annotation

GitHub Actions / check

'v5' is assigned a value but never used
const v6: string = at('abc', 0, { optional: false })

Check warning on line 25 in packages/util/src/tests/at.test.ts

View workflow job for this annotation

GitHub Actions / check

'v6' is assigned a value but never used
const v7: number = at([1, 2, 3], 0, { optional: false })

Check warning on line 26 in packages/util/src/tests/at.test.ts

View workflow job for this annotation

GitHub Actions / check

'v7' is assigned a value but never used

const a3: number = at([1, 2, 3], 0)

Check warning on line 28 in packages/util/src/tests/at.test.ts

View workflow job for this annotation

GitHub Actions / check

'a3' is assigned a value but never used
const a4: number | undefined = at([1, 2, 3, undefined], 0)

Check warning on line 29 in packages/util/src/tests/at.test.ts

View workflow job for this annotation

GitHub Actions / check

'a4' is assigned a value but never used
const a6: number | undefined = at([1, 2, 3], 0, { optional: true })
const a7: number = at([1, 2, 3], 0, { optional: false })
const a8: number = at([1, 2, 3], 0, {})
const a9: number = at([1, 2, 3], 0, { noWrap: true })
const a5: string = at('abc', 0)
const a10: string = at('abc', 0, { optional: false })
const a11: string | undefined = at('abc', 0, { optional: true })
const a12: undefined = at([undefined, undefined, undefined], 0)
const a13: undefined = at([undefined, undefined, undefined], 0, { optional: true })
const a14: undefined = at([undefined, undefined, undefined], 0, { optional: false })
})

test('compatible with string', () => {
expect(at('abc', 0)).to.equal('a')
expect(at('abc', 1)).to.equal('b')
expect(at('abc', 2)).to.equal('c')
expect(at('abc', 3)).to.equal('a')
expect(at('abc', 4)).to.equal('b')
expect(at('abc', 5)).to.equal('c')
expect(at('abc', -1)).to.equal('c')
expect(at('abc', -2)).to.equal('b')
expect(at('abc', -3)).to.equal('a')
expect(at('abc', -4)).to.equal('c')
expect(at('abc', -5)).to.equal('b')
expect(at('abc', -6)).to.equal('a')
})

test('empty string', () => {
expect(() => at('', 0)).to.throw()
})

test('throw on empty array', () => {
expect(() => at([], 0)).to.throw()
})

test('throw on index out of bounds high', () => {
expect(() => at([1, 2, 3], 3, { noWrap: true })).to.throw()
})

test('throw on index out of bounds low', () => {
expect(() => at([1, 2, 3], -1, { noWrap: true })).to.throw()
})

test('returns correct value', () => {
expect(at([1, 2, 3], 0)).to.equal(1)
expect(at([1, 2, 3], 1)).to.equal(2)
expect(at([1, 2, 3], 2)).to.equal(3)
})

test('wraps index high', () => {
expect(at([1, 2, 3], 3, { noWrap: false })).to.equal(1)
expect(at([1, 2, 3], 4, { noWrap: false })).to.equal(2)
expect(at([1, 2, 3], 5, { noWrap: false })).to.equal(3)
})

test('wraps index low', () => {
expect(at([1, 2, 3], -1, { noWrap: false })).to.equal(3)
expect(at([1, 2, 3], -2, { noWrap: false })).to.equal(2)
expect(at([1, 2, 3], -3, { noWrap: false })).to.equal(1)
expect(at([1, 2, 3], -4, { noWrap: false })).to.equal(3)
expect(at([1, 2, 3], -5, { noWrap: false })).to.equal(2)
expect(at([1, 2, 3], -6, { noWrap: false })).to.equal(1)
})

test('allow undefined in bounds', () => {
expect(at([undefined, undefined, undefined], 0, { optional: true })).to.equal(undefined)
expect(at([undefined, undefined, undefined], 1, { optional: true })).to.equal(undefined)
expect(at([undefined, undefined, undefined], 2, { optional: true })).to.equal(undefined)
})
})
46 changes: 46 additions & 0 deletions packages/util/src/tests/get.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright 2021-2023 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, expect, test } from 'vitest'
import { get } from '../get.js'

describe('get', () => {
test('types', () => {
// check the types are picked up correctly by ts
const v1: number = get({ a: 1 }, 'a')
const v2: number | undefined = get({ a: 1 }, 'a', false)
const v3: number = get({ a: 1 }, 'a', true)
const v4: number | undefined = get({ a: 1, b: undefined }, 'a')
const v5: number | undefined = get({ a: 1, b: undefined }, 'a', false)
// cast from any
const v6: number = get(JSON.parse('{"a": 1}') as any, 'a')
// cast from unknown
const v7: number = get(JSON.parse('{"a": 1}') as unknown, 'a')
})

test('throw on undefined field string', () => {
expect(() => get({ a: 1 }, 'b')).to.throw()
})

test('throw on undefined field number', () => {
expect(() => get({ a: 1 }, 1)).to.throw()
})

test('get correct field string', () => {
expect(get({ a: 1 }, 'a')).to.equal(1)
})

test('get correct field number', () => {
expect(get({ 1: 1 }, 1)).to.equal(1)
})
})
Loading
Loading