Skip to content

Commit a97b77f

Browse files
committed
WIP attribute type system
1 parent f8432b7 commit a97b77f

File tree

4 files changed

+198
-2
lines changed

4 files changed

+198
-2
lines changed

lib/importer/attribute-types.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// "input values" are what's found in the input data, and will generally be
2+
// simple string/boolean/number/date/etc things. "attribute values" are the
3+
// actual attributes in our output data model and can be some arbitrary type.
4+
5+
// Am attribute type is a pair of functions.
6+
7+
// One validates an input value to see if it can be made into a valid attribute
8+
// value. It should return a list of warnings and a list of errors.
9+
10+
// One maps an input value to a value of the attribute type.
11+
12+
// ABS TODOs: Make validation and translation happen together - drop
13+
// AttributeType and just implement it as a function from input value ->
14+
// (outputValue, [warning], [error]).
15+
16+
// This will enable optionalType to handle errors from the base type by turning
17+
// them into warnings *and* returning undefined as the translater value.
18+
19+
// Then add a requiredType that wraps any type and makes it "required", treating
20+
// "" or undefined inputs as errors.
21+
22+
// Base types without being wrapped by requiredType or optionalType may then
23+
// handled undefined or "" as they see fit.
24+
25+
class AttributeType {
26+
constructor(validator, translator) {
27+
this.v = validator;
28+
this.t = translator;
29+
}
30+
31+
get validator() { return this.v; }
32+
get translator() { return this.t; }
33+
34+
validate(inputValue) { return this.v(inputValue); }
35+
translate(inputValue) { return this.t(inputValue); }
36+
}
37+
38+
exports.AttributeType = AttributeType;
39+
40+
// Create an optional version of an existing type, that allows empty strings or
41+
// undefined values and maps then to "undefined", and converts any validation errors to warnings
42+
exports.optionalType = (baseType) => {
43+
return new AttributeType(
44+
(inputValue) => {
45+
if(inputValue !== undefined && inputValue != "") {
46+
const [warnings, errors] = baseType.validate(inputValue);
47+
// Convert any errors into warnings, as this is an optional field
48+
return [warnings.concat(errors), []];
49+
}
50+
else {
51+
return [[],[]];
52+
}
53+
},
54+
(inputValue) => {
55+
if(inputValue !== undefined && inputValue != "") return baseType.translate(inputValue);
56+
else return undefined;
57+
}
58+
);
59+
}
60+
61+
// Create a required version of an existing type, that
62+
exports.optionalType = (baseType) => {
63+
return new AttributeType(
64+
(inputValue) => {
65+
if(inputValue !== undefined && inputValue != "") {
66+
const [warnings, errors] = baseType.validate(inputValue);
67+
// Convert any errors into warnings, as this is an optional field
68+
return [warnings.concat(errors), []];
69+
}
70+
else {
71+
return [[],[]];
72+
}
73+
},
74+
(inputValue) => {
75+
if(inputValue !== undefined && inputValue != "") return baseType.translate(inputValue);
76+
else return undefined;
77+
}
78+
);
79+
}
80+
81+
// The default string type. Does not allow empty strings!
82+
exports.basicStringType = new AttributeType(
83+
(inputValue) => {
84+
if(inputValue === undefined || inputValue == "") {
85+
return [[],["A value must be provided"]];
86+
}
87+
// Anything else can be converted into a string
88+
return [[],[]];
89+
},
90+
(inputValue) => {
91+
return String(inputValue);
92+
}
93+
);
94+
95+
exports.basicNumberType = new AttributeType(
96+
(inputValue) => {
97+
if(inputValue === undefined || inputValue == "") {
98+
return [[],["A value must be provided"]];
99+
}
100+
101+
if(parseFloat(inputValue) === NaN) {
102+
return [[],[inputValue + " is not a valid number"]];
103+
}
104+
105+
return [[],[]];
106+
},
107+
(inputValue) => {
108+
return parseFloat(inputValue);
109+
}
110+
);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
const attributeTypes = require('./attribute-types');
2+
3+
function testType(t, invalidInputs, fixtures) {
4+
for([input,warnings,errors] of invalidInputs) {
5+
expect(t.validate(input)).toMatchObject([warnings, errors]);
6+
}
7+
8+
for ([input, output] of fixtures) {
9+
expect(t.validate(input)).toMatchObject([[],[]]);
10+
expect(t.translate(input)).toEqual(output);
11+
}
12+
};
13+
14+
test('basic string', () => {
15+
const invalidInputs = [
16+
[undefined, [], ["A value must be provided"]],
17+
["", [], ["A value must be provided"]]
18+
];
19+
20+
const fixtures = [
21+
["foo", "foo"],
22+
[123, "123"],
23+
];
24+
25+
testType(attributeTypes.basicStringType, invalidInputs, fixtures);
26+
});
27+
28+
test('optional basic string', () => {
29+
const invalidInputs = [];
30+
31+
const fixtures = [
32+
["foo", "foo"],
33+
[123, "123"],
34+
["", undefined],
35+
[undefined, undefined]
36+
];
37+
38+
testType(attributeTypes.optionalType(attributeTypes.basicStringType), invalidInputs, fixtures);
39+
});
40+
41+
test('basic number', () => {
42+
const invalidInputs = [
43+
[undefined, [], ["A value must be provided"]],
44+
["", [], ["A value must be provided"]],
45+
["foo", [], ["foo is not a valid number"]]
46+
];
47+
48+
const fixtures = [
49+
[123, 123],
50+
["123", "123"],
51+
];
52+
53+
testType(attributeTypes.basicNumberType, invalidInputs, fixtures);
54+
});

