Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conflict resolution #393

Merged
merged 12 commits into from
Jun 23, 2020
14 changes: 12 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@
"@ngneat/until-destroy": "^7.1.6",
"@sentry/browser": "^5.16.0",
"crypto-js": "^4.0.0",
"deep-object-diff": "^1.1.0",
"faker": "^4.1.0",
"file-saver": "^2.0.2",
"font-awesome": "^4.7.0",
"moment": "^2.26.0",
"ngx-cookie-service": "^3.0.4",
"lodash": "^4.17.15",
"ngx-filter-pipe": "^2.1.2",
"ngx-markdown": "^9.1.1",
"ngx-papaparse": "^4.0.4",
Expand Down Expand Up @@ -61,6 +63,7 @@
"@types/faker": "^4.1.12",
"@types/file-saver": "^2.0.1",
"@types/jasmine": "^3.5.10",
"@types/lodash": "^4.14.149",
"@types/node": "^12.12.43",
"@types/pouchdb": "^6.4.0",
"codelyzer": "^5.1.2",
Expand Down
3 changes: 3 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ export class AppModule {
this.navigationItemsService.addMenuItem(
new MenuItem("Users", "user", ["/users"], true)
);
this.navigationItemsService.addMenuItem(
new MenuItem("Database Conflicts", "wrench", ["/admin/conflicts"], true)
);
this.navigationItemsService.addMenuItem(
new MenuItem("Help", "question-circle", ["/help"])
);
Expand Down
8 changes: 8 additions & 0 deletions src/app/app.routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export const routes: Routes = [
{ path: "attendance/add/day", component: AddDayAttendanceComponent },
{ path: "admin", component: AdminComponent, canActivate: [AdminGuard] },
{ path: "users", component: UserListComponent, canActivate: [AdminGuard] },
{
path: "admin/conflicts",
canActivate: [AdminGuard],
loadChildren: () =>
import("./conflict-resolution/conflict-resolution.module").then(
(m) => m.ConflictResolutionModule
),
},
{ path: "help", component: HowToComponent },
{ path: "**", redirectTo: "/" },
];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Injectable } from "@angular/core";
import { ConflictResolutionStrategy } from "../../conflict-resolution/auto-resolution/conflict-resolution-strategy";
import { AttendanceMonth } from "./model/attendance-month";
import _ from "lodash";
import { diff } from "deep-object-diff";

/**
* Auto resolve simple database document conflicts concerning {@link AttendanceMonth} entities.
*/
@Injectable()
export class AttendanceMonthConflictResolutionStrategy
implements ConflictResolutionStrategy {
/**
* Checks if the given conflict is about AttendanceMonth entities (otherwise this strategy doesn't apply)
* and suggests whether the conflict is trivial and can be automatically deleted.
* @param currentDoc The currently active revision
* @param conflictingDoc The conflicting revision to be checked whether it can be deleted
*/
public autoDeleteConflictingRevision(
currentDoc: any,
conflictingDoc: any
): boolean {
if (!currentDoc._id.startsWith(AttendanceMonth.ENTITY_TYPE)) {
return false;
}

const currentDocC = _.merge({}, currentDoc);
delete currentDocC._rev;
const conflictingDocC = _.merge({}, conflictingDoc);
delete conflictingDocC._rev;

return this.isIrrelevantAttendanceMonthConflict(
currentDocC,
conflictingDocC
);
}

/**
* Calculate a diff between the two objects discarding trivial differences.
* @param currentDoc The object to compare against
* @param conflictingDoc The conflicting object version to compare
*/
private isIrrelevantAttendanceMonthConflict(
currentDoc: any,
conflictingDoc: any
): boolean {
const diffObject = diff(currentDoc, conflictingDoc);

const simplifiedDiff = this.removeTrivialDiffValuesRecursively(diffObject, [
"?",
"",
undefined,
null,
]);

return _.isObjectLike(simplifiedDiff) && _.isEmpty(simplifiedDiff);
}

/**
* Changes the given object, deep scanning it to remove any values given as the second argument.
* @param diffObject
* @param trivialValues
*/
private removeTrivialDiffValuesRecursively(
diffObject: any,
trivialValues: any[]
) {
for (const k of Object.keys(diffObject)) {
if (trivialValues.includes(diffObject[k])) {
delete diffObject[k];
}

if (typeof diffObject[k] === "object" && diffObject[k] !== null) {
this.removeTrivialDiffValuesRecursively(diffObject[k], trivialValues);

if (_.isObjectLike(diffObject[k]) && _.isEmpty(diffObject[k])) {
delete diffObject[k];
}
}
}

return diffObject;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { TestBed } from "@angular/core/testing";
import { AttendanceMonthConflictResolutionStrategy } from "./attendance-month-conflict-resolution-strategy";
import { AttendanceMonth } from "./model/attendance-month";
import { AttendanceDay, AttendanceStatus } from "./model/attendance-day";

describe("AutoResolutionService", () => {
let service: AttendanceMonthConflictResolutionStrategy;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [AttendanceMonthConflictResolutionStrategy],
});
service = TestBed.get(AttendanceMonthConflictResolutionStrategy);
});

