You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

368 lines
14 KiB
JavaScript

/* eslint-disable no-loop-func */
"use strict";
const assert = require("assert");
const matchValue = require("match-value");
const splitFilterN = require("split-filter-n");
const unreachable = require("@joepie91/unreachable")("zapdb");
const immutableDeepMerge = require("../packages/immutable-deep-merge");
const rules = require("./rules");
const computeTransform = require("./compute-transform");
const compose = require("../util/compose");
// FIXME: table/row terminology etc.
// FIXME: replace asserts with proper checks and error messages
const Delete = Symbol("DeleteProperty");
// TODO: Find a way to roll this into merge-by-template somehow? The main difference is specifying dynamic transforms at rule definition time (and needing to use meta-objects in the mergeable) vs. specifying dynamic transforms at merge time directly
// TODO: Add API for "set this object literally, no merge"
// FIXME: Find a way to support arrays? Particularly objects *within* arrays, which would also need to be merged recursively...
function checkTransforms(operations) {
let byType = splitFilterN(operations, null, (operation) => operation.type);
if (byType.transformTo != null && byType.transformTo.length > 1) {
// FIXME: Error code
throw new Error(`Only one transformTo can be specified per modified field`);
}
if (byType.rollbackTo != null && byType.rollbackTo.length > 1) {
// FIXME: Error code
throw new Error(`Only one rollbackTo can be specified per modified field`);
}
if (byType.rollbackTo != null && byType.forbidRollback != null) {
// FIXME: Error code
throw new Error(`Cannot specify both a rollbackTo and an unsafeForbidRollback`);
}
let hasRollbackTransform = (byType.rollbackTo != null);
let hasRollbackProhibition = (byType.forbidRollback != null);
return {
hasTransform: (byType.transformTo != null),
hasRollback: (hasRollbackTransform || hasRollbackProhibition),
hasRollbackTransform: hasRollbackTransform,
hasRollbackProhibition: hasRollbackProhibition
};
}
// FIXME: Throw an error if a non-required transformTo is specified without a corresponding rollbackTo
function changeType(schema, newType) {
if (schema.type != newType) {
let newSchema = { type: newType };
for (let attribute of Object.keys(schema)) {
if (attribute === "type" || !rules.attributes[attribute].validForTypes.has(newType)) {
continue;
} else {
newSchema[attribute] = schema[attribute];
}
}
return newSchema;
} else {
throw new Error(`Tried to set field type to '${operation.fieldType}', but that is already the type`);
}
}
function applyFieldOperations(currentField = {}, operations) {
// Things that are specific to this migration
let state = {
schema: { ... currentField }, // Clone for local mutation
forwardTransform: null,
backwardTransform: null,
transformsRequired: false,
rollbackForbidden: false,
changedAttributes: []
};
for (let operation of operations) {
matchValue(operation.type, {
setFieldType: () => {
// NOTE: This is separated out into a function because a bunch of complexity is needed for determining which attributes can be kept
state.schema = changeType(state.schema, operation.fieldType);
state.transformsRequired = true;
},
setAttribute: () => {
if (state.schema[operation.attribute] !== operation.value) {
state.changedAttributes.push(operation.attribute);
state.schema[operation.attribute] = operation.value;
state.transformsRequired = true;
} else {
// FIXME: Error quality
throw new Error(`Tried to change '${operation.attribute}' attribute to '${operation.value}', but it's already set to that`);
}
},
transformTo: () => {
if (state.forwardTransform == null) {
state.forwardTransform = operation.transformer;
} else {
// FIXME: Error quality
throw new Error(`You can only specify one transformTo per field per migration`);
}
},
rollbackTo: () => {
if (state.backwardTransform == null) {
state.backwardTransform = operation.transformer;
} else {
// FIXME: Error quality
throw new Error(`You can only specify one rollbackTo per field per migration`);
}
},
forbidRollback: () => {
state.rollbackForbidden = true;
},
// TODO: rest of operations
});
}
function createTransformComputer() {
let automaticTransformers = [];
let requiredTransformers = [];
return {
changeType: function (oldType, newType) {
let automatic = rules.types[oldType].losslessConversionTo[newType];
if (automatic != null) {
automaticTransformers.push(automatic);
} else {
requiredTransformers.push({ type: "type", oldType, newType });
}
},
changeAttribute: function (attribute, oldValue, newValue) {
let canBeAutomatic = rules.attributes[attribute].isLossless(oldValue, newValue);
if (canBeAutomatic) {
automaticTransformers.push(rules.attributes[attribute].losslessTransformer);
} else {
requiredTransformers.push({ type: "attribute", attribute, oldValue, newValue });
}
},
getResults: function (manualTransformer, operationName) {
// NOTE: There are deliberately duplicate conditional clauses in here to improve readability!
if (requiredTransformers.length === 0 && automaticTransformers.length === 0) {
if (manualTransformer == null) {
// Identity function; no changes were made that affect the value itself
return (value) => value;
} else {
// FIXME: Better error message
throw new Error(`A ${operationName} operation was specified, but no other schema changes require one. Maybe you meant to use updateRecords instead?`);
}
} else if (requiredTransformers.length === 0 && automaticTransformers.length > 0) {
return compose(automaticTransformers);
} else if (requiredTransformers.length > 0) {
// FIXME: Better error message
throw new Error(`One or more schema changes can't be automatically applied, because a lossless automatic conversion of existing values is not possible; you need to specify a ${operationName} operation manually`);
} else {
throw unreachable("Impossible condition");
}
}
};
}
// NOTE: We disallow transformTo/rollbackTo when they are not required; if the user wishes to bulk-transform values, they should specify a changeRecords operation instead. Otherwise, we cannot implement "maybe you forgot a rollbackTo" errors, because that's only actually an error when a transform isn't *required*, and so if a user 'overreaches' in their type transform to also do a value transform we can't detect missing corresponding rollbackTo logic.
if (transformsRequired) {
let forwardTransformers = { automatic: [], required: [] };
let backwardTransformers = { automatic: [], required: [] };
function addTransformer(collection, automaticTransformer, marker) {
if (automaticTransformer != null) {
collection.automatic.push(automaticTransformer);
} else {
collection.required.push(marker);
}
}
let oldType = currentField.type;
let newType = state.schema.type;
let transformers = computeTransform.type(oldType, newType);
addTransformer(forwardTransformers, transformers.forward, { type: "type" });
addTransformer(backwardTransformers, transformers.backward, { type: "type" });
// FIXME: Currently this implementation assumes that *all* possible attributes are required, and it doesn't deal with cases where the attribute is currently unset. That needs to be changed, especially because new attributes can be changed in later versions of the schema builder, which older migrations won't be using.
// TODO/QUESTION: Maybe all attributes should just be given a default instead of being required? Otherwise over time there'll be a mix of required and optional attributes, the requiredness being determined solely by when the attribute was added to the query builder...
for (let attribute of state.changedAttributes) {
let oldValue = currentField[attribute];
let newValue = state.schema[attribute];
let transformers = computeTransform.attribute(attribute, oldValue, newValue);
addTransformer(forwardTransformers, transformers.forward, { type: "attribute", attribute: attribute });
addTransformer(backwardTransformers, transformers.backward, { type: "attribute", attribute: attribute });
}
if (forwardTransformers.required.length > 0 && state.forwardTransform == null) {
// FIXME: Error quality, list the specific reasons
throw new Error(`One or more schema changes require you to specify a transformTo operation`);
} else {
state.forwardTransform = compose(forwardTransformers.automatic);
}
if (backwardTransformers.required.length > 0 && state.backwardTransform == null) {
// FIXME: Error quality, list the specific reasons
throw new Error(`One or more schema changes require you to specify a rollbackTo operation`);
} else {
state.backwardTransform = compose(backwardTransformers.automatic);
}
} else {
if (state.forwardTransform != null || state.backwardTransform != null) {
// FIXME: Error quality and in-depth explanation
throw new Error(`You cannot specify a transformTo or rollbackTo operation unless a field type change requires it. Maybe you meant to use changeRecords instead?`);
// FIXME: modifyRecords instead of changeRecords? For consistency with other APIs
}
}
return state;
}
function tableOperationReducer(table, operation) {
return matchValue(operation.type, {
createField: () => immutableDeepMerge(table, {
fields: {
[operation.name]: (field) => {
assert(field === undefined);
let { type, name, ... props } = operation;
return props;
}
}
}),
setFieldAttributes: () => immutableDeepMerge(table, {
fields: {
[operation.name]: (field) => {
assert(field !== undefined);
let { type, name, ... props } = operation;
// TODO: Improve readability here
return {
... field,
... props,
attributes: {
... field.attributes,
... props.attributes
}
};
}
}
}),
addIndex: () => immutableDeepMerge(table, {
indexes: {
[operation.name]: operation.definition
}
})
});
}
function schemaOperationReducer(schema, operation) {
return matchValue(operation.type, {
createCollection: () => immutableDeepMerge(schema, {
tables: {
[operation.name]: (table) => {
assert(table === undefined);
return operation.operations.reduce(tableOperationReducer, {});
}
}
}),
modifyCollection: () => immutableDeepMerge(schema, {
tables: {
[operation.name]: (table) => {
assert(table !== undefined);
return operation.operations.reduce(tableOperationReducer, table);
}
}
}),
deleteCollection: () => {
throw new Error(`Not implemented yet`);
}
});
}
module.exports = function reduceMigrations(migrationList, initial = {}) {
return migrationList.reduce((lastSchema, migration) => {
return migration.operations.reduce(schemaOperationReducer, lastSchema);
}, initial);
};
// let dummyMigrations = [
// { id: 1, operations: [
// { type: "createCollection", name: "users", operations: [
// { type: "createField", name: "username", fieldType: "string", required: true },
// { type: "createField", name: "passwordHash", fieldType: "string", required: true },
// { type: "createField", name: "emailAddress", fieldType: "string", required: false },
// { type: "createField", name: "isActive", fieldType: "boolean", required: true },
// { type: "createField", name: "registrationDate", fieldType: "date", required: true, withTimezone: false },
// { type: "createField", name: "invitesLeft", fieldType: "integer", required: true },
// ]}
// ]},
// { id: 2, operations: [
// { type: "modifyCollection", name: "users", operations: [
// { type: "setFieldAttributes", name: "emailAddress", required: false },
// { type: "setFieldAttributes", name: "isActive", required: true },
// { type: "setFieldAttributes", name: "registrationDate", withTimezone: true },
// { type: "setFieldAttributes", name: "invitesLeft", signed: false },
// ]}
// ]},
// ];
let dummyMigrations = [
{ id: 1, operations: [
{ type: "createCollection", name: "users", operations: [
{ type: "createField", name: "username", operations: [
{ type: "changeType", fieldType: "string" },
{ type: "setAttribute", attribute: "required", value: true }
]},
{ type: "createField", name: "passwordHash", operations: [
{ type: "changeType", fieldType: "string" },
{ type: "setAttribute", attribute: "required", value: true }
]},
{ type: "createField", name: "emailAddress", operations: [
{ type: "changeType", fieldType: "string" },
{ type: "setAttribute", attribute: "required", value: false }
]},
{ type: "createField", name: "isActive", operations: [
{ type: "changeType", fieldType: "boolean" },
{ type: "setAttribute", attribute: "required", value: true }
]},
{ type: "createField", name: "registrationDate", operations: [
{ type: "changeType", fieldType: "date" },
{ type: "setAttribute", attribute: "required", value: true },
{ type: "setAttribute", attribute: "withTimezone", value: false },
]},
{ type: "createField", name: "invitesLeft", operations: [
{ type: "changeType", fieldType: "integer" },
{ type: "setAttribute", attribute: "required", value: true },
]},
]}
]},
{ id: 2, operations: [
{ type: "modifyCollection", name: "users", operations: [
{ type: "modifyField", name: "emailAddress", operations: [
{ type: "setAttribute", attribute: "required", value: false },
]},
// FIXME: Disallow no-ops for attribute changes?
{ type: "modifyField", name: "isActive", operations: [
{ type: "setAttribute", attribute: "required", value: true },
]},
{ type: "modifyField", name: "registrationDate", operations: [
{ type: "setAttribute", attribute: "withTimezone", value: true },
]},
{ type: "modifyField", name: "invitesLeft", operations: [
{ type: "setAttribute", attribute: "signed", value: false },
]},
{ type: "createField", name: "sendNewsletter", operations: [
{ type: "changeType", fieldType: "boolean" },
{ type: "setAttribute", attribute: "required", value: true }, // FIXME: Enforce a default in this case! Otherwise existing columns would be invalid
{ type: "setDefault", value: () => false }, // FIXME: Always specified as a value-producing function, or also allow literals?
]},
]}
]},
];
// console.dir(module.exports(dummyMigrations), { depth: null });