Skip to content

Commit

Permalink
Merge pull request #2022 from effigies/fix/numeric-sort
Browse files Browse the repository at this point in the history
feat(expr): Allow sorted to take a method argument, enabling numeric and lexical sorting
  • Loading branch information
rwblair authored Jul 29, 2024
2 parents ee2cb78 + b531832 commit 56734ab
Show file tree
Hide file tree
Showing 4 changed files with 324 additions and 98 deletions.
103 changes: 8 additions & 95 deletions bids-validator/src/schema/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,26 @@
import { assert } from '../deps/asserts.ts'
import { DatasetIssues } from '../issues/datasetIssues.ts'
import { FileTree } from '../types/filetree.ts'
import { BIDSContext } from './context.ts'
import { nullReadBytes } from '../tests/nullReadBytes.ts'

const issues = new DatasetIssues()

const text = () => Promise.resolve('')

const anatJson = () =>
Promise.resolve(
JSON.stringify({
rootOverwrite: 'anat',
subOverwrite: 'anat',
anatValue: 'anat',
}),
)
const subjectJson = () =>
Promise.resolve(
JSON.stringify({ subOverwrite: 'subject', subValue: 'subject' }),
)
const rootJson = () =>
Promise.resolve(JSON.stringify({ rootOverwrite: 'root', rootValue: 'root' }))

const rootFileTree = new FileTree('/', '')
const subjectFileTree = new FileTree('/sub-01', 'sub-01', rootFileTree)
const sessionFileTree = new FileTree(
'/sub-01/ses-01',
'ses-01',
subjectFileTree,
)
const anatFileTree = new FileTree(
'/sub-01/ses-01/anat',
'anat',
sessionFileTree,
)

const dataFile = {
text,
path: '/sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz',
name: 'sub-01_ses-01_T1w.nii.gz',
size: 311112,
ignored: false,
stream: new ReadableStream<Uint8Array>(),
readBytes: nullReadBytes,
}

anatFileTree.files = [
dataFile,
{
text: anatJson,
path: '/sub-01/ses-01/anat/sub-01_ses-01_T1w.json',
name: 'sub-01_ses-01_T1w.json',
size: 311112,
ignored: false,
stream: new ReadableStream<Uint8Array>(),
readBytes: nullReadBytes,
},
]

sessionFileTree.files = []
sessionFileTree.directories = [anatFileTree]

subjectFileTree.files = [
{
text: subjectJson,
path: '/sub-01/ses-01_T1w.json',
name: 'ses-01_T1w.json',
size: 311112,
ignored: false,
stream: new ReadableStream<Uint8Array>(),
readBytes: nullReadBytes,
},
]
subjectFileTree.directories = [sessionFileTree]

rootFileTree.files = [
{
text: rootJson,
path: '/T1w.json',
name: 'T1w.json',
size: 311112,
ignored: false,
stream: new ReadableStream<Uint8Array>(),
readBytes: nullReadBytes,
},
]
rootFileTree.directories = [subjectFileTree]

let context = new BIDSContext(anatFileTree, dataFile, issues)
import { dataFile, rootFileTree } from './fixtures.test.ts'

Deno.test('test context LoadSidecar', async (t) => {
const context = new BIDSContext(rootFileTree, dataFile, new DatasetIssues())
await context.loadSidecar(rootFileTree)
await t.step('sidecar overwrites correct fields', () => {
const { rootOverwrite, subOverwrite } = context.sidecar
assert(rootOverwrite, 'anat')
assert(subOverwrite, 'anat')
assert(rootOverwrite === 'anat')
assert(subOverwrite === 'anat')
})
await t.step('sidecar adds new fields at each level', () => {
const { rootValue, subValue, anatValue } = context.sidecar
assert(rootValue, 'root')
assert(subValue, 'subject')
assert(anatValue, 'anat')
assert(rootValue === 'root')
assert(subValue === 'subject')
assert(anatValue === 'anat')
})
})

context = new BIDSContext(rootFileTree, dataFile, issues)

