Skip to content

Commit

Permalink
feat: add experimental entity-{uniqueness,property-names,join-columns…
Browse files Browse the repository at this point in the history
…} helpers (#1062)

* feat: add entity-{uniqueness,property-names,relation-columns} helpers

* fix: renamed entity-relation-columns to entity-join-columns & added js docs
  • Loading branch information
tada5hi authored Jul 27, 2024
1 parent 40aa3ea commit 9ab61cc
Show file tree
Hide file tree
Showing 26 changed files with 525 additions and 87 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"build:watch": "npm run build -- --watch",
"commit": "npx git-cz",
"test": "jest --config ./test/jest.config.js",
"test:coverage": "cross-env NODE_ENV=test jest --config ./test/jest.config.js --coverage",
"test:coverage": "cross-env NODE_ENV=test jest --config ./test/jest.config.js --coverage --runInBand",
"lint": "eslint --ext .js,.vue,.ts ./src ./test",
"lint:fix": "npm run lint -- --fix",
"docs:dev": "vitepress dev docs --temp .temp",
Expand Down
42 changes: 42 additions & 0 deletions src/helpers/entity/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { TypeormExtensionError } from '../../errors';

type TypeormRelationLookupErrorOptions = {
message: string,
relation: string,
columns: string[]
};

export class EntityRelationLookupError extends TypeormExtensionError {
/**
* The property name of the relation.
*/
public relation: string;

/**
* The property names of the join columns.
*/
public columns: string[];

constructor(options: TypeormRelationLookupErrorOptions) {
super(options.message);

this.relation = options.relation;
this.columns = options.columns;
}

static notReferenced(relation: string, columns: string[]) {
return new EntityRelationLookupError({
message: `${relation} entity is not referenced by ${columns.join(', ')}`,
relation,
columns,
});
}

static notFound(relation: string, columns: string[]) {
return new EntityRelationLookupError({
message: `Can't find ${relation} entity by ${columns.join(', ')}`,
relation,
columns,
});
}
}
5 changes: 5 additions & 0 deletions src/helpers/entity/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './error';
export * from './property-names';
export * from './metadata';
export * from './join-columns';
export * from './uniqueness';
74 changes: 74 additions & 0 deletions src/helpers/entity/join-columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ObjectLiteral } from 'rapiq';
import type { DataSource, EntityTarget, FindOptionsWhere } from 'typeorm';
import { useDataSource } from '../../data-source';
import { EntityRelationLookupError } from './error';
import { getEntityMetadata } from './metadata';

type EntityRelationColumnsValidateOptions<T> = {
dataSource?: DataSource,
entityTarget: EntityTarget<T>,
};

/**
* Validate join columns of a given entity.
* It will look up and append the referenced entities to the input entity.
*
* @experimental
* @param entity
* @param options
*/
export async function validateEntityJoinColumns<T extends ObjectLiteral>(
entity: Partial<T>,
options: EntityRelationColumnsValidateOptions<T>,
) {
const dataSource = options.dataSource || await useDataSource();
const entityMetadata = await getEntityMetadata(options.entityTarget, dataSource);

const relations : Partial<T> = {};
for (let i = 0; i < entityMetadata.relations.length; i++) {
const relation = entityMetadata.relations[i];

const where : FindOptionsWhere<ObjectLiteral> = {};
const columns : string[] = [];
for (let j = 0; j < relation.joinColumns.length; j++) {
const joinColumn = relation.joinColumns[j];
if (typeof entity[joinColumn.propertyName] === 'undefined') {
continue;
}

if (joinColumn.referencedColumn) {
where[joinColumn.referencedColumn.propertyName] = entity[joinColumn.propertyName];
columns.push(joinColumn.propertyName);
} else {
throw EntityRelationLookupError.notReferenced(
relation.propertyName,
[joinColumn.propertyName],
);
}
}

if (columns.length === 0) {
continue;
}

const repository = dataSource.getRepository(relation.type);
const item = await repository.findOne({
where,
});

if (!item) {
throw EntityRelationLookupError.notFound(relation.propertyName, columns);
}

relations[relation.propertyName as keyof T] = item as T[keyof T];
}

const relationKeys = Object.keys(relations);
for (let i = 0; i < relationKeys.length; i++) {
const relationKey = relationKeys[i];

entity[relationKey as keyof T] = relations[relationKey] as T[keyof T];
}

return entity;
}
34 changes: 34 additions & 0 deletions src/helpers/entity/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Repository } from 'typeorm';
import type {
DataSource, EntityMetadata, EntityTarget,
ObjectLiteral,
} from 'typeorm';
import { useDataSource } from '../../data-source';