lib/importer/backend.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const xlsx = require("xlsx");
22
const crypto = require("crypto");
33
const assert = require('node:assert').strict;
4+
const attributeTypes = require('./attribute-types');
45

56
// An implementation of the interface described in https://struct.register-dynamics.co.uk/trac/wiki/DataImporter/API
67

@@ -454,6 +455,13 @@ function validateMapping(range, mapping) {
454455
assert(attrSource >= 0);
455456
assert(attrSource < columnsInRange);
456457
}
458+
459+
// Output types are optional, we fall back to basicStringType if not specified
460+
if(mapping.attributeTypes) {
461+
for(const [attribute, attrType] of Object.entries(mapping.attributeTypes)) {
462+
assert(attrType instanceof AttributeType);
463+
}
464+
}
457465
}
458466

459467
function mapCellValue(cell) {
@@ -477,6 +485,7 @@ exports.SessionPerformMappingJob = (sid, range, mapping) => {
477485
const data = sheet["!data"];
478486
const merges = sheet["!merges"];
479487
const attrMap = Object.entries(mapping.attributeMappings);
488+
const attrTypes = mapping.attributeTypes || {};
480489

481490
for(let rowIdx=range.start.row; rowIdx <= range.end.row; rowIdx++) {
482491
let row = getMergedRow(data, merges, rowIdx);
@@ -487,6 +496,7 @@ exports.SessionPerformMappingJob = (sid, range, mapping) => {
487496
if (row) {
488497
attrMap.forEach((element) => {
489498
const [attr, m] = element;
499+
490500
// For now, attribute mappings are just integer column offsets
491501
const inputColumn = range.start.column + m;
492502
if (inputColumn >= row.length) {
@@ -504,7 +514,29 @@ exports.SessionPerformMappingJob = (sid, range, mapping) => {
504514
}
505515

506516
if (foundSomeValues) {
507-
records.push(record);
517+
// Only if we found something do we validate and map the types
518+
mappedRecord = {};
519+
520+
Object.entries(record).forEach((element) => {
521+
const [attr, inputVal] = element;
522+
const attrType = attrTypes[attr] || attributeTypes.basicStringType;
523+
524+
const [warnings, errors] = attrType.validate(inputVal);
525+
warnings.forEach((text) => {
526+
rowWarnings.push([attr,text]);
527+
});
528+
errors.forEach((text) => {
529+
rowErrors.push([attr,text]);
530+
});
531+
532+
if(errors.length == 0) {
533+
mappedRecord[attr] = attrType.translate(inputVal);
534+
}
535+
});
536+
537+
if (rowErrors.length == 0) {
538+
records.push(record);
539+
}
508540
}
509541

510542
if(rowWarnings.length > 0) {

lib/importer/backend.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const backend = require('./backend');
2-
2+
const attributeTypes = require('./attribute-types');
33

44
const testFiles = new Map([
55
["test", [

0 commit comments

Comments
 (0)