@vendure/core
Version:
A modern, headless ecommerce framework
490 lines • 23.9 kB
JavaScript
;
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ListQueryBuilder = void 0;
const common_1 = require("@nestjs/common");
const generated_types_1 = require("@vendure/common/lib/generated-types");
const unique_1 = require("@vendure/common/lib/unique");
const typeorm_1 = require("typeorm");
const common_2 = require("../../../common");
const instrument_decorator_1 = require("../../../common/instrument-decorator");
const config_1 = require("../../../config");
const connection_1 = require("../../../connection");
const tree_relations_qb_joiner_1 = require("../utils/tree-relations-qb-joiner");
const connection_utils_1 = require("./connection-utils");
const get_calculated_columns_1 = require("./get-calculated-columns");
const parse_filter_params_1 = require("./parse-filter-params");
const parse_sort_params_1 = require("./parse-sort-params");
/**
* @description
* This helper class is used when fetching entities the database from queries which return a {@link PaginatedList} type.
* These queries all follow the same format:
*
* In the GraphQL definition, they return a type which implements the `Node` interface, and the query returns a
* type which implements the `PaginatedList` interface:
*
* ```GraphQL
* type BlogPost implements Node {
* id: ID!
* published: DateTime!
* title: String!
* body: String!
* }
*
* type BlogPostList implements PaginatedList {
* items: [BlogPost!]!
* totalItems: Int!
* }
*
* # Generated at run-time by Vendure
* input BlogPostListOptions
*
* extend type Query {
* blogPosts(options: BlogPostListOptions): BlogPostList!
* }
* ```
* When Vendure bootstraps, it will find the `BlogPostListOptions` input and, because it is used in a query
* returning a `PaginatedList` type, it knows that it should dynamically generate this input. This means
* all primitive field of the `BlogPost` type (namely, "published", "title" and "body") will have `filter` and
* `sort` inputs created for them, as well a `skip` and `take` fields for pagination.
*
* Your resolver function will then look like this:
*
* ```ts
* \@Resolver()
* export class BlogPostResolver
* constructor(private blogPostService: BlogPostService) {}
*
* \@Query()
* async blogPosts(
* \@Ctx() ctx: RequestContext,
* \@Args() args: any,
* ): Promise<PaginatedList<BlogPost>> {
* return this.blogPostService.findAll(ctx, args.options || undefined);
* }
* }
* ```
*
* and the corresponding service will use the ListQueryBuilder:
*
* ```ts
* \@Injectable()
* export class BlogPostService {
* constructor(private listQueryBuilder: ListQueryBuilder) {}
*
* findAll(ctx: RequestContext, options?: ListQueryOptions<BlogPost>) {
* return this.listQueryBuilder
* .build(BlogPost, options)
* .getManyAndCount()
* .then(async ([items, totalItems]) => {
* return { items, totalItems };
* });
* }
* }
* ```
*
* @docsCategory data-access
* @docsPage ListQueryBuilder
* @docsWeight 0
*/
let ListQueryBuilder = class ListQueryBuilder {
constructor(connection, configService) {
this.connection = connection;
this.configService = configService;
}
/** @internal */
onApplicationBootstrap() {
this.registerSQLiteRegexpFunction();
}
/**
* @description
* Used to determine whether a list query `filter` object contains the
* given property, either at the top level or nested inside a boolean
* `_and` or `_or` expression.
*
* This is useful when a custom property map is used to map a filter
* field to a related entity, and we need to determine whether the
* filter object contains that property, which then means we would need
* to join that relation.
*/
filterObjectHasProperty(filterObject, property) {
if (!filterObject) {
return false;
}
for (const key in filterObject) {
if (!filterObject[key]) {
continue;
}
if (key === property) {
return true;
}
if (key === '_and' || key === '_or') {
const value = filterObject[key];
for (const condition of value) {
if (this.filterObjectHasProperty(condition, property)) {
return true;
}
}
}
}
return false;
}
/*
* @description
* Creates and configures a SelectQueryBuilder for queries that return paginated lists of entities.
*/
build(entity, options = {}, extendedOptions = {}) {
var _a, _b, _c, _d;
const apiType = (_b = (_a = extendedOptions.ctx) === null || _a === void 0 ? void 0 : _a.apiType) !== null && _b !== void 0 ? _b : 'shop';
const { take, skip } = this.parseTakeSkipParams(apiType, options, extendedOptions.ignoreQueryLimits);
const repo = extendedOptions.ctx
? this.connection.getRepository(extendedOptions.ctx, entity)
: this.connection.rawConnection.getRepository(entity);
const alias = extendedOptions.entityAlias || entity.name.toLowerCase();
const minimumRequiredRelations = this.getMinimumRequiredRelations(repo, options, extendedOptions);
const qb = repo.createQueryBuilder(alias);
let relations = (0, unique_1.unique)([...minimumRequiredRelations, ...((_c = extendedOptions === null || extendedOptions === void 0 ? void 0 : extendedOptions.relations) !== null && _c !== void 0 ? _c : [])]);
// Special case for the 'collection' entity, which has a complex nested structure
// and requires special handling to ensure that only the necessary relations are joined.
// This is bypassed an issue in TypeORM where it would join the same relation multiple times.
// See https://github.com/typeorm/typeorm/issues/9936 for more context.
const processedRelations = (0, tree_relations_qb_joiner_1.joinTreeRelationsDynamically)(qb, entity, relations);
// Remove any relations which are related to the 'collection' tree, as these are handled separately
// to avoid duplicate joins.
relations = relations.filter(relationPath => !processedRelations.has(relationPath));
qb.setFindOptions({
relations,
take,
skip,
where: extendedOptions.where || {},
relationLoadStrategy: 'query',
});
// join the tables required by calculated columns
this.joinCalculatedColumnRelations(qb, entity, options);
const { customPropertyMap } = extendedOptions;
if (customPropertyMap) {
this.normalizeCustomPropertyMap(customPropertyMap, options, qb);
}
const customFieldsForType = this.configService.customFields[entity.name];
const sortParams = Object.assign({}, options.sort, extendedOptions.orderBy);
this.applyTranslationConditions(qb, entity, sortParams, extendedOptions.ctx);
const sort = (0, parse_sort_params_1.parseSortParams)(qb.connection, entity, sortParams, customPropertyMap, qb.alias, customFieldsForType);
const filter = (0, parse_filter_params_1.parseFilterParams)(qb.connection, entity, options.filter, customPropertyMap, qb.alias);
if (filter.length) {
const filterOperator = (_d = options.filterOperator) !== null && _d !== void 0 ? _d : generated_types_1.LogicalOperator.AND;
qb.andWhere(new typeorm_1.Brackets(qb1 => {
for (const condition of filter) {
if ('conditions' in condition) {
this.addNestedWhereClause(qb1, condition, filterOperator);
}
else {
if (filterOperator === generated_types_1.LogicalOperator.AND) {
qb1.andWhere(condition.clause, condition.parameters);
}
else {
qb1.orWhere(condition.clause, condition.parameters);
}
}
}
}));
}
if (extendedOptions.channelId) {
qb.innerJoin(`${qb.alias}.channels`, 'lqb__channel', 'lqb__channel.id = :channelId', {
channelId: extendedOptions.channelId,
});
}
qb.orderBy(sort);
return qb;
}
addNestedWhereClause(qb, whereGroup, parentOperator) {
if (whereGroup.conditions.length) {
const subQb = new typeorm_1.Brackets(qb1 => {
whereGroup.conditions.forEach(condition => {
if ('conditions' in condition) {
this.addNestedWhereClause(qb1, condition, whereGroup.operator);
}
else {
if (whereGroup.operator === generated_types_1.LogicalOperator.AND) {
qb1.andWhere(condition.clause, condition.parameters);
}
else {
qb1.orWhere(condition.clause, condition.parameters);
}
}
});
});
if (parentOperator === generated_types_1.LogicalOperator.AND) {
qb.andWhere(subQb);
}
else {
qb.orWhere(subQb);
}
}
}
parseTakeSkipParams(apiType, options, ignoreQueryLimits = false) {
var _a;
const { shopListQueryLimit, adminListQueryLimit } = this.configService.apiOptions;
const takeLimit = ignoreQueryLimits
? Number.MAX_SAFE_INTEGER
: apiType === 'admin'
? adminListQueryLimit
: shopListQueryLimit;
if (options.take && options.take > takeLimit) {
throw new common_2.UserInputError('error.list-query-limit-exceeded', { limit: takeLimit });
}
const rawConnection = this.connection.rawConnection;
const skip = Math.max((_a = options.skip) !== null && _a !== void 0 ? _a : 0, 0);
// `take` must not be negative, and must not be greater than takeLimit
let take = options.take == null ? takeLimit : Math.min(Math.max(options.take, 0), takeLimit);
if (options.skip !== undefined && options.take === undefined) {
take = takeLimit;
}
return { take, skip };
}
/**
* @description
* As part of list optimization, we only join the minimum required relations which are needed to
* get the base list query. Other relations are then joined individually in the patched `getManyAndCount()`
* method.
*/
getMinimumRequiredRelations(repository, options, extendedOptions) {
const requiredRelations = [];
if (extendedOptions.channelId) {
requiredRelations.push('channels');
}
if (extendedOptions.customPropertyMap) {
const metadata = repository.metadata;
for (const [property, path] of Object.entries(extendedOptions.customPropertyMap)) {
if (!this.customPropertyIsBeingUsed(property, options)) {
// If the custom property is not being used to filter or sort, then we don't need
// to join the associated relations.
continue;
}
const relationPath = path.split('.').slice(0, -1);
let targetMetadata = metadata;
const reconstructedPath = [];
for (const relationPathPart of relationPath) {
const relationMetadata = targetMetadata.findRelationWithPropertyPath(relationPathPart);
if (relationMetadata) {
reconstructedPath.push(relationMetadata.propertyName);
requiredRelations.push(reconstructedPath.join('.'));
targetMetadata = relationMetadata.inverseEntityMetadata;
}
}
}
}
return (0, unique_1.unique)(requiredRelations);
}
customPropertyIsBeingUsed(property, options) {
var _a;
return !!(((_a = options.sort) === null || _a === void 0 ? void 0 : _a[property]) || this.isPropertyUsedInFilter(property, options.filter));
}
isPropertyUsedInFilter(property, filter) {
var _a, _b;
return !!(filter &&
(filter[property] ||
((_a = filter._and) === null || _a === void 0 ? void 0 : _a.some(nestedFilter => this.isPropertyUsedInFilter(property, nestedFilter))) ||
((_b = filter._or) === null || _b === void 0 ? void 0 : _b.some(nestedFilter => this.isPropertyUsedInFilter(property, nestedFilter)))));
}
/**
* If a customPropertyMap is provided, we need to take the path provided and convert it to the actual
* relation aliases being used by the SelectQueryBuilder.
*
* This method mutates the customPropertyMap object.
*/
normalizeCustomPropertyMap(customPropertyMap, options, qb) {
var _a;
for (const [property, value] of Object.entries(customPropertyMap)) {
if (!this.customPropertyIsBeingUsed(property, options)) {
continue;
}
let parts = customPropertyMap[property].split('.');
const normalizedRelationPath = [];
let entityMetadata = (_a = qb.expressionMap.mainAlias) === null || _a === void 0 ? void 0 : _a.metadata;
let entityAlias = qb.alias;
while (parts.length > 1) {
const entityPart = 2 <= parts.length ? parts[0] : qb.alias;
const columnPart = parts[parts.length - 1];
if (!entityMetadata) {
config_1.Logger.error(`Could not get metadata for entity ${qb.alias}`);
continue;
}
const relationMetadata = entityMetadata.findRelationWithPropertyPath(entityPart);
if (!relationMetadata || !(relationMetadata === null || relationMetadata === void 0 ? void 0 : relationMetadata.propertyName)) {
config_1.Logger.error(`The customPropertyMap entry "${property}:${value}" could not be resolved to a related table`);
delete customPropertyMap[property];
return;
}
const alias = `${entityMetadata.tableName}_${relationMetadata.propertyName}`;
if (!this.isRelationAlreadyJoined(qb, alias)) {
qb.leftJoinAndSelect(`${entityAlias}.${relationMetadata.propertyName}`, alias);
}
parts = parts.slice(1);
entityMetadata = relationMetadata === null || relationMetadata === void 0 ? void 0 : relationMetadata.inverseEntityMetadata;
normalizedRelationPath.push(entityAlias);
if (parts.length === 1) {
normalizedRelationPath.push(alias, columnPart);
}
else {
entityAlias = alias;
}
}
customPropertyMap[property] = normalizedRelationPath.slice(-2).join('.');
}
}
/**
* Some calculated columns (those with the `@Calculated()` decorator) require extra joins in order
* to derive the data needed for their expressions.
*/
joinCalculatedColumnRelations(qb, entity, options) {
const calculatedColumns = (0, get_calculated_columns_1.getCalculatedColumns)(entity);
const filterAndSortFields = this.getFilterAndSortFields(options);
const alias = (0, connection_utils_1.getEntityAlias)(this.connection.rawConnection, entity);
for (const field of filterAndSortFields) {
const calculatedColumnDef = calculatedColumns.find(c => c.name === field);
const instruction = calculatedColumnDef === null || calculatedColumnDef === void 0 ? void 0 : calculatedColumnDef.listQuery;
if (instruction) {
const relations = instruction.relations || [];
for (const relation of relations) {
const relationIsAlreadyJoined = qb.expressionMap.joinAttributes.find(ja => ja.entityOrProperty === `${alias}.${relation}`);
if (!relationIsAlreadyJoined) {
const propertyPath = relation.includes('.') ? relation : `${alias}.${relation}`;
const relationAlias = relation.includes('.')
? relation.split('.').reverse()[0]
: relation;
qb.innerJoinAndSelect(propertyPath, relationAlias);
}
}
if (typeof instruction.query === 'function') {
instruction.query(qb);
}
}
}
}
getFilterAndSortFields(options) {
const sortFields = Object.keys(options.sort || {});
// filter fields can be immediate children of the filter object
// or nested inside _and or _or
const filterFields = this.getFilterFields(options.filter);
return (0, unique_1.unique)([...sortFields, ...filterFields]);
}
getFilterFields(filter) {
if (!filter) {
return [];
}
const filterFields = [];
for (const key in filter) {
if (key === '_and' || key === '_or') {
const value = filter[key];
for (const condition of value) {
filterFields.push(...this.getFilterFields(condition));
}
}
else if (filter[key]) {
filterFields.push(key);
}
}
return (0, unique_1.unique)(filterFields);
}
/**
* @description
* If this entity is Translatable, and we are sorting on one of the translatable fields,
* then we need to apply appropriate WHERE clauses to limit
* the joined translation relations.
*/
applyTranslationConditions(qb, entity, sortParams, ctx) {
const languageCode = (ctx === null || ctx === void 0 ? void 0 : ctx.languageCode) || this.configService.defaultLanguageCode;
const { translationColumns } = (0, connection_utils_1.getColumnMetadata)(qb.connection, entity);
const alias = qb.alias;
const sortKeys = Object.keys(sortParams);
let sortingOnTranslatableKey = false;
for (const translationColumn of translationColumns) {
if (sortKeys.includes(translationColumn.propertyName)) {
sortingOnTranslatableKey = true;
}
}
if (translationColumns.length && sortingOnTranslatableKey) {
const translationsAlias = qb.connection.namingStrategy.joinTableName(alias, 'translations', '', '');
if (!this.isRelationAlreadyJoined(qb, translationsAlias)) {
qb.leftJoinAndSelect(`${alias}.translations`, translationsAlias);
}
qb.andWhere(new typeorm_1.Brackets(qb1 => {
var _a;
qb1.where(`${translationsAlias}.languageCode = :languageCode`, { languageCode });
const defaultLanguageCode = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.channel.defaultLanguageCode) !== null && _a !== void 0 ? _a : this.configService.defaultLanguageCode;
const translationEntity = translationColumns[0].entityMetadata.target;
if (languageCode !== defaultLanguageCode) {
// If the current languageCode is not the default, then we create a more
// complex WHERE clause to allow us to use the non-default translations and
// fall back to the default language if no translation exists.
qb1.orWhere(new typeorm_1.Brackets(qb2 => {
const subQb1 = this.connection.rawConnection
.createQueryBuilder(translationEntity, 'translation')
.where(`translation.base = ${alias}.id`)
.andWhere('translation.languageCode = :defaultLanguageCode');
const subQb2 = this.connection.rawConnection
.createQueryBuilder(translationEntity, 'translation')
.where(`translation.base = ${alias}.id`)
.andWhere('translation.languageCode = :nonDefaultLanguageCode');
qb2.where(`EXISTS (${subQb1.getQuery()})`).andWhere(`NOT EXISTS (${subQb2.getQuery()})`);
}));
}
else {
qb1.orWhere(new typeorm_1.Brackets(qb2 => {
const subQb1 = this.connection.rawConnection
.createQueryBuilder(translationEntity, 'translation')
.where(`translation.base = ${alias}.id`)
.andWhere('translation.languageCode = :defaultLanguageCode');
const subQb2 = this.connection.rawConnection
.createQueryBuilder(translationEntity, 'translation')
.where(`translation.base = ${alias}.id`)
.andWhere('translation.languageCode != :defaultLanguageCode');
qb2.where(`NOT EXISTS (${subQb1.getQuery()})`).andWhere(`EXISTS (${subQb2.getQuery()})`);
}));
}
qb.setParameters({
nonDefaultLanguageCode: languageCode,
defaultLanguageCode,
});
}));
}
}
/**
* Registers a user-defined function (for flavors of SQLite driver that support it)
* so that we can run regex filters on string fields.
*/
registerSQLiteRegexpFunction() {
const regexpFn = (pattern, value) => {
const result = new RegExp(`${pattern}`, 'i').test(value);
return result ? 1 : 0;
};
const dbType = this.connection.rawConnection.options.type;
if (dbType === 'better-sqlite3') {
const driver = this.connection.rawConnection.driver;
driver.databaseConnection.function('regexp', regexpFn);
}
if (dbType === 'sqljs') {
const driver = this.connection.rawConnection.driver;
driver.databaseConnection.create_function('regexp', regexpFn);
}
}
isRelationAlreadyJoined(qb, alias) {
return qb.expressionMap.joinAttributes.some(ja => ja.alias.name === alias);
}
};
exports.ListQueryBuilder = ListQueryBuilder;
exports.ListQueryBuilder = ListQueryBuilder = __decorate([
(0, common_1.Injectable)(),
(0, instrument_decorator_1.Instrument)(),
__metadata("design:paramtypes", [connection_1.TransactionalConnection,
config_1.ConfigService])
], ListQueryBuilder);
//# sourceMappingURL=list-query-builder.js.map