/**
* Receive metadata for a given repository or entity-target.
*
* @experimental
* @param input
* @param dataSource
*/
export async function getEntityMetadata<T extends ObjectLiteral>(
input: Repository<T> | EntityTarget<T>,
dataSource?: DataSource,
): Promise<EntityMetadata> {
if (input instanceof Repository) {
return input.metadata;
}

dataSource = dataSource || await useDataSource();

const index = dataSource.entityMetadatas.findIndex(
(entityMetadata) => entityMetadata.target === input,
);

if (index === -1) {
throw new Error(`The entity ${input} is not registered.`);
}

return dataSource.entityMetadatas[index];
}
35 changes: 35 additions & 0 deletions src/helpers/entity/property-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ObjectLiteral } from 'rapiq';
import { Repository } from 'typeorm';
import type { DataSource, EntityMetadata, EntityTarget } from 'typeorm';
import { getEntityMetadata } from './metadata';

/**
* Get (relation-) property names of a given entity.
*
* @experimental
* @param input
* @param dataSource
*/
export async function getEntityPropertyNames<T extends ObjectLiteral>(
input: EntityTarget<T> | Repository<T>,
dataSource?: DataSource,
) : Promise<string[]> {
let entityMetadata : EntityMetadata;
if (input instanceof Repository) {
entityMetadata = input.metadata;
} else {
entityMetadata = await getEntityMetadata(input, dataSource);
}

const items : string[] = [];

for (let i = 0; i < entityMetadata.columns.length; i++) {
items.push(entityMetadata.columns[i].propertyName);
}

for (let i = 0; i < entityMetadata.relations.length; i++) {
items.push(entityMetadata.relations[i].propertyName);
}

return items;
}
101 changes: 101 additions & 0 deletions src/helpers/entity/uniqueness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { FilterComparisonOperator } from 'rapiq';
import type { FiltersParseOutputElement } from 'rapiq';
import { Brackets } from 'typeorm';
import type {
DataSource, EntityTarget, ObjectLiteral, WhereExpressionBuilder,
} from 'typeorm';
import { useDataSource } from '../../data-source';
import { applyFiltersTransformed, transformParsedFilters } from '../../query';
import { pickRecord } from '../../utils';
import { getEntityMetadata } from './metadata';

type EntityUniquenessCheckOptions<T> = {
entityTarget: EntityTarget<T>,
entity: Partial<T>,
entityExisting?: Partial<T>,
dataSource?: DataSource
};

function transformUndefinedToNull<T>(input: undefined | T) : T {
if (typeof input === 'undefined') {
return null as T;
}

return input;
}

function applyWhereExpression(
qb: WhereExpressionBuilder,
data: Record<string, any>,
type: 'source' | 'target',
) {
const elements : FiltersParseOutputElement[] = [];

const keys = Object.keys(data);
for (let i = 0; i < keys.length; i++) {
elements.push({
key: keys[i],
value: transformUndefinedToNull(data[keys[i]]),
operator: type === 'target' ?
FilterComparisonOperator.EQUAL :
FilterComparisonOperator.NOT_EQUAL,
});
}

const queryFilters = transformParsedFilters(elements, {
bindingKey(key) {
if (type === 'source') {
return `filter_source_${key}`;
}

return `filter_target_${key}`;
},
});

applyFiltersTransformed(qb, queryFilters);

return queryFilters;
}