Deno.test('test context loadSubjects', async (t) => {
const context = new BIDSContext(rootFileTree, dataFile, new DatasetIssues())
await context.loadSubjects()
await t.step('context produces correct subjects object', () => {
assert(context.dataset.subjects, 'subjects object exists')
Expand Down
206 changes: 206 additions & 0 deletions bids-validator/src/schema/expressionLanguage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { assert } from '../deps/asserts.ts'
import { expressionFunctions } from './expressionLanguage.ts'
import { dataFile, rootFileTree } from './fixtures.test.ts'
import { BIDSContext } from './context.ts'
import { DatasetIssues } from '../issues/datasetIssues.ts'

Deno.test('test expression functions', async (t) => {
const context = new BIDSContext(rootFileTree, dataFile, new DatasetIssues())

await t.step('index function', () => {
const index = expressionFunctions.index
assert(index([1, 2, 3], 2) === 1)
assert(index([1, 2, 3], 4) === null)
assert(index(['a', 'b', 'c'], 'b') === 1)
assert(index(['a', 'b', 'c'], 'd') === null)
})
await t.step('intersects function', () => {
const intersects = expressionFunctions.intersects
assert(intersects([1, 2, 3], [2, 3, 4]) === true)
assert(intersects([1, 2, 3], [4, 5, 6]) === false)
assert(intersects(['abc', 'def'], ['def']) === true)
assert(intersects(['abc', 'def'], ['ghi']) === false)

// Promote scalars to arrays
// @ts-ignore
assert(intersects('abc', ['abc', 'def']) === true)
// @ts-ignore
assert(intersects('abc', ['a', 'b', 'c']) === false)
})
await t.step('match function', () => {
const match = expressionFunctions.match
assert(match('abc', 'abc') === true)
assert(match('abc', 'def') === false)
assert(match('abc', 'a.*') === true)
assert(match('abc', 'd.*') === false)
})
await t.step('type function', () => {
const type = expressionFunctions.type
assert(type(1) === 'number')
assert(type('abc') === 'string')
assert(type([1, 2, 3]) === 'array')
assert(type({ a: 1, b: 2 }) === 'object')
assert(type(true) === 'boolean')
assert(type(null) === 'null')
assert(type(undefined) === 'null')
})
await t.step('min function', () => {
const min = expressionFunctions.min
assert(min([1, 2, 3]) === 1)
assert(min([3, 2, 1]) === 1)
assert(min([]) === Infinity)
// @ts-ignore
assert(min(null) === null)
})
await t.step('max function', () => {
const max = expressionFunctions.max
assert(max([1, 2, 3]) === 3)
assert(max([3, 2, 1]) === 3)
assert(max([]) === -Infinity)
// @ts-ignore
assert(max(null) === null)
})
await t.step('length function', () => {
const length = expressionFunctions.length
assert(length([1, 2, 3]) === 3)
// Out-of-scope (but permitted) inputs
// @ts-ignore
assert(length('abc') === 3)
// Out-of-scope inputs
// @ts-ignore
assert(length({ a: 1, b: 2 }) === null)
// @ts-ignore
assert(length(true) === null)
// @ts-ignore
assert(length(null) === null)
})
await t.step('count function', () => {
const count = expressionFunctions.count
assert(count(['a', 'b', 'a', 'b'], 'a') === 2)
assert(count(['a', 'b', 'a', 'b'], 'c') === 0)
})
await t.step('exists(..., "dataset") function', () => {
const exists = expressionFunctions.exists.bind(context)
assert(exists([], 'dataset') === 0)
assert(
exists(['sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz'], 'dataset') === 1,
)
assert(
exists(
['sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz', 'T1w.json'],
'dataset',
) === 2,
)
})
await t.step('exists(..., "subject") function', () => {
const exists = expressionFunctions.exists.bind(context)
assert(exists([], 'subject') === 0)
assert(exists(['ses-01/anat/sub-01_ses-01_T1w.nii.gz'], 'subject') === 1)
assert(
exists(
['ses-01/anat/sub-01_ses-01_T1w.nii.gz', 'T1w.json'],
'subject',
) === 1,
)
})
await t.step('exists(..., "stimuli") function', () => {
const exists = expressionFunctions.exists.bind(context)
assert(exists([], 'stimuli') === 0)
assert(exists(['stimfile1.png'], 'stimuli') === 1)
assert(exists(['stimfile1.png', 'stimfile2.png'], 'stimuli') === 2)
assert(exists(['X.png', 'Y.png'], 'stimuli') === 0)
})
await t.step('exists(..., "bids-uri") function', () => {
const exists = expressionFunctions.exists.bind(context)
assert(exists([], 'subject') === 0)
assert(
exists(
['bids::sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz'],
'bids-uri',
) === 1,
)
assert(
exists(
['bids::sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz', 'bids::T1w.json'],
'bids-uri',
) === 2,
)
// Not yet implemented; currently returns length of array
// assert(exists(['bids::sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz', 'bids::T2w.json'], 'bids-uri') === 1)
})
await t.step('substr function', () => {
const substr = expressionFunctions.substr
assert(substr('abc', 0, 1) === 'a')
assert(substr('abc', 1, 2) === 'b')
assert(substr('abc', 2, 3) === 'c')
assert(substr('abc', 3, 4) === '')
assert(substr('abc', 0, 4) === 'abc')
// @ts-ignore
assert(substr(null, 0, 1) === null)
// @ts-ignore
assert(substr('abc', null, 1) === null)
// @ts-ignore
assert(substr('abc', 0, null) === null)
})
await t.step('sorted(..., "numeric") function', () => {
const sorted = expressionFunctions.sorted
const array_equal = (a: any[], b: any[]) =>
a.length === b.length && a.every((v, i) => v === b[i])
assert(array_equal(sorted([3, 2, 1], 'numeric'), [1, 2, 3]))
assert(array_equal(sorted([1, 2, 3], 'numeric'), [1, 2, 3]))
assert(array_equal(sorted(['3', '2', '1'], 'numeric'), ['1', '2', '3']))
assert(array_equal(sorted(['1', '2', '3'], 'numeric'), ['1', '2', '3']))
assert(array_equal(sorted([], 'numeric'), []))
assert(array_equal(sorted([5, 25, 125, 625], 'numeric'), [5, 25, 125, 625]))
assert(array_equal(sorted([125, 25, 5, 625], 'numeric'), [5, 25, 125, 625]))
})
await t.step('sorted(..., "lexical") function', () => {
const sorted = expressionFunctions.sorted
const array_equal = (a: any[], b: any[]) =>
a.length === b.length && a.every((v, i) => v === b[i])
assert(array_equal(sorted([3, 2, 1], 'lexical'), [1, 2, 3]))
assert(array_equal(sorted([1, 2, 3], 'lexical'), [1, 2, 3]))
assert(array_equal(sorted(['3', '2', '1'], 'lexical'), ['1', '2', '3']))
assert(array_equal(sorted(['1', '2', '3'], 'lexical'), ['1', '2', '3']))
assert(array_equal(sorted([], 'lexical'), []))
assert(
array_equal(sorted(['5', '25', '125', '625'], 'lexical'), [
'125',
'25',
'5',
'625',
]),
)
assert(
array_equal(sorted(['125', '25', '5', '625'], 'lexical'), [
'125',
'25',
'5',
'625',
]),
)
})
await t.step('sorted(..., "auto") function', () => {
const sorted = expressionFunctions.sorted
const array_equal = (a: any[], b: any[]) =>
a.length === b.length && a.every((v, i) => v === b[i])
assert(array_equal(sorted([125, 25, 5, 625], 'auto'), [5, 25, 125, 625]))
assert(
array_equal(sorted(['5', '25', '125', '625'], 'auto'), [
'125',
'25',
'5',
'625',
]),
)
})
await t.step('allequal function', () => {
const allequal = expressionFunctions.allequal
assert(allequal([1, 2, 1], [1, 2, 1]) === true)
assert(allequal([1, 2, 1], [1, 2, 3]) === false)
assert(allequal(['a', 'b', 'a'], ['a', 'b', 'a']) === true)
assert(allequal(['a', 'b', 'a'], ['a', 'b', 'c']) === false)
assert(allequal([1, 2, 1], [1, 2, 1, 2]) === false)
assert(allequal([1, 2, 1, 2], [1, 2, 1]) === false)
})
})
16 changes: 13 additions & 3 deletions bids-validator/src/schema/expressionLanguage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,19 @@ export const expressionFunctions = {
}
return arg.substr(start, end - start)
},
sorted: <T>(list: T[]): T[] => {
// Use a cmp function that will work for any comparable types
return list.toSorted((a, b) => +(a > b) - +(a < b))
sorted: <T>(list: T[], method: string = 'auto'): T[] => {
const cmp = {
numeric: (a: T, b: T) => {
return Number(a) - Number(b)
},
lexical: (a: T, b: T) => {
return String(a).localeCompare(String(b))
},
auto: (a: T, b: T) => {
return +(a > b) - +(a < b)
},
}[method]
return list.toSorted(cmp)
},
allequal: <T>(a: T[], b: T[]): boolean => {
return (a != null && b != null) && a.length === b.length && a.every((v, i) => v === b[i])
Expand Down
Loading

0 comments on commit 56734ab

Please sign in to comment.