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

Entity Mapping Update #2823

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 63 additions & 37 deletions src/app/core/basic-datatypes/entity/entity.datatype.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,50 +23,76 @@ import { EntitySchemaField } from "../../entity/schema/entity-schema-field";
import { TestEntity } from "../../../utils/test-utils/TestEntity";

describe("Schema data type: entity", () => {
testDatatype(new EntityDatatype(null, null), "1", "1", "User");
testDatatype(new EntityDatatype(null as any, null as any), "1", "1", "User");

it("should map to the referenced entity", async () => {
const c1 = TestEntity.create("first");
const c2 = new TestEntity();
c2.other = "123";
const entityMapper = mockEntityMapper([c1, c2]);
const dataType = new EntityDatatype(entityMapper, null);
const schema = TestEntity.schema.get("ref");
describe("importMapFunction", () => {
sadaf895 marked this conversation as resolved.
Show resolved Hide resolved
let entityMapper: ReturnType<typeof mockEntityMapper>;
let dataType: EntityDatatype;
let schema: EntitySchemaField;

await expectAsync(
dataType.importMapFunction("first", schema, "name"),
).toBeResolvedTo(c1.getId());
await expectAsync(
dataType.importMapFunction("123", schema, "other"),
).toBeResolvedTo(c2.getId());
await expectAsync(
dataType.importMapFunction("345", schema, "other"),
).toBeResolvedTo(undefined);
let c1: TestEntity;
let c2: TestEntity;

beforeEach(() => {
c1 = TestEntity.create("first");
c2 = new TestEntity();
c2.other = "123"; // Ensure other is a string
entityMapper = mockEntityMapper([c1, c2]);
dataType = new EntityDatatype(entityMapper, null as any);
schema = TestEntity.schema.get("ref") as EntitySchemaField;
});

it("should map to the referenced entity by name", async () => {
await expectAsync(dataType.importMapFunction("first", schema, "name"))
.toBeResolvedTo(c1.getId());
});

it("should map to the referenced entity by other field", async () => {
await expectAsync(dataType.importMapFunction("123", schema, "other"))
.toBeResolvedTo(c2.getId());
});

it("should return undefined when no matching entity is found", async () => {
await expectAsync(dataType.importMapFunction("345", schema, "other"))
.toBeResolvedTo(undefined);
});

it("should handle numeric-string mismatches correctly", async () => {
const c3 = new TestEntity();
c3.other = "456"; // Ensure it's a string
entityMapper = mockEntityMapper([c3]);
dataType = new EntityDatatype(entityMapper, null as any);

await expectAsync(dataType.importMapFunction("456", schema, "other"))
.toBeResolvedTo(c3.getId());
sadaf895 marked this conversation as resolved.
Show resolved Hide resolved
});
});

it("should anonymize entity recursively", async () => {
const referencedEntity = new TestEntity("ref-1");
referencedEntity.name = "test";
describe("anonymize", () => {
it("should anonymize entity recursively", async () => {
const referencedEntity = new TestEntity("ref-1");
referencedEntity.name = "test";

const entityMapper = mockEntityMapper([referencedEntity]);
spyOn(entityMapper, "save");
const mockRemoveService: jasmine.SpyObj<EntityActionsService> =
jasmine.createSpyObj("EntityRemoveService", ["anonymize"]);
const dataType = new EntityDatatype(entityMapper, mockRemoveService);
const entityMapper = mockEntityMapper([referencedEntity]);
spyOn(entityMapper, "save");
const mockRemoveService: jasmine.SpyObj<EntityActionsService> =
jasmine.createSpyObj("EntityRemoveService", ["anonymize"]);
const dataType = new EntityDatatype(entityMapper, mockRemoveService);

const testValue = referencedEntity.getId();
const testSchemaField: EntitySchemaField = {
additional: TestEntity.ENTITY_TYPE,
dataType: "entity",
};
const testValue = referencedEntity.getId();
const testSchemaField: EntitySchemaField = {
additional: TestEntity.ENTITY_TYPE,
dataType: "entity",
};

const anonymizedValue = await dataType.anonymize(
testValue,
testSchemaField,
null,
);
const anonymizedValue = await dataType.anonymize(
testValue,
testSchemaField,
null,
);

expect(anonymizedValue).toEqual(testValue);
expect(mockRemoveService.anonymize).toHaveBeenCalledWith(referencedEntity);
expect(anonymizedValue).toEqual(testValue);
expect(mockRemoveService.anonymize).toHaveBeenCalledWith(referencedEntity);
});
});
});
98 changes: 72 additions & 26 deletions src/app/core/basic-datatypes/entity/entity.datatype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ import { EntitySchemaField } from "../../entity/schema/entity-schema-field";
import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service";
import { ColumnMapping } from "../../import/column-mapping";
import { EntityActionsService } from "../../entity/entity-actions/entity-actions.service";
import { Logging } from "app/core/logging/logging.service";

/**
* Datatype for the EntitySchemaService to handle a single reference to another entity.
* Stored as simple id string.
* Stored as a simple ID string.
*
* For example:
* Example:
*
* `@DatabaseField({dataType: 'entity', additional: 'Child'}) relatedEntity: string;`
*/
Expand All @@ -41,50 +42,95 @@ export class EntityDatatype extends StringDatatype {

constructor(
private entityMapper: EntityMapperService,
private removeService: EntityActionsService,
private removeService: EntityActionsService
) {
super();
}

override importMapFunction(
/**
* Maps a value from an import to an actual entity in the database by comparing the value with the given field of entities.
* Handles type conversion between numbers and strings to improve matching.
*
* @param val The value from an import that should be mapped to an entity reference.
* @param schemaField The config defining details of the field that will hold the entity reference after mapping.
* @param additional The field of the referenced entity that should be compared with the val.
* @returns Promise resolving to the ID of the matched entity or undefined if no match is found.
*/
override async importMapFunction(
val: any,
schemaField: EntitySchemaField,
additional?: any,
) {
if (!additional) {
return Promise.resolve(undefined);
additional?: string
): Promise<string | undefined> {
if (!additional || val == null) {
return undefined;
}

const normalizedVal = this.normalizeValue(val);

try {
const entities = await this.entityMapper.loadType(schemaField.additional);
const matchedEntity = entities.find((entity) => {
const entityFieldValue = this.normalizeValue(entity[additional]);
return entityFieldValue === normalizedVal;
});

return matchedEntity?.getId();
} catch (error) {
Logging.error("Error in EntityDatatype importMapFunction:", error);
return undefined;
}
}

/**
* Normalizes a value for comparison, converting it to a string or number.
* @param val The value to normalize.
* @returns The normalized value as a string or number.
*/
private normalizeValue(val: any): string | number {
if (typeof val === "string" || typeof val === "number") {
return val;
}
return this.entityMapper
.loadType(schemaField.additional)
.then((res) => res.find((e) => e[additional] === val)?.getId());

const numVal = Number(val);
return !isNaN(numVal) ? numVal : String(val);
}

/**
* Returns a badge indicator if additional config is missing.
* @param col The column mapping object.
* @returns "?" if additional config is missing, otherwise undefined.
*/
override importIncompleteAdditionalConfigBadge(col: ColumnMapping): string {
return col.additional ? undefined : "?";
}

/**
* Recursively calls anonymize on the referenced entity and saves it.
* @param value
* @param schemaField
* @param parent
* Recursively anonymizes a referenced entity.
* @param value The entity reference value.
* @param schemaField The schema field containing reference details.
* @param parent The parent entity (not used in this method).
* @returns The original value if anonymization is successful.
*/
override async anonymize(
value,
value: string,
schemaField: EntitySchemaField,
parent,
parent: any
): Promise<string> {
const referencedEntity = await this.entityMapper.load(
schemaField.additional,
value,
);
try {
const referencedEntity = await this.entityMapper.load(
schemaField.additional,
value
);

if (!referencedEntity) {
return value;
}

if (!referencedEntity) {
// TODO: remove broken references?
await this.removeService.anonymize(referencedEntity);
return value;
} catch (error) {
Logging.error("Error in EntityDatatype anonymize:", error);
return value;
}

await this.removeService.anonymize(referencedEntity);
return value;
}
}
17 changes: 0 additions & 17 deletions src/app/core/entity/default-datatype/default.datatype.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,3 @@
/*
* This file is part of ndb-core.
*
* ndb-core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ndb-core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ndb-core. If not, see <http://www.gnu.org/licenses/>.
*/

import { EntitySchemaField } from "../schema/entity-schema-field";
import { Entity } from "../model/entity";
import { ColumnMapping } from "../../import/column-mapping";
Expand Down
Loading