/**
* Check if a given entity does not already exist.
* Composite unique keys on a null column can only be present once.
*
* @experimental
* @param options
*/
export async function isEntityUnique<T extends ObjectLiteral>(
options: EntityUniquenessCheckOptions<T>,
) : Promise<boolean> {
const dataSource = options.dataSource || await useDataSource();

const metadata = await getEntityMetadata(options.entityTarget, dataSource);

const repository = dataSource.getRepository(metadata.target);

const primaryColumnNames = metadata.primaryColumns.map((c) => c.propertyName);

for (let i = 0; i < metadata.ownUniques.length; i++) {
const uniqueColumnNames = metadata.ownUniques[i].columns.map(
(column) => column.propertyName,
);

const queryBuilder = repository.createQueryBuilder('entity');
queryBuilder.where(new Brackets((qb) => {
applyWhereExpression(qb, pickRecord(options.entity, uniqueColumnNames), 'target');
}));

queryBuilder.andWhere(new Brackets((qb) => {
if (options.entityExisting) {
applyWhereExpression(qb, pickRecord(options.entityExisting, primaryColumnNames), 'source');
}
}));

const entity = await queryBuilder.getOne();
if (entity) {
return false;
}
}

return true;
}
1 change: 1 addition & 0 deletions src/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './entity';
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export * from './errors';
export * from './query';
export * from './cli/commands';
export * from './database';
export * from './data-source';
export * from './env';
export * from './database';
export * from './helpers';
export * from './seeder';
export * from './utils';
18 changes: 8 additions & 10 deletions src/query/parameter/filters/module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FiltersParseOutput } from 'rapiq';
import { FilterComparisonOperator, parseQueryFilters } from 'rapiq';

import type { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import type { ObjectLiteral, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm';
import { Brackets } from 'typeorm';
import { buildKeyWithPrefix, getAliasForPath } from '../../utils';
import type {
Expand All @@ -16,8 +16,6 @@ export function transformParsedFilters<T extends ObjectLiteral = ObjectLiteral>(
data: FiltersParseOutput,
options: QueryFiltersApplyOptions<T> = {},
) : QueryFiltersOutput {
options = options || {};

const items : QueryFiltersOutput = [];

for (let i = 0; i < data.length; i++) {
Expand All @@ -33,11 +31,11 @@ export function transformParsedFilters<T extends ObjectLiteral = ObjectLiteral>(
fullKey,
];

let bindingKey : string | undefined = typeof options.bindingKey === 'function' ?
options.bindingKey(fullKey) :
undefined;

if (typeof bindingKey === 'undefined') {
let bindingKey : string;
if (options.bindingKey) {
bindingKey = options.bindingKey(fullKey)
.replace('.', '_');
} else {
bindingKey = `filter_${fullKey.replace('.', '_')}`;
}

Expand Down Expand Up @@ -153,7 +151,7 @@ export function transformParsedFilters<T extends ObjectLiteral = ObjectLiteral>(
* @param data
*/
export function applyFiltersTransformed<T extends ObjectLiteral = ObjectLiteral>(
query: SelectQueryBuilder<T>,
query: SelectQueryBuilder<T> | WhereExpressionBuilder,
data: QueryFiltersOutput,
) : QueryFiltersOutput {
if (data.length === 0) {
Expand Down Expand Up @@ -182,7 +180,7 @@ export function applyFiltersTransformed<T extends ObjectLiteral = ObjectLiteral>
* @param options
*/
export function applyQueryFiltersParseOutput<T extends ObjectLiteral = ObjectLiteral>(
query: SelectQueryBuilder<T>,
query: SelectQueryBuilder<T> | WhereExpressionBuilder,
data: FiltersParseOutput,
options?: QueryFiltersApplyOptions<T>,
) : QueryFiltersApplyOutput {
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './entity';
export * from './file-path';
export * from './file-system';
export * from './has-property';
export * from './object';
export * from './promise';
export * from './separator';
export * from './slash';
Expand Down
8 changes: 8 additions & 0 deletions src/utils/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function pickRecord(data: Record<string, any>, keys: string[]) {
const output : Record<string, any> = {};
for (let i = 0; i < keys.length; i++) {
output[keys[i]] = data[keys[i]];
}

return output;
}
Loading

0 comments on commit 9ab61cc

Please sign in to comment.