Improved migration code substantially, wrote migrations for more properties

This commit is contained in:
Stefan Zermatten
2021-09-06 17:40:57 +02:00
parent 235560eb44
commit e79b8fda3b
20 changed files with 525 additions and 303 deletions

View File

@@ -52,3 +52,4 @@ seba:minifiers-autoprefixer
akryum:vue-component
akryum:vue-sass
percolate:migrations
meteortesting:mocha

View File

@@ -62,6 +62,9 @@ mdg:validated-method@1.2.0
meteor@1.9.3
meteor-base@1.4.0
meteorhacks:subs-manager@1.6.4
meteortesting:browser-tests@1.3.4
meteortesting:mocha@2.0.2
meteortesting:mocha-core@8.0.1
mikowals:batch-insert@1.2.0
minifier-css@1.5.4
minifier-js@2.6.1

View File

@@ -10,6 +10,10 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let CreatureProperties = new Mongo.Collection('creatureProperties');
let CreaturePropertySchema = new SimpleSchema({
_id: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
_migrationError: {
type: String,
optional: true,

View File

@@ -5,9 +5,8 @@ import {
InlineCalculationFieldSchema,
} from '/imports/api/properties/subSchemas/InlineCalculationFieldSchema.js';
import {
FieldToComputeSchema,
ComputedOnlyFieldSchema,
ComputedFieldSchema,
fieldToCompute,
computedOnlyField,
} from '/imports/api/properties/subSchemas/ComputedFieldSchema.js';
import {
ResourcesSchema,
@@ -60,7 +59,7 @@ let ActionSchema = new SimpleSchema({
},
// Calculation of how many times this action can be used
uses: {
type: FieldToComputeSchema,
type: Object,
optional: true,
},
// Integer of how many times it has already been used
@@ -74,7 +73,7 @@ let ActionSchema = new SimpleSchema({
allowedValues: ['longRest', 'shortRest'],
optional: true,
},
});
}).extend(fieldToCompute('uses'));
const ComputedOnlyActionSchema = new SimpleSchema({
summary: {
@@ -85,10 +84,6 @@ const ComputedOnlyActionSchema = new SimpleSchema({
type: ComputedOnlyInlineCalculationFieldSchema,
optional: true,
},
uses: {
type: ComputedOnlyFieldSchema,
optional: true,
},
resources: {
type: ResourcesComputedOnlySchema,
defaultValue: {},
@@ -99,16 +94,12 @@ const ComputedOnlyActionSchema = new SimpleSchema({
type: Boolean,
optional: true,
},
});
}).extend(computedOnlyField('uses'));
const ComputedActionSchema = new SimpleSchema()
.extend(ActionSchema)
.extend(ComputedOnlyActionSchema)
.extend({
uses: {
type: ComputedFieldSchema,
optional: true,
},
summary: {
type: InlineCalculationFieldSchema,
optional: true,

View File

@@ -1,15 +1,20 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import {
fieldToCompute,
computedOnlyField,
} from '/imports/api/properties/subSchemas/ComputedFieldSchema.js';
const AdjustmentSchema = new SimpleSchema({
// The roll that determines how much to change the attribute
// This can be simplified, but should only compute when activated
amount: {
type: String,
type: Object,
optional: true,
defaultValue: '1',
max: STORAGE_LIMITS.calculation,
},
'amount.calculation': {
type: String,
defaultValue: 1,
},
// Who this adjustment applies to
target: {
@@ -32,22 +37,9 @@ const AdjustmentSchema = new SimpleSchema({
allowedValues: ['set', 'increment'],
defaultValue: 'increment',
},
});
}).extend(fieldToCompute('amount'));
const ComputedOnlyAdjustmentSchema = new SimpleSchema({
amountResult: {
type: SimpleSchema.oneOf(String, Number),
optional: true,
},
amountErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'amountErrors.$':{
type: ErrorSchema,
},
});
const ComputedOnlyAdjustmentSchema = computedOnlyField('amount');
const ComputedAdjustmentSchema = new SimpleSchema()
.extend(AdjustmentSchema)

View File

@@ -1,6 +1,9 @@
import SimpleSchema from 'simpl-schema';
import { ActionSchema, ComputedOnlyActionSchema } from '/imports/api/properties/Actions.js';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import {
fieldToCompute,
computedOnlyField,
} from '/imports/api/properties/subSchemas/ComputedFieldSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
// Attacks are special instances of actions
@@ -9,10 +12,12 @@ let AttackSchema = new SimpleSchema()
.extend({
// What gets added to the d20 roll
rollBonus: {
type: Object,
optional: true,
},
'rollBonus.calculation': {
type: String,
defaultValue: 'strength.modifier + proficiencyBonus',
optional: true,
max: STORAGE_LIMITS.calculation,
},
// Set better defaults for the action
actionType: {
@@ -29,24 +34,11 @@ let AttackSchema = new SimpleSchema()
type: String,
max: STORAGE_LIMITS.tagLength,
},
});
}).extend(fieldToCompute('rollBonus'));
const ComputedOnlyAttackSchema = new SimpleSchema()
.extend(ComputedOnlyActionSchema)
.extend({
rollBonusResult: {
type: Number,
optional: true,
},
rollBonusErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'rollBonusErrors.$':{
type: ErrorSchema,
},
});
.extend(computedOnlyField('rollBonus'));
const ComputedAttackSchema = new SimpleSchema()
.extend(AttackSchema)

View File

@@ -1,9 +1,8 @@
import SimpleSchema from 'simpl-schema';
import { Random } from 'meteor/random';
import {
FieldToComputeSchema,
ComputedOnlyFieldSchema,
ComputedFieldSchema,
fieldToCompute,
computedOnlyField,
} from '/imports/api/properties/subSchemas/ComputedFieldSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
@@ -21,10 +20,10 @@ const AttributeConsumedSchema = new SimpleSchema({
max: STORAGE_LIMITS.variableName,
},
quantity: {
type: FieldToComputeSchema,
type: Object,
optional: true,
},
});
}).extend(fieldToCompute('quantity'));
const ComputedOnlyAttributeConsumedSchema = new SimpleSchema({
available: {
@@ -41,21 +40,11 @@ const ComputedOnlyAttributeConsumedSchema = new SimpleSchema({
optional: true,
max: STORAGE_LIMITS.name,
},
quantity: {
type: ComputedOnlyFieldSchema,
optional: true,
},
});
}).extend(computedOnlyField('quantity'));
const ComputedAttributeConsumedSchema = new SimpleSchema()
.extend(AttributeConsumedSchema)
.extend(ComputedOnlyAttributeConsumedSchema)
.extend({
quantity: {
type: ComputedFieldSchema,
optional: true,
},
});
.extend(ComputedOnlyAttributeConsumedSchema);
export {
AttributeConsumedSchema,

View File

@@ -2,34 +2,53 @@ import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const FieldToComputeSchema = new SimpleSchema({
// This is required, if we don't have a calculation delete the whole object
calculation: {
type: String,
},
});
// Get schemas that apply fields directly so they can be gracefully extended
// because {type: Schema} fields can't be extended
function fieldToCompute(field){
return new SimpleSchema({
// The object should already be set, but set again just in case
[field]: {
type: Object,
optional: true,
},
// This is required, if we don't have a calculation delete the whole object
[`${field}.calculation`]: {
type: String,
max: STORAGE_LIMITS.calculation,
},
});
}
const ComputedOnlyFieldSchema = new SimpleSchema({
value: {
type: SimpleSchema.oneOf(String, Number),
optional: true,
},
errors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'errors.$':{
type: ErrorSchema,
},
});
function computedOnlyField(field){
return new SimpleSchema({
// The object should already be set, but set again just in case
[field]: {
type: Object,
optional: true,
},
[`${field}.value`]: {
type: SimpleSchema.oneOf(String, Number),
optional: true,
},
[`${field}.errors`]: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
[`${field}.errors.$`]:{
type: ErrorSchema,
},
});
}
const ComputedFieldSchema = new SimpleSchema()
.extend(FieldToComputeSchema)
.extend(ComputedOnlyFieldSchema)
// This should rarely be used, since the other two will merge correctly when
// uncomputed and computedOnly schemas are merged
function computedField(field){
return computedField(field).extend(computedOnlyField(field));
}
export {
FieldToComputeSchema,
ComputedOnlyFieldSchema,
ComputedFieldSchema
fieldToCompute,
computedOnlyField,
computedField,
};

View File

@@ -1,9 +1,8 @@
import SimpleSchema from 'simpl-schema';
import { Random } from 'meteor/random';
import {
FieldToComputeSchema,
ComputedOnlyFieldSchema,
ComputedFieldSchema,
fieldToCompute,
computedOnlyField,
} from '/imports/api/properties/subSchemas/ComputedFieldSchema.js';
import { storedIconsSchema } from '/imports/api/icons/Icons.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
@@ -21,7 +20,7 @@ const ItemConsumedSchema = new SimpleSchema({
optional: true,
},
quantity: {
type: FieldToComputeSchema,
type: Object,
optional: true,
},
itemId: {
@@ -29,17 +28,13 @@ const ItemConsumedSchema = new SimpleSchema({
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
});
}).extend(fieldToCompute('quantity'));
const ComputedOnlyItemConsumedSchema = new SimpleSchema({
available: {
type: Number,
optional: true,
},
quantity: {
type: ComputedOnlyFieldSchema,
optional: true,
},
// This appears both in the computed and uncomputed schema because it can be
// set by both a computation or a form
itemId: {
@@ -62,17 +57,11 @@ const ComputedOnlyItemConsumedSchema = new SimpleSchema({
optional: true,
max: STORAGE_LIMITS.color,
},
})
}).extend(computedOnlyField('quantity'));
const ComputedItemConsumedSchema = new SimpleSchema()
.extend(ItemConsumedSchema)
.extend(ComputedOnlyItemConsumedSchema)
.extend({
quantity: {
type: ComputedFieldSchema,
optional: true,
},
});
.extend(ComputedOnlyItemConsumedSchema);
export {
ItemConsumedSchema,

View File

@@ -0,0 +1,3 @@
const SCHEMA_VERSION = 1;
export default SCHEMA_VERSION;

View File

@@ -1,186 +0,0 @@
import { Migrations } from 'meteor/percolate:migrations';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { get, merge } from 'lodash';
// Git version 2.0-beta.33
// Database version 1
Migrations.add({
version: 1,
name: 'Unifies calculated field schema',
up(){
CreatureProperties.find({}).forEach(prop => {
const modifier = getUpPropModifier(prop);
if (!modifier) return;
updateOrStoreError(CreatureProperties, prop, modifier);
});
},
down(){
CreatureProperties.find({}).forEach(prop => {
const modifier = getDownPropModifier(prop);
if (!modifier) return;
updateOrStoreError(CreatureProperties, prop, modifier);
});
},
});
function updateOrStoreError(collection, prop, modifier){
try {
collection.update(prop._id, modifier, {
bypassCollection2: true,
//selector: {type: prop.type},
});
} catch(e){
let errorString = e.toString();
if (errorString){
console.warn(errorString, prop._id);
collection.update(prop._id, {
$set: {_migrationError: e.toString()}
}, {
bypassCollection2: true,
});
}
}
}
function getUpPropModifier(prop){
const modifiers = typeUpModifiers[prop.type]?.(prop);
if (!modifiers) return;
return cleanModifier(merge(...modifiers));
}
function getDownPropModifier(prop){
const modifiers = typeDownModifiers[prop.type]?.(prop);
if (!modifiers) return;
return cleanModifier(merge(...modifiers));
}
function cleanModifier(modifier){
if (modifier.$set && !Object.keys(modifier.$set).length){
delete modifier.$set;
}
if (modifier.$unset && !Object.keys(modifier.$unset).length){
delete modifier.$unset;
}
if (!modifier.$set && !modifier.$unset) return;
return modifier;
}
const typeUpModifiers = {
action(prop){
return [
convertComputedField(prop, 'uses'),
// TODO: This doesn't work on itemsConsumed because it is an array field
// Need to iterate over every item consumed
convertComputedField(prop, 'resources.itemsConsumed.quantity'),
convertComputedField(prop, 'resources.attributesConsumed.quantity'),
convertInlineComputationField(prop, 'summary'),
convertInlineComputationField(prop, 'description'),
];
},
};
const typeDownModifiers = {
action(prop){
const modifiers = [
unConvertComputedField(prop, 'uses'),
unConvertComputedField(prop, 'resources.itemsConsumed.quantity'),
unConvertComputedField(prop, 'resources.attributesConsumed.quantity'),
unConvertInlineComputationField(prop, 'summary'),
unConvertInlineComputationField(prop, 'description'),
];
return modifiers;
},
};
function convertComputedField(object, field){
const calculation = get(object, field);
if (!calculation) return {
$unset: {
[field]: 1,
[field + 'Errors']: 1,
[field + 'Result']: 1,
}
};
const errors = get(object, field + 'Errors');
let value = get(object, field + 'Result');
// If the calculation can be cast to number, use that for value
if (value === undefined && Number.isFinite(+calculation)){
value = +calculation;
}
const modifier = {
$unset:{
[field + 'Errors']: 1,
[field + 'Result']: 1,
},
$set: {
[field]: {
value,
calculation,
errors,
}
}
};
return modifier;
}
function unConvertComputedField(object, field){
const calculation = get(object, field)?.calculation;
if (!calculation) return {
$unset: {
[field]: 1,
}
};
const errors = get(object, field).errors;
let value = get(object, field).value;
// If the calculation can be cast to number, use that for value
if (value === undefined && Number.isFinite(+calculation)){
value = +calculation;
}
const modifier = {
$set:{
[field]: calculation,
[field + 'Errors']: errors,
[field + 'Result']: value,
},
};
return modifier;
}
function convertInlineComputationField(object, field){
const text = get(object, field);
const inlineCalculations = get(object, field + 'Calculations');
if (inlineCalculations){
inlineCalculations.forEach(calc => {
calc.value = calc.result;
delete calc.result;
});
}
return {
$unset: {
[field + 'Calculations']: 1,
},
$set: {
[field]: {
text,
inlineCalculations,
}
},
};
}
function unConvertInlineComputationField(object, field){
const text = get(object, field)?.text;
const inlineCalculations = get(object, field)?.inlineCalculations;
if (inlineCalculations) {
inlineCalculations.forEach(calc => {
calc.result = calc.value;
delete calc.value;
});
}
return {
$set: {
[field]: text,
[field + 'Calculations']: inlineCalculations,
},
};
}

View File

@@ -0,0 +1,88 @@
import { Migrations } from 'meteor/percolate:migrations';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { get } from 'lodash';
import embedInlineCalculations from '/imports/api/creature/computation/afterComputation/embedInlineCalculations.js';
import transformFields from '/imports/migrations/server/transformFields.js';
import SCHEMA_VERSION from '/imports/constants/SCHEMA_VERSION.js';
// Git version 2.0-beta.33
// Database version 1
Migrations.add({
version: 1,
name: 'Unifies calculated field schema',
up(){
migrate();
},
down(){
migrate({reversed: true});
},
});
function migrate({reversed} = {}){
migrateCollection({collection: CreatureProperties, reversed});
migrateCollection({collection: LibraryNodes, reversed});
}
function migrateCollection({collection, reversed}){
const bulk = collection.rawCollection().initializeUnorderedBulkOp();
collection.find({}).forEach(prop => {
const newProp = migrateProperty({collection, reversed, prop});
bulk.find({ _id: prop._id }).replaceOne(newProp);
});
bulk.execute();
}
export default function migrateProperty({collection, reversed, prop}){
const transforms = transformsByPropType[prop.type];
let migratedProp = transformFields(prop, transforms, reversed);
const schema = collection.simpleSchema({type: prop.type});
// Only clean if the schema version matches our destination version
if(!reversed && SCHEMA_VERSION === 1){
try {
migratedProp = schema.clean(migratedProp);
schema.validate(migratedProp);
} catch(e){
console.warn(e);
}
}
return migratedProp;
}
const actionTransforms = [
...getComputedPropertyTransforms('uses'),
...getComputedPropertyTransforms('resources.attributesConsumed.$.quantity'),
...getComputedPropertyTransforms('resources.itemsConsumed.$.quantity'),
...getInlineComputationTransforms('summary'),
...getInlineComputationTransforms('description'),
];
const transformsByPropType = {
'action': actionTransforms,
'adjustment': [
...getComputedPropertyTransforms('amount'),
],
'attack': [
...actionTransforms,
...getComputedPropertyTransforms('rollBonus'),
],
};
function getComputedPropertyTransforms(key){
return [
{from: key, to: `${key}.calculation`},
{from: `${key}Result`, to: `${key}.value`},
{from: `${key}Errors`, to: `${key}.errors`},
];
}
function getInlineComputationTransforms(key){
return [
{from: key, to: `${key}.text`},
{from: `${key}Calculations`, to: `${key}.inlineCalculations`},
{to: `${key}.value`, up: (val, doc) =>
embedInlineCalculations(get(doc, key), get(doc, `${key}Calculations`))
},
{from: `${key}Calculations.$.result`, to: `${key}.inlineCalculations.$.value`},
];
}

View File

@@ -0,0 +1,85 @@
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import migrateProperty from './2.0-beta.33-dbv1.js';
import { assert } from 'chai';
const exampleAction = {
'_id':'hY5MKZ4ivaoTRpNWy',
'actionType':'bonus',
'target':'singleTarget',
'tags':[],
'resources':{
'itemsConsumed':[],
'attributesConsumed':[{
'_id':'FaK6jXEj3pSe7mNuu',
'quantity':1,
'variableName':'HunterTech',
'statId':'qccf9j5tfNJjZ3GGn',
'statName':'Hunter\'s Technique',
'available':5
}],
},
'type':'action',
'name':'Hexblade\\\'s Curse',
'parent':{
'id':'JqtDmqa5Zd3xpts5G',
'collection':'creatureProperties'
},
'ancestors':[
{
'collection':'creatures',
'id':'X9rzFhsgFhodYfHmG'
},
],
'order':315,
'summary':'Curse a creature for 1 minute. The curse ends early if {warlock.level >14 ? "" : "the target dies, or"} you are incapacitated. \nGain the following benefits: \n- *Bonus to damage rolls against the cursed target of* **+{proficiencyBonus}**. \n- Any attack roll you make against the cursed target is a **critical hit on a roll of 19 or 20**. \n- If the cursed target dies, you **regain {warlock.level+charisma.modifier} hit points**. \n{warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."}',
'uses':'1',
'usesResult':1,
'reset':'shortRest',
'usesUsed':0,
'description':'Starting at 1st level, you gain the ability to place a baleful curse on someone. As a bonus action, choose one creature you can see within 30 feet of you. The target is cursed for 1 minute. The curse ends early if the target dies, you die, or you are incapacitated. Until the curse ends, you gain the following benefits:\n\n- You gain a bonus to damage rolls against the cursed target. The bonus equals your proficiency bonus.\n- Any attack roll you make against the cursed target is a critical hit on a roll of 19 or 20 on the d20.\n- If the cursed target dies, you regain hit points equal to your warlock level + your Charisma modifier (minimum of 1 hit point). \n{warlock.level <10 ? "" :"- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."} \nYou can\\\'t use this feature again until you finish a short or long rest.',
'color':'#8e24aa',
'dependencies':[
'4eM4YkgAaoCJfCfQ8',
],
'descriptionCalculations':[
{
'calculation':'warlock.level <10 ? "" :"- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."',
'result':'- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.'
}
],
'summaryCalculations':[
{
'calculation':'warlock.level >14 ? "" : "the target dies, or"',
'result':'the target dies, or'
},
{
'calculation':'proficiencyBonus',
'result':'4'
},
{
'calculation':'warlock.level+charisma.modifier',
'result':'15'
},
{
'calculation':'warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."',
'result':'- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.'
}
]
};
describe('migrateProperty', function () {
it('Migrates actions reversibly', function () {
const action = {...exampleAction};
const newAction = migrateProperty({
collection: CreatureProperties,
prop: action
});
const reversedAction = migrateProperty({
collection: CreatureProperties,
prop: newAction,
reversed: true,
});
assert.deepEqual(action, exampleAction, 'action should not be bashed');
assert.deepEqual(exampleAction, reversedAction, 'operation should be reversible');
});
});

View File

@@ -1,2 +1 @@
import './2.0-beta.33-dbv1.js';
import './methods/index.js';

View File

@@ -0,0 +1,112 @@
import { get, set, unset, forEachRight, cloneDeep } from 'lodash';
export default function transformFields(src, transformList, reversed = false){
// don't bash the old document during the transforms
let doc = cloneDeep(src);
for(let originalTransform of transformList){
let transform;
// Swap to and from when reversing
if (reversed){
transform = {
to: originalTransform.from,
from: originalTransform.to,
up: originalTransform.down,
}
} else {
transform = {...originalTransform};
}
if (transform.from?.includes('$.')){
transformArrayField(src, doc, transform, reversed);
} else {
transformSingleField(src, doc, transform);
}
}
return doc;
}
function transformSingleField(src, doc, {from, to, up}){
// Get the value in the `from` path and delete it
let value = undefined;
if (from){
value = get(src, from);
unset(doc, from);
}
// apply the transform function
if (up){
value = up(value, src, doc);
}
// Store the value in the `to` path or unset it if undefined
if (to){
if (value === undefined){
unset(doc, to);
} else {
set(doc, to, value);
}
}
}
/**
* from: 'from.$.here', to: 'to.$.here'
* where from and to are an [array, of, objects] that each need to be modified
* documents at 'from.x.here' will map to 'to.x.here'
* Attempts to support 'from.$.here.$.nested'
* by mapping 'from.x.here.y.nest.z.deep' to 'to.y.nest.z.lessDeep'
* from depth must be >= to depth
*/
function transformArrayField(src, doc, {from, to, up}, reversed){
const fromSplit = from.split('.$');
const toSplit = to.split('.$');
if (toSplit.length > fromSplit.length){
throw 'Can\'t transform array fields where "to" is deeper than "from"'
}
// Stack based depth first traversal of arrays
const stack = [{
array: get(src, fromSplit[0]),
paths: fromSplit.slice(1),
currentPath: fromSplit[0],
indices: [],
}];
while(stack.length){
const state = stack.pop();
// Iterate forwads or backwads defpending on our migration direction
if (reversed){
forEachRight(state.array, iterate(stack, state, src, doc, toSplit, up));
} else {
state.array.forEach(iterate(stack, state, src, doc, toSplit, up));
}
}
}
function iterate(stack, state, src, doc, toSplit, up){return function(key, index){
const currentPath = `${state.currentPath}[${index}]${state.paths[0]}`
if (state.paths.length == 1){
transformSingleField(src, doc, {
from: currentPath,
to: buildToPath(toSplit, [...state.indices, index]),
up
});
} else {
stack.push({
array: get(src, currentPath),
paths: state.paths.slice(1),
currentPath,
indices: [...state.indices, index],
});
}
}}
function buildToPath(toSplit, indices){
let toPath = '';
let offset = indices.length - toSplit.length + 1;
toSplit.forEach((path, i) => {
toPath += `${path}`;
if (i < toSplit.length - 1){
toPath += `[${indices[i + offset]}]`
}
});
return toPath;
}

View File

@@ -0,0 +1,85 @@
import transformFields from './transformFields.js';
import { assert } from 'chai';
const originalDoc = {
name: 'doc name',
description: 'a document to test transforms on',
nest: {
deeper: {
field: 'some nested field'
},
},
array: [{num: 1}, {num: 3}, {num: 5}],
nestArray: [
{array: [{item: 2},{item: 4},{item: 6}]},
{array: [{item: 8},{item: 10},{item: 12}]},
{array: [{item: 14},{item: 16},{item: 18}]},
],
};
describe('transformFields', function () {
it('Takes a doc and transforms it according to single field rules', function () {
let doc = {...originalDoc};
const transformList = [
{from: 'name', to: 'title'},
];
assert.equal(doc.name, 'doc name', '.name is set');
assert.doesNotHaveAnyKeys(doc, ['title'], '.title doesn\'t exist');
doc = transformFields(doc, transformList);
assert.equal(doc.title, 'doc name', '.name -> .title');
assert.doesNotHaveAnyKeys(doc, ['name'], '.name deleted');
});
it('Takes a doc and transforms it with functions', function () {
let doc = {...originalDoc};
const transformList = [
{from: 'name', to: 'name', up: name => name.toUpperCase()},
];
assert.equal(doc.name, 'doc name', 'name in lowercase');
doc = transformFields(doc, transformList);
assert.equal(doc.name, 'DOC NAME', 'name in uppercase');
});
it('Handles empty to and from fields', function () {
let doc = {...originalDoc};
const transformList = [
{to: 'created', up: () => 'from thin air'},
{from: 'description'},
];
doc = transformFields(doc, transformList);
assert.equal(doc.created, 'from thin air', 'created field success');
assert.doesNotHaveAnyKeys(doc, ['description'], '.description deleted');
});
it('Takes a nested field and transforms it into a different nested field', function () {
let doc = {...originalDoc};
const transformList = [
{from: 'nest.deeper', to: 'different.deep'},
];
doc = transformFields(doc, transformList);
assert.equal(doc.different.deep.field, 'some nested field', 'field moved correctly');
assert.doesNotHaveAnyKeys(doc.nest, ['deeper'], 'doc.nest.deeper deleted');
});
it('Transforms arrays', function () {
let doc = {...originalDoc};
const transformList = [
{from: 'array.$.num', to: 'list.$.number'},
];
doc = transformFields(doc, transformList);
assert.equal(doc.list[1].number, 3, 'array field moved correctly');
});
it('Transforms deep arrays', function () {
let doc = {...originalDoc};
const transformList = [
{from: 'nestArray.$.array.$.item', to: 'nestList.$.list.$.ting'},
];
doc = transformFields(doc, transformList);
assert.equal(doc.nestList[2].list[1].ting, 16, 'nested array field moved correctly');
});
});

59
app/package-lock.json generated
View File

@@ -289,6 +289,12 @@
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
},
"assertion-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
"integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
"dev": true
},
"astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -433,6 +439,20 @@
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
},
"chai": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz",
"integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==",
"dev": true,
"requires": {
"assertion-error": "^1.1.0",
"check-error": "^1.0.2",
"deep-eql": "^3.0.1",
"get-func-name": "^2.0.0",
"pathval": "^1.1.1",
"type-detect": "^4.0.5"
}
},
"chalk": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
@@ -484,6 +504,12 @@
}
}
},
"check-error": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
"integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=",
"dev": true
},
"chokidar": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
@@ -651,6 +677,15 @@
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
},
"deep-eql": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
"integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==",
"dev": true,
"requires": {
"type-detect": "^4.0.0"
}
},
"deep-is": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
@@ -1106,6 +1141,12 @@
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
"get-func-name": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
"integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=",
"dev": true
},
"getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
@@ -2491,6 +2532,12 @@
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true
},
"pathval": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
"integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
"dev": true
},
"performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -2966,9 +3013,9 @@
}
},
"tar": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.6.tgz",
"integrity": "sha512-oaWyu5dQbHaYcyZCTfyPpC+VmI62/OM2RTUYavTk1MDr1cwW5Boi3baeYQKiZbY2uSQJGr+iMOzb/JFxLrft+g==",
"version": "6.1.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
"integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
"requires": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
@@ -3024,6 +3071,12 @@
"prelude-ls": "^1.2.1"
}
},
"type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
"dev": true
},
"type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",

View File

@@ -52,6 +52,7 @@
"vuex": "^3.1.3"
},
"devDependencies": {
"chai": "^4.3.4",
"eslint": "^7.31.0",
"eslint-plugin-vue": "^7.14.0",
"eslint-plugin-vuetify": "^1.0.1",
@@ -96,7 +97,8 @@
"es2020": true,
"browser": true,
"node": true,
"meteor": true
"meteor": true,
"mocha": true
},
"rules": {
"quotes": [

View File

@@ -6,4 +6,5 @@ import '/imports/server/publications/index.js';
import '/imports/server/cron/deleteSoftRemovedDocuments.js';
import '/imports/api/parenting/organizeMethods.js';
import '/imports/api/users/patreon/updatePatreonOnLogin.js';
import '/imports/migrations/index.js';
import '/imports/migrations/server/index.js';
import '/imports/migrations/methods/index.js'