fix(validations): parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX
RULE_VALUE is authored in PRX delimiter form because prxparse() requires it, but HARDREGEX, the SOFTREGEX grid renderer, and failsSoftRegex were all passing that string straight into `new RegExp()`, so the delimiters and flags were matched as literal characters instead of applied - making these rules silently never match real data. Adds parseRegexRule (extracted, tested independently) to strip the delimiters, apply flags, hoist a leading (?i) modifier, and translate \Q...\E and \A/\z to their JS equivalents. Atomic groups and possessive quantifiers are left unfixed (documented, fail-safe) - translating them risks renumbering the pattern's own capture groups. Also switches the REGEX_HARD_COL/REGEX_SOFT_COL mock rules to the delimited form so editor.cy.ts's existing e2e coverage actually exercises this path.
This commit is contained in:
@@ -89,6 +89,35 @@ describe('makeRegexWarningRenderer', () => {
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const hot = new Handsontable(container, {
|
||||
data: [
|
||||
{ val: 'this is dummy data', _____DELETE__THIS__RECORD_____: 'No' },
|
||||
{ val: 'THE WIND WAS BLOWING', _____DELETE__THIS__RECORD_____: 'No' },
|
||||
{ val: 'nothing relevant here', _____DELETE__THIS__RECORD_____: 'No' }
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
data: 'val',
|
||||
renderer: makeRegexWarningRenderer('/\\b(the|data)\\b/i')
|
||||
},
|
||||
{ data: '_____DELETE__THIS__RECORD_____' }
|
||||
],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
|
||||
expect(hot.getCell(0, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
expect(hot.getCell(1, 0)?.classList.contains('dc-warning-cell')).toBeFalse()
|
||||
expect(hot.getCell(2, 0)?.classList.contains('dc-warning-cell')).toBeTrue()
|
||||
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('is display-only — the stored value is untouched', () => {
|
||||
const { hot, container } = buildHot([
|
||||
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
|
||||
import { parseRegexRule } from '../../shared/dc-validator/utils/parseRegexRule'
|
||||
|
||||
/**
|
||||
* Builds a display-only HOT renderer for SOFTREGEX: a value that fails the
|
||||
@@ -18,7 +19,7 @@ export const makeRegexWarningRenderer = (pattern?: string) => {
|
||||
let regex: RegExp | null = null
|
||||
|
||||
try {
|
||||
if (pattern) regex = new RegExp(pattern)
|
||||
if (pattern) regex = parseRegexRule(pattern)
|
||||
} catch (e) {
|
||||
console.warn(`SOFTREGEX - invalid pattern, warning disabled: ${pattern}`)
|
||||
regex = null
|
||||
|
||||
@@ -31,6 +31,7 @@ import { registerIntlCellTypes } from './cellTypes/intlCellTypes'
|
||||
import { makeNumberFormatRenderer } from '../../editor/utils/renderers.utils'
|
||||
import { makeRegexWarningRenderer } from '../../editor/utils/regex-warning-renderer'
|
||||
import { isRegexRuleExempt } from './utils/isRegexRuleExempt'
|
||||
import { parseRegexRule } from './utils/parseRegexRule'
|
||||
|
||||
export class DcValidator {
|
||||
private rules: DcValidation[] = []
|
||||
@@ -261,7 +262,7 @@ export class DcValidator {
|
||||
if (!softRegexRule) return false
|
||||
|
||||
try {
|
||||
return !new RegExp(softRegexRule.RULE_VALUE).test(value.toString())
|
||||
return !parseRegexRule(softRegexRule.RULE_VALUE).test(value.toString())
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -670,6 +670,27 @@ describe('DC Validator', () => {
|
||||
).toBeFalse()
|
||||
})
|
||||
|
||||
it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
|
||||
const dcValidator = buildValidator([
|
||||
{
|
||||
BASE_COL: 'SOME_CHAR_ANY',
|
||||
RULE_TYPE: 'SOFTREGEX',
|
||||
RULE_VALUE: '/\\b(the|data)\\b/i',
|
||||
X: 0
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'this is dummy data')
|
||||
).toBeFalse()
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'THE WIND WAS BLOWING')
|
||||
).toBeFalse()
|
||||
expect(
|
||||
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'nothing relevant here')
|
||||
).toBeTrue()
|
||||
})
|
||||
|
||||
it('is false for a column with no SOFTREGEX rule', () => {
|
||||
const dcValidator = buildValidator([])
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { parseRegexRule } from './parseRegexRule'
|
||||
|
||||
describe('parseRegexRule', () => {
|
||||
it('treats a bare pattern (no delimiters) exactly as before', () => {
|
||||
const regex = parseRegexRule('^[A-Z]{3}\\d{4}$')
|
||||
|
||||
expect(regex.test('ABC1234')).toBeTrue()
|
||||
expect(regex.test('abc1234')).toBeFalse()
|
||||
})
|
||||
|
||||
it('parses a delimited pattern with no flags, case-sensitively', () => {
|
||||
const regex = parseRegexRule('/\\b(the|data)\\b/')
|
||||
|
||||
expect(regex.test('this is dummy data')).toBeTrue()
|
||||
expect(regex.test('THE WIND WAS BLOWING')).toBeFalse()
|
||||
})
|
||||
|
||||
it('parses a delimited pattern with the i flag, case-insensitively', () => {
|
||||
const regex = parseRegexRule('/\\b(the|data)\\b/i')
|
||||
|
||||
expect(regex.test('THIS IS DUMMY DATA')).toBeTrue()
|
||||
expect(regex.test('The wind was blowing')).toBeTrue()
|
||||
expect(regex.test('nothing relevant here')).toBeFalse()
|
||||
})
|
||||
|
||||
it('extracts source and flags correctly from a delimited pattern', () => {
|
||||
const regex = parseRegexRule('/^[A-Z]+$/g')
|
||||
|
||||
expect(regex.source).toEqual('^[A-Z]+$')
|
||||
expect(regex.flags).toEqual('g')
|
||||
})
|
||||
|
||||
it('treats a bare pattern containing an internal slash as a literal, not a delimited form', () => {
|
||||
const regex = parseRegexRule('a/b')
|
||||
|
||||
expect(regex.test('a/b')).toBeTrue()
|
||||
expect(regex.test('ab')).toBeFalse()
|
||||
})
|
||||
|
||||
it('throws for an unterminated delimited pattern, same as a bad bare pattern would', () => {
|
||||
expect(() => parseRegexRule('/[unterminated/')).toThrow()
|
||||
})
|
||||
|
||||
// parseRegexRule strips PRX's /pattern/flags delimiter wrapper and
|
||||
// translates the three PRX-only constructs below to a JS equivalent. These
|
||||
// three were picked because they're mechanical, low-risk translations with
|
||||
// one unambiguous JS equivalent. Atomic groups and possessive quantifiers are
|
||||
// NOT translated - both would need a lookahead+backreference rewrite that
|
||||
// changes the pattern's capture-group numbering, which is a real
|
||||
// correctness risk for comparatively rare constructs, so they're left as a
|
||||
// documented limitation below instead.
|
||||
describe('translated PRX-only syntax', () => {
|
||||
it('hoists a leading (?i) inline modifier into the i flag', () => {
|
||||
const regex = parseRegexRule('/(?i)abc/')
|
||||
|
||||
expect(regex.test('ABC')).toBeTrue()
|
||||
expect(regex.test('abc')).toBeTrue()
|
||||
})
|
||||
|
||||
it('merges a leading (?i) with flags already present after the closing delimiter', () => {
|
||||
const regex = parseRegexRule('/(?i)^[a-z]+$/g')
|
||||
|
||||
expect(regex.flags).toContain('i')
|
||||
expect(regex.flags).toContain('g')
|
||||
expect(regex.test('ABC')).toBeTrue()
|
||||
})
|
||||
|
||||
it('does not add a duplicate i flag when (?i) and the trailing /i are both present', () => {
|
||||
expect(() => parseRegexRule('/(?i)abc/i')).not.toThrow()
|
||||
})
|
||||
|
||||
it('treats \\Q...\\E as a literal, metacharacter-escaped sequence', () => {
|
||||
const regex = parseRegexRule('/\\Qa+b*c\\E/')
|
||||
|
||||
expect(regex.test('a+b*c')).toBeTrue()
|
||||
// Without the \Q...\E escaping, '+' and '*' would be quantifiers, so
|
||||
// this would also match a literal "c" alone. It shouldn't.
|
||||
expect(regex.test('c')).toBeFalse()
|
||||
})
|
||||
|
||||
it('translates \\A/\\z to absolute start/end anchors equivalent to PRX semantics', () => {
|
||||
const regex = parseRegexRule('/\\Afoo\\z/')
|
||||
|
||||
expect(regex.test('foo')).toBeTrue()
|
||||
expect(regex.test('foobar')).toBeFalse()
|
||||
expect(regex.test('barfoo')).toBeFalse()
|
||||
// \z (unlike JS's own $) does not match immediately before a
|
||||
// trailing newline - this is stricter, matching PRX/Perl semantics.
|
||||
expect(regex.test('foo\n')).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
// Pre-existing gaps, not regressions from this fix - both fail safely:
|
||||
// they throw, same as any other malformed pattern, and each call site
|
||||
// already treats a throw as "always valid" / "never warn".
|
||||
describe('known limitations - PRX-only syntax not translated', () => {
|
||||
it('throws on free-spacing/extended (/x) mode (valid PRX, not a JS flag)', () => {
|
||||
expect(() => parseRegexRule('/a+ \\s+ b/x')).toThrow()
|
||||
})
|
||||
|
||||
it('throws on an atomic group (?>...) (valid PRX, unsupported in JS)', () => {
|
||||
expect(() => parseRegexRule('/(?>a|ab)c/')).toThrow()
|
||||
})
|
||||
|
||||
it('throws on a possessive quantifier, e.g. a++ (valid PRX, unsupported in JS)', () => {
|
||||
expect(() => parseRegexRule('/a++b/')).toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* HARDREGEX/SOFTREGEX rule values are authored as SAS PRX patterns, which
|
||||
* require the delimiter form `/pattern/flags` (prxparse's own syntax check
|
||||
* in mpe_validations_postedit.sas requires it). A bare pattern with no
|
||||
* delimiters is also accepted, for backwards compatibility with existing
|
||||
* rules/tests. Passing the delimited form as-is to `new RegExp(string)`
|
||||
* would treat the slashes and flags as literal pattern text instead of
|
||||
* stripping/applying them, so this parses the PRX form before constructing
|
||||
* the regex.
|
||||
*
|
||||
* PRX also accepts some Perl-only regex syntax with no direct JS equivalent.
|
||||
* Three mechanical, unambiguous cases are translated below - a leading
|
||||
* `(?i)` modifier, an escaped `\Q...\E` literal sequence, and `\A`/`\z`
|
||||
* absolute anchors.
|
||||
* Atomic groups (`(?>...)`) and possessive quantifiers (`a++`) are NOT
|
||||
* translated: both would need a lookahead+backreference rewrite that
|
||||
* changes the pattern's capture-group numbering, a real correctness risk
|
||||
* for comparatively rare constructs, so they're left to fail (they already
|
||||
* throw, which every caller already treats as "always valid" / "never
|
||||
* warn", same as any other malformed pattern).
|
||||
*/
|
||||
const escapeRegExpMetacharacters = (text: string): string =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
export const parseRegexRule = (ruleValue: string): RegExp => {
|
||||
const delimited = /^\/(.*)\/([a-z]*)$/s.exec(ruleValue)
|
||||
|
||||
if (!delimited) return new RegExp(ruleValue)
|
||||
|
||||
let [, body, flags] = delimited
|
||||
|
||||
const inlineCaseInsensitive = /^\(\?i\)/.exec(body)
|
||||
|
||||
if (inlineCaseInsensitive) {
|
||||
body = body.slice(inlineCaseInsensitive[0].length)
|
||||
if (!flags.includes('i')) flags += 'i'
|
||||
}
|
||||
|
||||
body = body.replace(/\\Q([\s\S]*?)\\E/g, (_match, literal) =>
|
||||
escapeRegExpMetacharacters(literal)
|
||||
)
|
||||
|
||||
body = body.replace(/\\A/g, '^').replace(/\\z/g, '(?![\\s\\S])')
|
||||
|
||||
return new RegExp(body, flags)
|
||||
}
|
||||
@@ -55,6 +55,24 @@ describe('dqValidate - HARDREGEX', () => {
|
||||
expect(dqValidate(rules, 'user@example')).toBeFalse()
|
||||
})
|
||||
|
||||
it('accepts a value matching a PRX-delimited pattern (/pattern/flags, as authored for prxparse)', () => {
|
||||
const rules = [rule({ RULE_VALUE: '/^[A-Z]{3}\\d{4}$/' })]
|
||||
|
||||
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
|
||||
})
|
||||
|
||||
it('rejects a value not matching a PRX-delimited pattern', () => {
|
||||
const rules = [rule({ RULE_VALUE: '/^[A-Z]{3}\\d{4}$/' })]
|
||||
|
||||
expect(dqValidate(rules, 'abc1234')).toBeFalse()
|
||||
})
|
||||
|
||||
it('applies the i flag from a PRX-delimited pattern', () => {
|
||||
const rules = [rule({ RULE_VALUE: '/^[a-z]{3}\\d{4}$/i' })]
|
||||
|
||||
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
|
||||
})
|
||||
|
||||
it('still evaluates a second, unrelated rule on the same column', () => {
|
||||
const rules = [
|
||||
rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { specialMissingNumericValidator } from './hot-custom-validators'
|
||||
import { isRegexRuleExempt } from '../utils/isRegexRuleExempt'
|
||||
import { parseRegexRule } from '../utils/parseRegexRule'
|
||||
|
||||
const dqValidation: {
|
||||
[key: string]: (value: any, ruleValue: string | number) => boolean
|
||||
@@ -54,7 +55,7 @@ const dqValidation: {
|
||||
if (isRegexRuleExempt(value)) return true
|
||||
|
||||
try {
|
||||
return new RegExp(ruleValue.toString()).test(value.toString())
|
||||
return parseRegexRule(ruleValue.toString()).test(value.toString())
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`HARDREGEX - invalid pattern, treated as always-valid: ${ruleValue}`
|
||||
|
||||
@@ -281,8 +281,8 @@ let webouts = {
|
||||
{ BASE_COL: "HIDDEN_COL", RULE_TYPE: "HIDDEN", RULE_VALUE: "Hidden default" },
|
||||
{ BASE_COL: "ROUND_COL", RULE_TYPE: "ROUND", RULE_VALUE: "2" },
|
||||
{ BASE_COL: "NUMFMT_COL", RULE_TYPE: "NUMBER_FORMAT", RULE_VALUE: '{"style":"currency","currency":"EUR"}' },
|
||||
{ BASE_COL: "REGEX_HARD_COL", RULE_TYPE: "HARDREGEX", RULE_VALUE: "[\\w.]+@[\\w]+\\.[a-z]{2,}" },
|
||||
{ BASE_COL: "REGEX_SOFT_COL", RULE_TYPE: "SOFTREGEX", RULE_VALUE: "[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}" }
|
||||
{ BASE_COL: "REGEX_HARD_COL", RULE_TYPE: "HARDREGEX", RULE_VALUE: "/[\\w.]+@[\\w]+\\.[a-z]{2,}/" },
|
||||
{ BASE_COL: "REGEX_SOFT_COL", RULE_TYPE: "SOFTREGEX", RULE_VALUE: "/[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/" }
|
||||
],
|
||||
dsmeta: [
|
||||
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "DC996664.MPE_X_TEST" },
|
||||
|
||||
Reference in New Issue
Block a user