diff --git a/.eslintrc.json b/.eslintrc.json index bea60a2be..07af91d88 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -68,7 +68,8 @@ "prefer-rest-params": "off", "react/jsx-pascal-case": "off", "react/no-children-prop": "off", - "react/prop-types": "off" + "react/prop-types": "off", + "valid-jsdoc": "off" }, "settings": { "import/core-modules": ["meteor/aldeed:simple-schema", "meteor/check"], diff --git a/docs/api-helpers.md b/docs/api-helpers.md index 7cbca4cf1..a9d5ede22 100644 --- a/docs/api-helpers.md +++ b/docs/api-helpers.md @@ -104,15 +104,40 @@ filterDOMProps.register('propA', 'propB'); ## `joinName` -Safely joins partial field names. When the first param is null, returns an array of strings. Otherwise, returns a string. If you create a custom field with subfields, then it's better to use this helper than manually concatenating them. +Safely joins partial field names. +If you create a custom field with subfields, do use `joinName` instead of manually concatenating them. +It ensures that the name will be correctly escaped if needed. ```tsx import { joinName } from 'uniforms'; -joinName(null, 'a', 'b.c', 'd'); // ['a', 'b', 'c', 'd'] -joinName('a', 'b.c', 'd'); // 'a.b.c.d' +joinName('array', 1, 'field'); // 'array.1.field' +joinName('object', 'nested.property'); // 'object.nested.property' ``` +If the first argument is `null`, then it returns an array of escaped parts. + +```tsx +import { joinName } from 'uniforms'; + +joinName(null, 'array', 1, 'field'); // ['array', '1', 'field'] +joinName(null, 'object', 'nested.property'); // ['object', 'nested', 'property'] +``` + +If the field name contains a dot (`.`) or a bracket (`[` or `]`), it has to be escaped with `["..."]`. +If any of these characters is not escaped, `joinName` will **not** throw an error but its behavior is not specified. +The escape of any other name part will be stripped. + +```tsx +joinName(null, 'object["with.dot"].field'); // ['object', '["with.dot"]', 'field'] +joinName('object["with.dot"].field'); // 'object["with.dot"].field' + +joinName(null, 'this["is"].safe'); // ['this', 'is', 'safe'] +joinName('this["is"].safe'); // 'this.is.safe' +``` + +For more examples check [`joinName` tests](https://github.com/vazco/uniforms/blob/master/packages/uniforms/__tests__/joinName.ts). + ## `randomIds` Generates random ID, based on given prefix. Use it, if you want to have random but deterministic strings. If no prefix is provided, a unique 'uniforms-X' prefix will be used generated. diff --git a/packages/uniforms-bridge-json-schema/__tests__/JSONSchemaBridge.ts b/packages/uniforms-bridge-json-schema/__tests__/JSONSchemaBridge.ts index 8f84ff80b..e3a745541 100644 --- a/packages/uniforms-bridge-json-schema/__tests__/JSONSchemaBridge.ts +++ b/packages/uniforms-bridge-json-schema/__tests__/JSONSchemaBridge.ts @@ -173,6 +173,39 @@ describe('JSONSchemaBridge', () => { objectWithoutProperties: { type: 'object' }, withLabel: { type: 'string', uniforms: { label: 'Example' } }, forcedRequired: { type: 'string', uniforms: { required: true } }, + 'path.with.a.dot': { + type: 'object', + properties: { + 'another.with.a.dot': { + type: 'string', + }, + another: { + type: 'object', + properties: { + with: { + type: 'object', + properties: { + a: { + type: 'object', + properties: { + dot: { + type: 'number', + }, + }, + }, + }, + }, + }, + }, + }, + }, + path: { + type: 'object', + properties: { + 'with.a.dot': { type: 'number' }, + '["with.a.dot"]': { type: 'string' }, + }, + }, }, required: ['dateOfBirth', 'nonObjectAnyOfRequired'], }; @@ -477,6 +510,28 @@ describe('JSONSchemaBridge', () => { }); }); + it('returns correct definition (dots in name)', () => { + expect(bridge.getField('["path.with.a.dot"]')).toMatchObject({ + type: 'object', + }); + expect( + bridge.getField('["path.with.a.dot"]["another.with.a.dot"]'), + ).toMatchObject({ + type: 'string', + }); + expect( + bridge.getField('["path.with.a.dot"].another.with.a.dot'), + ).toMatchObject({ + type: 'number', + }); + expect(bridge.getField('path["with.a.dot"]')).toMatchObject({ + type: 'number', + }); + expect(bridge.getField('path["[\\"with.a.dot\\"]"]')).toMatchObject({ + type: 'string', + }); + }); + it('returns correct definition ($ref pointing to $ref)', () => { expect(bridge.getField('personalData.middleName')).toEqual({ type: 'string', @@ -830,6 +885,8 @@ describe('JSONSchemaBridge', () => { 'objectWithoutProperties', 'withLabel', 'forcedRequired', + '["path.with.a.dot"]', + 'path', ]); }); diff --git a/packages/uniforms-bridge-json-schema/src/JSONSchemaBridge.ts b/packages/uniforms-bridge-json-schema/src/JSONSchemaBridge.ts index 566055761..fa73137df 100644 --- a/packages/uniforms-bridge-json-schema/src/JSONSchemaBridge.ts +++ b/packages/uniforms-bridge-json-schema/src/JSONSchemaBridge.ts @@ -150,7 +150,7 @@ export default class JSONSchemaBridge extends Bridge { fieldInvariant(name, !!definition); } else if (definition.type === 'object') { fieldInvariant(name, !!definition.properties); - definition = definition.properties[next]; + definition = definition.properties[joinName.unescape(next)]; fieldInvariant(name, !!definition); } else { let nextFound = false; @@ -296,7 +296,7 @@ export default class JSONSchemaBridge extends Bridge { this._compiledSchema[name]; if (type === 'object' && properties) { - return Object.keys(properties); + return Object.keys(properties).map(joinName.escape); } return []; diff --git a/packages/uniforms/__tests__/joinName.ts b/packages/uniforms/__tests__/joinName.ts index 4a0d301ad..d475acc86 100644 --- a/packages/uniforms/__tests__/joinName.ts +++ b/packages/uniforms/__tests__/joinName.ts @@ -1,52 +1,128 @@ import { joinName } from 'uniforms'; +function test(parts: unknown[], array: string[], string: string) { + // Serialization (join). + expect(joinName(...parts)).toBe(string); + + // Deserialization (split). + expect(joinName(null, ...parts)).toEqual(array); + + // Re-serialization (split + join). + expect(joinName(joinName(null, ...parts))).toEqual(string); + + // Re-deserialization (join + split). + expect(joinName(null, joinName(...parts))).toEqual(array); +} + describe('joinName', () => { it('is a function', () => { expect(joinName).toBeInstanceOf(Function); }); - it('have raw mode', () => { - expect(joinName(null)).toEqual([]); - expect(joinName(null, 'a')).toEqual(['a']); - expect(joinName(null, 'a', 'b')).toEqual(['a', 'b']); - expect(joinName(null, 'a', 'b', null)).toEqual(['a', 'b']); - expect(joinName(null, 'a', 'b', null, 0)).toEqual(['a', 'b', '0']); - expect(joinName(null, 'a', 'b', null, 1)).toEqual(['a', 'b', '1']); + it('works with empty name', () => { + test([], [], ''); }); it('works with arrays', () => { - expect(joinName(['a'], 'b')).toBe('a.b'); - expect(joinName('a', ['b'])).toBe('a.b'); + test([['a']], ['a'], 'a'); + test([[['a']]], ['a'], 'a'); + test([[[['a']]]], ['a'], 'a'); + + test([[], 'a'], ['a'], 'a'); + test(['a', []], ['a'], 'a'); + + test([['a'], 'b'], ['a', 'b'], 'a.b'); + test(['a', ['b']], ['a', 'b'], 'a.b'); + + test([['a', 'b'], 'c'], ['a', 'b', 'c'], 'a.b.c'); + test(['a', ['b', 'c']], ['a', 'b', 'c'], 'a.b.c'); + + test(['a', ['b', 'c'], 'd'], ['a', 'b', 'c', 'd'], 'a.b.c.d'); }); it('works with empty strings', () => { - expect(joinName('', 'a', 'b')).toBe('a.b'); - expect(joinName('a', '', 'b')).toBe('a.b'); - expect(joinName('a', 'b', '')).toBe('a.b'); + test(['', 'a', 'b'], ['a', 'b'], 'a.b'); + test(['a', '', 'b'], ['a', 'b'], 'a.b'); + test(['a', 'b', ''], ['a', 'b'], 'a.b'); }); it('works with falsy values', () => { - expect(joinName('a', null, 'b')).toBe('a.b'); - expect(joinName('a', false, 'b')).toBe('a.b'); - expect(joinName('a', undefined, 'b')).toBe('a.b'); + test(['a', null, 'b'], ['a', 'b'], 'a.b'); + test(['a', false, 'b'], ['a', 'b'], 'a.b'); + test(['a', undefined, 'b'], ['a', 'b'], 'a.b'); }); it('works with numbers', () => { - expect(joinName(0, 'a', 'b')).toBe('0.a.b'); - expect(joinName('a', 0, 'b')).toBe('a.0.b'); - expect(joinName('a', 'b', 0)).toBe('a.b.0'); - expect(joinName(1, 'a', 'b')).toBe('1.a.b'); - expect(joinName('a', 1, 'b')).toBe('a.1.b'); - expect(joinName('a', 'b', 1)).toBe('a.b.1'); + test([0, 'a', 'b'], ['0', 'a', 'b'], '0.a.b'); + test(['a', 0, 'b'], ['a', '0', 'b'], 'a.0.b'); + test(['a', 'b', 0], ['a', 'b', '0'], 'a.b.0'); + test([1, 'a', 'b'], ['1', 'a', 'b'], '1.a.b'); + test(['a', 1, 'b'], ['a', '1', 'b'], 'a.1.b'); + test(['a', 'b', 1], ['a', 'b', '1'], 'a.b.1'); }); it('works with partials', () => { - expect(joinName('a', 'b.c.d')).toBe('a.b.c.d'); - expect(joinName('a.b', 'c.d')).toBe('a.b.c.d'); - expect(joinName('a.b.c', 'd')).toBe('a.b.c.d'); + test(['a', 'b.c.d'], ['a', 'b', 'c', 'd'], 'a.b.c.d'); + test(['a.b', 'c.d'], ['a', 'b', 'c', 'd'], 'a.b.c.d'); + test(['a.b.c', 'd'], ['a', 'b', 'c', 'd'], 'a.b.c.d'); + }); + + it('works with subscripts', () => { + test(['a["b"]'], ['a', 'b'], 'a.b'); + test(['a["b"].c'], ['a', 'b', 'c'], 'a.b.c'); + test(['a["b"].c["d"]'], ['a', 'b', 'c', 'd'], 'a.b.c.d'); + test(['a["b"]["c.d"]'], ['a', 'b', '["c.d"]'], 'a.b["c.d"]'); + test(['a["b"]["c.d"].e'], ['a', 'b', '["c.d"]', 'e'], 'a.b["c.d"].e'); + test(['a["b"]["c.d"]["e"]'], ['a', 'b', '["c.d"]', 'e'], 'a.b["c.d"].e'); + test(['a["b"].["c.d"]'], ['a', 'b', '["c.d"]'], 'a.b["c.d"]'); + test(['a["b"].["c.d"].e'], ['a', 'b', '["c.d"]', 'e'], 'a.b["c.d"].e'); + test(['a["b"].["c.d"]["e"]'], ['a', 'b', '["c.d"]', 'e'], 'a.b["c.d"].e'); + + test(['["a"]'], ['a'], 'a'); + test(['["a"].b'], ['a', 'b'], 'a.b'); + test(['["a"]["b.c"]'], ['a', '["b.c"]'], 'a["b.c"]'); + test(['["a"]["b.c"].d'], ['a', '["b.c"]', 'd'], 'a["b.c"].d'); + test(['["a"]["b.c"]["d"]'], ['a', '["b.c"]', 'd'], 'a["b.c"].d'); + test(['["a"].["b.c"]'], ['a', '["b.c"]'], 'a["b.c"]'); + test(['["a"].["b.c"].d'], ['a', '["b.c"]', 'd'], 'a["b.c"].d'); + test(['["a"].["b.c"]["d"]'], ['a', '["b.c"]', 'd'], 'a["b.c"].d'); + + test(['[""]'], ['[""]'], '[""]'); + test(['["."]'], ['["."]'], '["."]'); + test(['[".."]'], ['[".."]'], '[".."]'); + test(['["..."]'], ['["..."]'], '["..."]'); + test(['["[\'\']"]'], ['["[\'\']"]'], '["[\'\']"]'); + test(['["[\\"\\"]"]'], ['["[\\"\\"]"]'], '["[\\"\\"]"]'); + }); + + it('handles incorrect cases _somehow_', () => { + // Boolean `true`. + test([true], ['true'], 'true'); + test([true, 'a'], ['true', 'a'], 'true.a'); + test(['a', true], ['a', 'true'], 'a.true'); + + // Dots before subscripts. + test(['a["b"].c.["d"]'], ['a', 'b', 'c', 'd'], 'a.b.c.d'); + test(['a.["b"].c["d"]'], ['a', 'b', 'c', 'd'], 'a.b.c.d'); + test(['a.["b"].c.["d"]'], ['a', 'b', 'c', 'd'], 'a.b.c.d'); + + // Only dots. + test(['.'], ['["."]'], '["."]'); + test(['..'], ['[".."]'], '[".."]'); + test(['...'], ['["..."]'], '["..."]'); + + // Leading and trailing dots. + test(['a.'], ['["a."]'], '["a."]'); + test(['.a'], ['[""]', 'a'], '[""].a'); + test(['["a"].'], ['a'], 'a'); + test(['.["a"]'], ['a'], 'a'); - expect(joinName(null, 'a', 'b.c.d')).toEqual(['a', 'b', 'c', 'd']); - expect(joinName(null, 'a.b', 'c.d')).toEqual(['a', 'b', 'c', 'd']); - expect(joinName(null, 'a.b.c', 'd')).toEqual(['a', 'b', 'c', 'd']); + // Unescaped brackets. + test(['['], ['["["]'], '["["]'); + test(["['"], ['["[\'"]'], '["[\'"]'); + test(["[''"], ['["[\'\'"]'], '["[\'\'"]'); + test(["['']"], ['["[\'\']"]'], '["[\'\']"]'); + test(['["'], ['["[\\""]'], '["[\\""]'); + test(['[""'], ['["[\\"\\""]'], '["[\\"\\""]'); }); }); diff --git a/packages/uniforms/src/joinName.ts b/packages/uniforms/src/joinName.ts index 8bc97571b..e6b4d6651 100644 --- a/packages/uniforms/src/joinName.ts +++ b/packages/uniforms/src/joinName.ts @@ -1,23 +1,128 @@ -export function joinName(flag: null, ...parts: unknown[]): string[]; -export function joinName(...parts: unknown[]): string; -export function joinName(...parts: unknown[]) { +const escapeMatch = /[.[\]]/; +const escapeRegex = /"/g; + +/** @internal */ +function escape(string: string) { + return string === '' || escapeMatch.test(string) + ? `["${string.replace(escapeRegex, '\\"')}"]` + : string; +} + +/** @internal */ +function escapeToJoin(string: string, index: number) { + const escaped = escape(string); + return escaped === string ? (index ? `.${string}` : string) : escaped; +} + +const unescapeMatch = /^\["(.*)"]$/; +const unescapeRegex = /\\"/g; + +/** @internal */ +function unescape(string: string) { + const match = unescapeMatch.exec(string); + return match ? match[1].replace(unescapeRegex, '"') : string; +} + +// This regular expression splits the string into three parts: +// `prefix` is a dotted name, e.g., `object.nested.2.field` at the +// front (hence prefix). It covers most standard usecases. +// `subscript` is a `["..."]` subscript after the prefix. The content +// within should be escaped by the user, e.g., `["\\""]`. +// `rest` is anything following the subscript. The leading dot (`.`) +// is stripped (`.a` -> `a`) if there is one. It is empty if +// `subscript` is empty. +// +// All three parts can be empty! +const nameRegex = + /^([^.[\]]*(?:\.[^.[\]]+)*)(?:\.?(\["(?:[^"]|\\")*?(? `a`) if there is one. It is empty if + // `subscript` is empty. + const match = nameRegex.exec(part); + if (match) { + const [, prefix, subscript, rest] = match; + + if (prefix) { + // We could always `.split` the `prefix`, but it results in a severe + // performance hit. + if (prefix.includes('.')) { + name.push(...prefix.split('.')); + } else { + name.push(prefix); + } + } + + if (subscript) { + // We could adjust the `nameRegex` to skip brackets and `unescape` + // to skip this check, but then every other call (e.g., a one in the + // bridge) would have to know that. The performance is not affected + // much by it anyway. + name.push(unescape(subscript)); + + // The `rest` is inlined in place as it is a single string. + if (rest) { + parts[index--] = rest; + } + } } else { + // If a string is not matching the pattern, we leave it as it is. We + // may want to raise a warning here as it should not happen. Most + // likely it is something that should have been escaped (e.g., `[`). name.push(part); } } else if (Array.isArray(part)) { - parts.splice(index--, 1, ...part); + // Arrays are flattened in place but only if needed, i.e., they are not + // empty. We calculate the length of the overlapping parts to reuse the + // `parts` array as much as possible: + // [[], ...] -> [[], ...] // No change. + // [['a'], ...] -> ['a', ...] // Inline in place. + // [['a', 'b'], ...] -> ['a', 'b', ...] // Inline with extension. + // ['a', ['b'], ...] -> ['a', 'b', ...] // Inline in place. + // ['a', ['b', 'c'], ...] -> ['b', 'c', ...] // Inline with overlap. + if (part.length) { + const length = Math.min(index + 1, part.length); + index -= length; + parts.splice(index + 1, length, ...part); + } } else { + // Other values -- most likely numbers and `true` -- are stringified. name.push('' + part); } } } - return parts[0] === null ? name : name.join('.'); + // We cannot escape the parts earlier as `escapeToJoin` depends on the index. + return returnAsParts ? name.map(escape) : name.map(escapeToJoin).join(''); } + +export const joinName = Object.assign(joinNameImpl, { escape, unescape });