Skip to content

Commit aeada8c

Browse files
authored
Support custom unwrapped unions (#469)
Users can now specify a custom projection function to control value unwrapping. This simplifies a variety of use-cases which would previously require a type hook and logical type.
1 parent 384b656 commit aeada8c

File tree

3 files changed

+150
-54
lines changed

3 files changed

+150
-54
lines changed

lib/types.js

+83-53
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ class Type {
111111
wrapUnions = 'auto';
112112
} else if (typeof wrapUnions == 'string') {
113113
wrapUnions = wrapUnions.toLowerCase();
114+
} else if (typeof wrapUnions === 'function') {
115+
wrapUnions = 'auto';
114116
}
115117
switch (wrapUnions) {
116118
case 'always':
@@ -196,11 +198,20 @@ class Type {
196198
let types = schema.map((obj) => {
197199
return Type.forSchema(obj, opts);
198200
});
201+
let projectionFn;
199202
if (!UnionType) {
200-
UnionType = isAmbiguous(types) ? WrappedUnionType : UnwrappedUnionType;
203+
if (typeof opts.wrapUnions === 'function') {
204+
// we have a projection function
205+
projectionFn = opts.wrapUnions(types);
206+
UnionType = typeof projectionFn !== 'undefined'
207+
? UnwrappedUnionType
208+
: WrappedUnionType;
209+
} else {
210+
UnionType = isAmbiguous(types) ? WrappedUnionType : UnwrappedUnionType;
211+
}
201212
}
202213
LOGICAL_TYPE = logicalType;
203-
type = new UnionType(types, opts);
214+
type = new UnionType(types, opts, projectionFn);
204215
} else { // New type definition.
205216
type = (function (typeName) {
206217
let Type = TYPES[typeName];
@@ -341,10 +352,10 @@ class Type {
341352
return branchTypes[name];
342353
}), opts);
343354
} catch (err) {
344-
opts.wrapUnions = wrapUnions;
345355
throw err;
356+
} finally {
357+
opts.wrapUnions = wrapUnions;
346358
}
347-
opts.wrapUnions = wrapUnions;
348359
return unionType;
349360
}
350361

@@ -1226,6 +1237,60 @@ UnionType.prototype._branchConstructor = function () {
12261237
throw new Error('unions cannot be directly wrapped');
12271238
};
12281239

1240+
1241+
function generateProjectionIndexer(projectionFn) {
1242+
return (val) => {
1243+
const index = projectionFn(val);
1244+
if (typeof index !== 'number') {
1245+
throw new Error(`Projected index '${index}' is not valid`);
1246+
}
1247+
return index;
1248+
};
1249+
}
1250+
1251+
function generateDefaultIndexer(types) {
1252+
const dynamicBranches = [];
1253+
const bucketIndices = {};
1254+
1255+
const getBranchIndex = (any, index) => {
1256+
let logicalBranches = dynamicBranches;
1257+
for (let i = 0, l = logicalBranches.length; i < l; i++) {
1258+
let branch = logicalBranches[i];
1259+
if (branch.type._check(any)) {
1260+
if (index === undefined) {
1261+
index = branch.index;
1262+
} else {
1263+
// More than one branch matches the value so we aren't guaranteed to
1264+
// infer the correct type. We throw rather than corrupt data. This can
1265+
// be fixed by "tightening" the logical types.
1266+
throw new Error('ambiguous conversion');
1267+
}
1268+
}
1269+
}
1270+
return index;
1271+
}
1272+
1273+
types.forEach(function (type, index) {
1274+
if (Type.isType(type, 'abstract', 'logical')) {
1275+
dynamicBranches.push({index, type});
1276+
} else {
1277+
let bucket = getTypeBucket(type);
1278+
if (bucketIndices[bucket] !== undefined) {
1279+
throw new Error(`ambiguous unwrapped union: ${j(this)}`);
1280+
}
1281+
bucketIndices[bucket] = index;
1282+
}
1283+
});
1284+
return (val) => {
1285+
let index = bucketIndices[getValueBucket(val)];
1286+
if (dynamicBranches.length) {
1287+
// Slower path, we must run the value through all branches.
1288+
index = getBranchIndex(val, index);
1289+
}
1290+
return index;
1291+
};
1292+
}
1293+
12291294
/**
12301295
* "Natural" union type.
12311296
*
@@ -1246,54 +1311,17 @@ UnionType.prototype._branchConstructor = function () {
12461311
* + `map`, `record`
12471312
*/
12481313
class UnwrappedUnionType extends UnionType {
1249-
constructor (schema, opts) {
1314+
constructor (schema, opts, /* @private parameter */ _projectionFn) {
12501315
super(schema, opts);
12511316

1252-
this._dynamicBranches = null;
1253-
this._bucketIndices = {};
1254-
this.types.forEach(function (type, index) {
1255-
if (Type.isType(type, 'abstract', 'logical')) {
1256-
if (!this._dynamicBranches) {
1257-
this._dynamicBranches = [];
1258-
}
1259-
this._dynamicBranches.push({index, type});
1260-
} else {
1261-
let bucket = getTypeBucket(type);
1262-
if (this._bucketIndices[bucket] !== undefined) {
1263-
throw new Error(`ambiguous unwrapped union: ${j(this)}`);
1264-
}
1265-
this._bucketIndices[bucket] = index;
1266-
}
1267-
}, this);
1268-
1269-
Object.freeze(this);
1270-
}
1271-
1272-
_getIndex (val) {
1273-
let index = this._bucketIndices[getValueBucket(val)];
1274-
if (this._dynamicBranches) {
1275-
// Slower path, we must run the value through all branches.
1276-
index = this._getBranchIndex(val, index);
1317+
if (!_projectionFn && opts && typeof opts.wrapUnions === 'function') {
1318+
_projectionFn = opts.wrapUnions(this.types);
12771319
}
1278-
return index;
1279-
}
1320+
this._getIndex = _projectionFn
1321+
? generateProjectionIndexer(_projectionFn)
1322+
: generateDefaultIndexer(this.types);
12801323

1281-
_getBranchIndex (any, index) {
1282-
let logicalBranches = this._dynamicBranches;
1283-
for (let i = 0, l = logicalBranches.length; i < l; i++) {
1284-
let branch = logicalBranches[i];
1285-
if (branch.type._check(any)) {
1286-
if (index === undefined) {
1287-
index = branch.index;
1288-
} else {
1289-
// More than one branch matches the value so we aren't guaranteed to
1290-
// infer the correct type. We throw rather than corrupt data. This can
1291-
// be fixed by "tightening" the logical types.
1292-
throw new Error('ambiguous conversion');
1293-
}
1294-
}
1295-
}
1296-
return index;
1324+
Object.freeze(this);
12971325
}
12981326

12991327
_check (val, flags, hook, path) {
@@ -1355,16 +1383,18 @@ class UnwrappedUnionType extends UnionType {
13551383
// Using the `coerceBuffers` option can cause corruption and erroneous
13561384
// failures with unwrapped unions (in rare cases when the union also
13571385
// contains a record which matches a buffer's JSON representation).
1358-
if (isJsonBuffer(val) && this._bucketIndices.buffer !== undefined) {
1359-
index = this._bucketIndices.buffer;
1360-
} else {
1361-
index = this._getIndex(val);
1386+
if (isJsonBuffer(val)) {
1387+
let bufIndex = this.types.findIndex(t => getTypeBucket(t) === 'buffer');
1388+
if (bufIndex !== -1) {
1389+
index = bufIndex;
1390+
}
13621391
}
1392+
index ??= this._getIndex(val);
13631393
break;
13641394
case 2:
13651395
// Decoding from JSON, we must unwrap the value.
13661396
if (val === null) {
1367-
index = this._bucketIndices['null'];
1397+
index = this._getIndex(null);
13681398
} else if (typeof val === 'object') {
13691399
let keys = Object.keys(val);
13701400
if (keys.length === 1) {

test/test_types.js

+51
Original file line numberDiff line numberDiff line change
@@ -3505,6 +3505,57 @@ suite('types', () => {
35053505
assert(Type.isType(t.field('unwrapped').type, 'union:unwrapped'));
35063506
});
35073507

3508+
test('union projection', () => {
3509+
const Dog = {
3510+
type: 'record',
3511+
name: 'Dog',
3512+
fields: [
3513+
{ type: 'string', name: 'bark' }
3514+
],
3515+
};
3516+
const Cat = {
3517+
type: 'record',
3518+
name: 'Cat',
3519+
fields: [
3520+
{ type: 'string', name: 'meow' }
3521+
],
3522+
};
3523+
const animalTypes = [Dog, Cat];
3524+
3525+
let callsToWrapUnions = 0;
3526+
const wrapUnions = (types) => {
3527+
callsToWrapUnions++;
3528+
assert.deepEqual(types.map(t => t.name), ['Dog', 'Cat']);
3529+
return (animal) => {
3530+
const animalType = ((animal) => {
3531+
if ('bark' in animal) {
3532+
return 'Dog';
3533+
} else if ('meow' in animal) {
3534+
return 'Cat';
3535+
}
3536+
throw new Error('Unknown animal');
3537+
})(animal);
3538+
return types.indexOf(types.find(type => type.name === animalType));
3539+
}
3540+
};
3541+
3542+
// Ambiguous, but we have a projection function
3543+
const Animal = Type.forSchema(animalTypes, { wrapUnions });
3544+
Animal.toBuffer({ meow: '🐈' });
3545+
assert.equal(callsToWrapUnions, 1);
3546+
assert.throws(() => Animal.toBuffer({ snap: '🐊' }), /Unknown animal/)
3547+
});
3548+
3549+
test('union projection with fallback', () => {
3550+
let t = Type.forSchema({
3551+
type: 'record',
3552+
fields: [
3553+
{name: 'wrapped', type: ['int', 'double' ]}, // Ambiguous.
3554+
]
3555+
}, {wrapUnions: () => undefined });
3556+
assert(Type.isType(t.field('wrapped').type, 'union:wrapped'));
3557+
});
3558+
35083559
test('invalid wrap unions option', () => {
35093560
assert.throws(() => {
35103561
Type.forSchema('string', {wrapUnions: 'FOO'});

types/index.d.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,21 @@ interface EncoderOptions {
9595
syncMarker: Buffer;
9696
}
9797

98+
/**
99+
* A projection function that is used when unwrapping unions.
100+
* This function is called at schema parsing time on each union with its branches'
101+
* types.
102+
* If it returns a non-null (function) value, that function will be called each
103+
* time a value's branch needs to be inferred and should return the branch's
104+
* index.
105+
* The index muss be a number between 0 and length-1 of the passed types.
106+
* In this case (a branch index) the union will use an unwrapped representation.
107+
* Otherwise (undefined), the union will be wrapped.
108+
*/
109+
type BranchProjection = (types: ReadonlyArray<Type>) =>
110+
| ((val: unknown) => number)
111+
| undefined;
112+
98113
interface ForSchemaOptions {
99114
assertLogicalTypes: boolean;
100115
logicalTypes: { [type: string]: new (schema: Schema, opts?: any) => types.LogicalType; };
@@ -103,7 +118,7 @@ interface ForSchemaOptions {
103118
omitRecordMethods: boolean;
104119
registry: { [name: string]: Type };
105120
typeHook: (schema: Schema | string, opts: ForSchemaOptions) => Type | undefined;
106-
wrapUnions: boolean | 'auto' | 'always' | 'never';
121+
wrapUnions: BranchProjection | boolean | 'auto' | 'always' | 'never';
107122
}
108123

109124
interface TypeOptions extends ForSchemaOptions {

0 commit comments

Comments
 (0)