it("should be created", () => {
expect(service).toBeTruthy();
});

it("should suggest deleting irrelevant/trivial conflict", () => {
const currentDoc = new AttendanceMonth("test1");
currentDoc.month = new Date(2019, 0);
currentDoc.dailyRegister[0] = new AttendanceDay(
new Date(2019, 0, 1),
AttendanceStatus.ABSENT
);

const conflictingDoc = new AttendanceMonth("test1");
conflictingDoc.month = new Date(2019, 0);
// no dailyRegister entries set

const result = service.autoDeleteConflictingRevision(
currentDoc,
conflictingDoc
);
expect(result).toBe(true);
});

it("should not suggest deleting complex attendance diff conflicts", () => {
const currentDoc = new AttendanceMonth("test1");
currentDoc.month = new Date(2019, 0);
currentDoc.dailyRegister[0] = new AttendanceDay(
new Date(2019, 0, 1),
AttendanceStatus.ABSENT
);

const conflictingDoc = new AttendanceMonth("test1");
conflictingDoc.month = new Date(2019, 0);
conflictingDoc.dailyRegister[1] = new AttendanceDay(
new Date(2019, 0, 1),
AttendanceStatus.EXCUSED
);

const result = service.autoDeleteConflictingRevision(
currentDoc,
conflictingDoc
);
expect(result).toBe(false);
});
});
13 changes: 12 additions & 1 deletion src/app/child-dev-project/children/children.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ import { MatProgressSpinnerModule } from "@angular/material/progress-spinner";
import { RecentNotesDashboardComponent } from "../notes/dashboard-widgets/recent-notes-dashboard/recent-notes-dashboard.component";
import { FormDialogModule } from "../../core/form-dialog/form-dialog.module";
import { ConfirmationDialogModule } from "../../core/confirmation-dialog/confirmation-dialog.module";
import { CONFLICT_RESOLUTION_STRATEGY } from "../../conflict-resolution/auto-resolution/conflict-resolution-strategy";
import { AttendanceMonthConflictResolutionStrategy } from "../attendance/attendance-month-conflict-resolution-strategy";
import { MatPaginatorModule } from "@angular/material/paginator";

