Skip to content

Commit ba58d76

Browse files
committed
#195 - support for metadata value matchers
- extended the supported syntax to string, extract string, ranges with various formats and comparators: alphanumeric, true-alphabetical, numeric (integers), numeric (floating point) - full unit tests coverage of the syntax
1 parent 2e75619 commit ba58d76

File tree

3 files changed

+263
-20
lines changed

3 files changed

+263
-20
lines changed

src/custom-sort/mdata-matchers.ts

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,24 @@ import {
22
getNormalizedDate_NormalizerFn_for
33
} from "./matchers";
44
import {NormalizerFn} from "./custom-sort-types";
5+
import {CollatorCompare, CollatorTrueAlphabeticalCompare} from "./custom-sort";
56

67
export interface MDataMatcher {
78
(mdataValue: string): boolean
89
}
910

10-
export interface MDataMatcherFactory {
11-
(specsMatch: string|RegExpMatchArray): MDataMatcher
11+
export type SN = string|number
12+
export type SNResult<T extends SN> = T extends number ? number : string
13+
export type CompareFn<T extends SN> = (a: T, b: T) => number
14+
export interface MDataMatcherFactory<T extends SN> {
15+
(specsMatch: string|RegExpMatchArray, compareFn: CompareFn<SNResult<T>>, mdvConverter: MDVConverter<SNResult<T>>): MDataMatcher
1216
}
1317

14-
interface ValueMatcherSpec {
18+
interface ValueMatcherSpec<T extends SN> {
1519
specPattern: string|RegExp,
16-
valueMatcherFnFactory: MDataMatcherFactory
20+
valueMatcherFnFactory: MDataMatcherFactory<SNResult<T>>
21+
compareFn: CompareFn<SNResult<T>>
22+
mdvConterter?: MDVConverter<SNResult<T>>
1723
unitTestsId: string
1824
}
1925

@@ -22,65 +28,100 @@ export interface MDataMatcherParseResult {
2228
remainder: string
2329
}
2430

25-
const VALUE_MATCHER_REGEX = /value\(([^)]+)\)/
26-
function getPlainValueMatcherFn(specsMatch: RegExpMatchArray) {
31+
const VALUE_MATCHER_REGEX = /value\(([^)]+)\)/ // 001 === 1
32+
const VALUE_TRUE_ALPHABETIC_MATCHER_REGEX = /valueE\(([^)]+)\)/ // 001 != 1
33+
function getPlainValueMatcherFn(specsMatch: RegExpMatchArray, compareFn: CompareFn<string>) {
2734
const EXACT_VALUE_IDX = 1 // Related to the spec regexp
2835
const expectedValue = specsMatch[EXACT_VALUE_IDX].trim()
2936
return (mdataValue: string): boolean => {
30-
return mdataValue === expectedValue
37+
return compareFn(mdataValue, expectedValue) === 0
3138
}
3239
}
3340

