Skip to content

Commit

Permalink
Implemented handling of complex field names (closes #963).
Browse files Browse the repository at this point in the history
  • Loading branch information
radekmie authored Feb 12, 2022
1 parent 1b4591a commit 331f717
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 40 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
31 changes: 28 additions & 3 deletions docs/api-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions packages/uniforms-bridge-json-schema/__tests__/JSONSchemaBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
};
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -830,6 +885,8 @@ describe('JSONSchemaBridge', () => {
'objectWithoutProperties',
'withLabel',
'forcedRequired',
'["path.with.a.dot"]',
'path',
]);
});

Expand Down
4 changes: 2 additions & 2 deletions packages/uniforms-bridge-json-schema/src/JSONSchemaBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 [];
Expand Down
130 changes: 103 additions & 27 deletions packages/uniforms/__tests__/joinName.ts
Original file line number Diff line number Diff line change
@@ -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(['[""'], ['["[\\"\\""]'], '["[\\"\\""]');
});
});
Loading

0 comments on commit 331f717

Please sign in to comment.