@NgModule({
Expand Down Expand Up @@ -141,7 +143,16 @@ import { MatPaginatorModule } from "@angular/material/paginator";
HealthCheckupComponent,
PreviousSchoolsComponent,
],
providers: [ChildrenService, DatePipe, PercentPipe],
providers: [
ChildrenService,
DatePipe,
PercentPipe,
{
provide: CONFLICT_RESOLUTION_STRATEGY,
useClass: AttendanceMonthConflictResolutionStrategy,
multi: true,
},
],
exports: [
ChildBlockComponent,
ChildSelectComponent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { TestBed } from "@angular/core/testing";

import { AutoResolutionService } from "./auto-resolution.service";
import {
CONFLICT_RESOLUTION_STRATEGY,
ConflictResolutionStrategy,
} from "./conflict-resolution-strategy";

describe("AutoResolutionService", () => {
let service: AutoResolutionService;

let mockResolutionStrategy: jasmine.SpyObj<ConflictResolutionStrategy>;

beforeEach(() => {
mockResolutionStrategy = jasmine.createSpyObj("mockResolutionStrategy", [
"autoDeleteConflictingRevision",
]);

TestBed.configureTestingModule({
providers: [
{
provide: CONFLICT_RESOLUTION_STRATEGY,
useValue: mockResolutionStrategy,
multi: true,
},
],
});
service = TestBed.get(AutoResolutionService);
});

it("should be created", () => {
expect(service).toBeTruthy();
});

it("should suggest auto delete conflict if a strategy applies", () => {
const testDoc = { _id: "abc", _rev: "rev-1a", value: 1 };
const testConflictDoc = { _id: "abc", _rev: "rev-1b", value: 2 };

mockResolutionStrategy.autoDeleteConflictingRevision.and.returnValue(true);

const result = service.shouldDeleteConflictingRevision(
testDoc,
testConflictDoc
);

expect(
mockResolutionStrategy.autoDeleteConflictingRevision
).toHaveBeenCalled();
expect(result).toBe(true);
});

it("should not suggest auto delete conflicts if no strategy applies", () => {
const testDoc = { _id: "abc", _rev: "rev-1a", value: 1 };
const testConflictDoc = { _id: "abc", _rev: "rev-1b", value: 2 };

mockResolutionStrategy.autoDeleteConflictingRevision.and.returnValue(false);

const result = service.shouldDeleteConflictingRevision(
testDoc,
testConflictDoc
);

expect(
mockResolutionStrategy.autoDeleteConflictingRevision
).toHaveBeenCalled();
expect(result).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Inject, Injectable } from "@angular/core";
import {
CONFLICT_RESOLUTION_STRATEGY,
ConflictResolutionStrategy,
} from "./conflict-resolution-strategy";

/**
* Attempt automatic conflict resolutions or identify trivial conflicts for semi-automatic resolution.
*/
@Injectable({
providedIn: "root",
})
export class AutoResolutionService {
constructor(
@Inject(CONFLICT_RESOLUTION_STRATEGY)
private resolutionStrategies: ConflictResolutionStrategy[]
) {}

/**
* Checks whether any registered resolution strategy suggests that the conflicting version should be automatically deleted.
*
* This method does not delete the conflict. It only suggests whether it should be deleted automatically.
*
* @param currentDoc The currently active revision of the doc
* @param conflictingDoc The conflicting revision of the doc to be checked whether it can be deleted
*/
public shouldDeleteConflictingRevision(
currentDoc: any,
conflictingDoc: any
): boolean {
for (const resolutionStrategy of this.resolutionStrategies) {
if (
resolutionStrategy.autoDeleteConflictingRevision(
currentDoc,
conflictingDoc
)
) {
return true;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { InjectionToken } from "@angular/core";

/**
* Use this token to provide (and thereby register) custom implementations of {@link ConflictResolutionStrategy}.
*
* `{ provide: CONFLICT_RESOLUTION_STRATEGY, useClass: MyConflictResolutionStrategy, multi: true }`
*
* see {@link ConflictResolutionModule}
*/
export const CONFLICT_RESOLUTION_STRATEGY = new InjectionToken<
ConflictResolutionStrategy
>("ConflictResolutionStrategy");

/**
* Implement this interface to provide custom strategies how certain conflicts of an Entity type can be resolved automatically.
*
* see {@link ConflictResolutionModule}
*/
export interface ConflictResolutionStrategy {
autoDeleteConflictingRevision(currentDoc: any, conflictingDoc: any): boolean;
}
Loading