3441
const RANGE_MATCHER_REGEX = /range([[(])([^,]*),([^)\]]*)([)\]])/
42+
const RANGE_TRUE_ALPHABETIC_MATCHER_REGEX = /rangeE([[(])([^,]*),([^)\]]*)([)\]])/
43+
const RANGE_NUMERIC_MATCHER_REGEX_INT = /rangeN([[(])\s*(-?\d*)\s*,\s*(-?\d*)\s*([)\]])/
44+
const RANGE_NUMERIC_MATCHER_REGEX_FLOAT = /rangeF([[(])\s*?(-?\d+\.\d+)?\s*,\s*(-?\d+\.\d+)?\s*([)\]])/
3545
/*
3646
range(aaa,bbb)
3747
range[aaa,bbb)
3848
range(, x)
3949
range( y, ]
4050
*/
51+
52+
const CompareIntFn: CompareFn<number> = (a: number, b: number) => a - b
53+
const CompareFloatFn: CompareFn<number> = (a: number, b: number) => a - b
54+
type MDVConverter<T extends SN> = (s: string) => SNResult<T>
55+
4156
enum RangeEdgeType { INCLUSIVE, EXCLUSIVE}
42-
function getRangeMatcherFn(specsMatch: RegExpMatchArray) {
57+
function getRangeMatcherFn<T extends SN>(specsMatch: RegExpMatchArray, compareFn: CompareFn<SNResult<T>>, mdvConverter: MDVConverter<T>) {
4358
const RANGE_START_TYPE_IDX = 1
4459
const RANGE_START_IDX = 2
4560
const RANGE_END_IDX = 3
4661
const RANGE_END_TYPE_IDX = 4
47-
const rangeStartType: RangeEdgeType = specsMatch[RANGE_END_TYPE_IDX] === '(' ? RangeEdgeType.EXCLUSIVE : RangeEdgeType.INCLUSIVE
48-
const rangeStartValue: string = specsMatch[RANGE_START_IDX].trim()
49-
const rangeEndValue: string = specsMatch[RANGE_END_IDX].trim()
62+
const rangeStartType: RangeEdgeType = specsMatch[RANGE_START_TYPE_IDX] === '(' ? RangeEdgeType.EXCLUSIVE : RangeEdgeType.INCLUSIVE
63+
const rangeStartValue: SNResult<T> = mdvConverter(specsMatch[RANGE_START_IDX].trim())
64+
const rangeEndValue: SNResult<T> = mdvConverter(specsMatch[RANGE_END_IDX].trim())
5065
const rangeEndType: RangeEdgeType = specsMatch[RANGE_END_TYPE_IDX] === ')' ? RangeEdgeType.EXCLUSIVE : RangeEdgeType.INCLUSIVE
5166
return (mdataValue: string): boolean => {
5267
let rangeStartMatched = true
68+
const mdv: SNResult<T> = mdvConverter(mdataValue)
5369
if (rangeStartValue) {
5470
if (rangeStartType === RangeEdgeType.INCLUSIVE) {
55-
rangeStartMatched = mdataValue >= rangeStartValue
71+
rangeStartMatched = compareFn (mdv, rangeStartValue) >= 0
5672
} else {
57-
rangeStartMatched = mdataValue > rangeStartValue
73+
rangeStartMatched = compareFn (mdv, rangeStartValue) > 0
5874
}
5975
}
6076
let rangeEndMatched = true
6177
if (rangeEndValue) {
6278
if (rangeEndType === RangeEdgeType.INCLUSIVE) {
63-
rangeEndMatched = mdataValue <= rangeEndValue
79+
rangeEndMatched = compareFn (mdv, rangeEndValue) <= 0
6480
} else {
65-
rangeEndMatched = mdataValue < rangeEndValue
81+
rangeEndMatched = compareFn (mdv, rangeEndValue) < 0
6682
}
6783
}
6884

69-
return rangeStartMatched && rangeEndMatched
85+
return rangeStartMatched && rangeEndMatched
7086
}
7187
}
7288

73-
const ValueMatchers: ValueMatcherSpec[] = [
89+
const ValueMatchers: ValueMatcherSpec<SN>[] = [
7490
{ specPattern: VALUE_MATCHER_REGEX,
7591
valueMatcherFnFactory: getPlainValueMatcherFn,
92+
compareFn: CollatorCompare,
7693
unitTestsId: 'value'
94+
}, {
95+
specPattern: VALUE_TRUE_ALPHABETIC_MATCHER_REGEX,
96+
valueMatcherFnFactory: getPlainValueMatcherFn,
97+
compareFn: CollatorTrueAlphabeticalCompare,
98+
unitTestsId: 'valueE'
7799
}, {
78100
specPattern: RANGE_MATCHER_REGEX,
79101
valueMatcherFnFactory: getRangeMatcherFn,
102+
compareFn: CollatorCompare,
80103
unitTestsId: 'range'
104+
},{
105+
specPattern: RANGE_TRUE_ALPHABETIC_MATCHER_REGEX,
106+
valueMatcherFnFactory: getRangeMatcherFn,
107+
compareFn: CollatorTrueAlphabeticalCompare,
108+
unitTestsId: 'rangeE'
109+
},{
110+
specPattern: RANGE_NUMERIC_MATCHER_REGEX_INT,
111+
valueMatcherFnFactory: getRangeMatcherFn,
112+
compareFn: CompareIntFn,
113+
mdvConterter: (s: string) => ~~s,
114+
unitTestsId: 'rangeN'
115+
},{
116+
specPattern: RANGE_NUMERIC_MATCHER_REGEX_FLOAT,
117+
valueMatcherFnFactory: getRangeMatcherFn,
118+
compareFn: CompareFloatFn,
119+
mdvConterter: (s: string) => parseFloat(s),
120+
unitTestsId: 'rangeF'
81121
}, {
82122
specPattern: 'any-value', // Artificially added for testing purposes
83123
valueMatcherFnFactory: () => (s: string) => true,
124+
compareFn: CollatorCompare, // Not used
84125
unitTestsId: 'any-value-explicit'
85126
}
86127
]
@@ -90,14 +131,14 @@ export const tryParseAsMDataMatcherSpec = (s: string): MDataMatcherParseResult|u
90131
for (const matcherSpec of ValueMatchers) {
91132
if ('string' === typeof matcherSpec.specPattern && s.trim().startsWith(matcherSpec.specPattern)) {
92133
return {
93-
m: matcherSpec.valueMatcherFnFactory(matcherSpec.specPattern),
134+
m: matcherSpec.valueMatcherFnFactory(matcherSpec.specPattern, matcherSpec.compareFn, (s: string) => s ),
94135
remainder: s.substring(matcherSpec.specPattern.length).trim()
95136
}
96137
} else { // regexp
97138
const match = s.match(matcherSpec.specPattern)
98139
if (match) {
99140
return {
100-
m: matcherSpec.valueMatcherFnFactory(match),
141+
m: matcherSpec.valueMatcherFnFactory(match, matcherSpec.compareFn, matcherSpec.mdvConterter ?? (s => s)),
101142
remainder: s.substring(match[0].length).trim()
102143
}
103144
}
@@ -109,5 +150,8 @@ export const tryParseAsMDataMatcherSpec = (s: string): MDataMatcherParseResult|u
109150
export const _unitTests = {
110151
matcherFn_value: ValueMatchers.find((it) => it.unitTestsId === 'value'),
111152
matcherFn_range: ValueMatchers.find((it) => it.unitTestsId === 'range'),
153+
matcherFn_rangeE: ValueMatchers.find((it) => it.unitTestsId === 'rangeE'),
154+
matcherFn_rangeN: ValueMatchers.find((it) => it.unitTestsId === 'rangeN'),
155+
matcherFn_rangeF: ValueMatchers.find((it) => it.unitTestsId === 'rangeF'),
112156
matcherFn_anyValue: ValueMatchers.find((it) => it.unitTestsId === 'any-value-explicit'),
113157
}

src/custom-sort/sorting-spec-processor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1783,7 +1783,7 @@ export class SortingSpecProcessor {
17831783
if (hasMetadataMatcher) {
17841784
metadataMatcher = hasMetadataMatcher.m
17851785
} else {
1786-
this.problem(ProblemCode.UnrecognizedMetadataValueMatcher, "unrecognized metadata value matcher specification")
1786+
this.problem(ProblemCode.UnrecognizedMetadataValueMatcher, "unrecognized or malformed metadata value matcher specification")
17871787
return null;
17881788
}
17891789
} else {
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import {
2+
tryParseAsMDataMatcherSpec,
3+
_unitTests
4+
} from '../../custom-sort/mdata-matchers'
5+
6+
describe('MDataMatcher', () => {
7+
it('value(...) should correctly parse plain value matcher and do the matching - plain syntax', () => {
8+
const matcher = tryParseAsMDataMatcherSpec('value(testValue)')
9+
expect(matcher!).toBeDefined()
10+
expect(matcher!.m('testValue')).toBe(true)
11+
expect(matcher!.m('otherValue')).toBe(false)
12+
expect(matcher!.remainder).toBe('')
13+
})
14+
it('value(...) should correctly parse plain value matcher and do the matching - syntax with spaces', () => {
15+
const result = tryParseAsMDataMatcherSpec('value( test value ) ')
16+
expect(result).toBeDefined()
17+
expect(result!.m('test value')).toBe(true)
18+
expect(result!.m('other value')).toBe(false)
19+
expect(result!.m('')).toBe(false)
20+
expect(result!.remainder).toBe('')
21+
})
22+
it('value(...) should correctly parse plain value matcher and do the matching - syntax with spaces and remainder', () => {
23+
const result = tryParseAsMDataMatcherSpec('value( test Value ) some remainder')
24+
expect(result).toBeDefined()
25+
expect(result!.m('test Value')).toBe(true)
26+
expect(result!.m('otherValue')).toBe(false)
27+
expect(result!.remainder).toBe('some remainder')
28+
})
29+
it('value(...) should correctly parse plain value matcher and do the matching - numbers', () => {
30+
const matcher = tryParseAsMDataMatcherSpec('value(123.01)')
31+
expect(matcher!).toBeDefined()
32+
expect(matcher!.m('123.01')).toBe(true)
33+
expect(matcher!.m('123.1')).toBe(true)
34+
expect(matcher!.m('0123.01')).toBe(true)
35+
expect(matcher!.m('00123.000')).toBe(false)
36+
expect(matcher!.m('0000123.0001')).toBe(true)
37+
expect(matcher!.remainder).toBe('')
38+
})
39+
it('valueE(...) should correctly parse plain value matcher and do the matching - numbers and true alphabetical comparison', () => {
40+
const matcher = tryParseAsMDataMatcherSpec('valueE(123.01)')
41+
expect(matcher!).toBeDefined()
42+
expect(matcher!.m('123.01')).toBe(true)
43+
expect(matcher!.m('123.1')).toBe(false)
44+
expect(matcher!.m('0123.01')).toBe(false)
45+
expect(matcher!.m('00123.000')).toBe(false)
46+
expect(matcher!.m('0000123.0001')).toBe(false)
47+
expect(matcher!.remainder).toBe('')
48+
})
49+
it('value(...) should reject error empty value spec', () => {
50+
const result = tryParseAsMDataMatcherSpec('value()')
51+
expect(result).toBeUndefined()
52+
})
53+
54+
it('should correctly parse any-value matcher', () => {
55+
const result = tryParseAsMDataMatcherSpec('any-value')
56+
expect(result).toBeDefined()
57+
expect(result!.m('anyValue')).toBe(true)
58+
expect(result!.m('anotherValue')).toBe(true)
59+
expect(result!.m('')).toBe(true)
60+
expect(result!.remainder).toBe('')
61+
})
62+
63+
it('should return undefined for unknown matcher', () => {
64+
const result = tryParseAsMDataMatcherSpec('unknown-matcher')
65+
expect(result).toBeUndefined()
66+
})
67+
})
68+
69+
describe('MDataMatcher - range matcher', () => {
70+
it.each([
71+
// Default alphabetical comparison
72+
['range[aaa,bbb)', 'aaa', true],
73+
['range[ aaa, bbb)', 'aax', true],
74+
['range[aaa ,bbb )', 'aa', false],
75+
['range[ aaa , bbb )', 'bbb', false],
76+
['range( aaa,bbb]', 'aaaa', true],
77+
['range(aaa ,bbb]', 'bbb', true],
78+
['range(aaa, bbb]', 'aaa', false],
79+
['range( aaa , bbb ]', 'bbc', false],
80+
['range(,456)', '1', true],
81+
['range(,456)', '456', false],
82+
['range(,456)', '0456', false],
83+
['range(,456)', '1000', false],
84+
['range(123,)', '1', false],
85+
['range(123,)', '01', false],
86+
['range(123,)', '123', false],
87+
['range(123,)', '0123', false],
88+
['range(123,)', '1000', true],
89+
90+
// True alphabetical comparison
91+
['rangeE(,456)', '1', true],
92+
['rangeE(,456)', '456', false],
93+
['rangeE(,456)', '0456', true],
94+
['rangeE(,456)', '1000', true],
95+
['rangeE(123,)', '1', false],
96+
['rangeE(123,)', '01', false],
97+
['rangeE(123,)', '123', false],
98+
['rangeE(123,)', '124', true],
99+
['rangeE(123,)', '0123', false],
100+
['rangeE(123,)', '1000', false],
101+
102+
// Trickier cases
103+
['range[2025-02-17,2025-02-17]', '2025-02-17', true],
104+
['range[2025-02-17,2025-02-17]', '2025-2-17', true],
105+
['range[2025-02-17,2025-02-17]', '002025-2-017', true],
106+
107+
['rangeE[2025-02-17,2025-02-17]', '2025-02-17', true],
108+
['rangeE[2025-02-17,2025-02-17]', '2025-2-17', false],
109+
['rangeE[2025-02-17,2025-02-17]', '002025-2-017', false],
110+
111+
// Edge cases
112+
['range(1,1)', '1', false],
113+
['range[1,1)', '1', false],
114+
['range(1,1]', '1', false],
115+
['range[1,1]', '1', true],
116+
['range(,)', '1', true],
117+
['range(,)', '', true],
118+
['range(,)', 'anything', true],
119+
120+
// range[-1,1] is not what you would expect, it is not numerical comparison
121+
['range[-1,1]', '-10', true],
122+
['range[-1,1]', '-2', true],
123+
['range[-1,1]', '-1', true],
124+
['range[-1,1]', '0', true],
125+
['range[-1,1]', '1', true],
126+
['range[-1,1]', '2', false],
127+
])('should correctly parse range matcher %s and evaluate against %s', (spec, value, result) => {
128+
const matcher = tryParseAsMDataMatcherSpec(spec)
129+
expect(matcher).toBeDefined()
130+
expect(matcher!.m(value)).toBe(result)
131+
expect(matcher!.remainder).toBe('')
132+
})
133+
it.each([
134+
// Numerical ranges
135+
136+
// rangeN[-1,1] is exactly what you would expect, numerical comparison
137+
['rangeN[-1,1]', '-10', false],
138+
['rangeN[-1,1)', '-2', false],
139+
['rangeN[-1,1)', '-1', true],
140+
['rangeN(-1,1)', '0', true],
141+
['rangeN(-1,1]', '1', true],
142+
['rangeN[-1,1]', '2', false],
143+
144+
// tricky - mdata value interpreted as integer
145+
['rangeN[-1,1]', '1.0', true],
146+
['rangeN[-1,1]', '1.1', true],
147+
['rangeN[-1,1]', '-1.2', true],
148+
149+
// rangeF[-1.5,1.5] is numerical, floating point correct behavior
150+
['rangeF[-1.0,1.0]', '-10', false],
151+
['rangeF[-1.0,1.0]', '-1.0', true],
152+
['rangeF[-1.0,1.0]', '-1.1', false],
153+
['rangeF[-1.0,1.0]', '1.1', false],
154+
['rangeF[-10.0,11.0]', '-10.1', false],
155+
['rangeF(-1.0,1.0]', '-2', false],
156+
['rangeF(-0.456,1.0]', '-0.455', true],
157+
['rangeF(-0.456,2.0]', '-0.456', false],
158+
['rangeF[-0.456,0.999]', '-0.456', true],
159+
['rangeF(-0.456,444.4]', '-0.457', false],
160+
161+
// reverse range spec - always false
162+
['rangeF[1.0,-1.0]', '0', false],
163+
164+
// NaN and non-float or not-numeric values
165+
['rangeF[1000.1,1000.999]', '', false],
166+
['rangeF[1000.1,1000.999]', 'abc', false],
167+
['rangeF[1000.1,1000.999]', '+1000.12bvcs', true],
168+
['rangeF[-1000.1,1000.999]', '-.0', true],
169+
['rangeF[-1000.1,1000.999]', '0.', true],
170+
171+
])('should correctly parse numerical range matcher %s and evaluate against %s', (spec, value, result) => {
172+
const matcher = tryParseAsMDataMatcherSpec(spec)
173+
expect(matcher).toBeDefined()
174+
expect(matcher!.m(value)).toBe(result)
175+
expect(matcher!.remainder).toBe('')
176+
})
177+
it.each([
178+
// invalid rangeN full explicit [-]N format required
179+
'rangeN[1.,1]',
180+
'rangeN[1.,1.1]',
181+
'rangeN[1.1,1.]',
182+
'rangeN[-1.,1.]',
183+
'rangeN[-1.,-1.]',
184+
'rangeN[0,.1]',
185+
186+
// invalid rangeF syntax - full explicit [-]N.N format required
187+
'rangeF[1,1]',
188+
'rangeF[1.,1.1]',
189+
'rangeF[1.1,1.]',
190+
'rangeF[-1.,1.]',
191+
'rangeF[-1.,-1.]',
192+
'rangeF[.0,.1]',
193+
'rangeF[.,1.0]',
194+
195+
])('should not parse not strictly formatted rangeN or rangeF matcher %s ', (spec) => {
196+
const matcher = tryParseAsMDataMatcherSpec(spec)
197+
expect(matcher).toBeUndefined()
198+
})
199+
})

0 commit comments

Comments
 (0)