fix(validations): parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX
Build / Build-and-ng-test (pull_request) Successful in 5m27s
Build / Build-and-test-development (pull_request) Successful in 15m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m43s

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:
YuryShkoda
2026-07-23 13:37:22 +03:00
parent 6cd9b68581
commit 7ed3730ae3
9 changed files with 231 additions and 5 deletions
@@ -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}`
+2 -2
View File
@@ -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" },