mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-28 14:52:28 +03:00
feat: add findAll and findUnique resolver for universal objects (#1576)
* wip: refacto and start creating custom resolver * feat: findMany & findUnique of a custom entity * feat: wip pagination * feat: initial metadata migration * feat: universal findAll with pagination * fix: clean small stuff in pagination * fix: test * fix: miss file * feat: rename custom into universal * feat: create metadata schema in default database --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
parent
dafe08ef78
commit
b1171e22a3
@ -7,3 +7,9 @@ CREATE DATABASE "test";
|
||||
-- Create a twenty user
|
||||
CREATE USER twenty PASSWORD 'twenty';
|
||||
ALTER USER twenty CREATEDB;
|
||||
|
||||
-- Connect to the "default" database
|
||||
\c "default";
|
||||
|
||||
-- Create the metadata schema if it doesn't exist
|
||||
CREATE SCHEMA IF NOT EXISTS "metadata";
|
||||
|
@ -23,10 +23,10 @@
|
||||
"prisma:generate-gql-select": "node scripts/generate-model-select-map.js",
|
||||
"prisma:generate-nest-graphql": "npx prisma generate --generator nestgraphql",
|
||||
"prisma:generate": "yarn prisma:generate-client && yarn prisma:generate-gql-select && yarn prisma:generate-nest-graphql",
|
||||
"prisma:migrate": "npx prisma migrate deploy",
|
||||
"prisma:migrate": "npx prisma migrate deploy && yarn typeorm migration:run -- -d ./src/tenant/metadata/metadata.datasource.ts",
|
||||
"prisma:seed": "npx prisma db seed",
|
||||
"prisma:reset": "npx prisma migrate reset && yarn prisma:generate",
|
||||
"typeorm": "typeorm-ts-node-commonjs"
|
||||
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.7.3",
|
||||
@ -54,7 +54,7 @@
|
||||
"add": "^2.0.6",
|
||||
"apollo-server-express": "^3.12.0",
|
||||
"axios": "^1.4.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"bytes": "^3.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
@ -70,6 +70,7 @@
|
||||
"lodash.isobject": "^3.0.2",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.snakecase": "^4.1.1",
|
||||
"ms": "^2.1.3",
|
||||
"passport": "^0.6.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
@ -98,6 +99,7 @@
|
||||
"@types/jest": "28.1.8",
|
||||
"@types/lodash.isobject": "^3.0.7",
|
||||
"@types/lodash.kebabcase": "^4.1.7",
|
||||
"@types/lodash.snakecase": "^4.1.7",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
|
@ -14,6 +14,7 @@ import { IntegrationsModule } from './integrations/integrations.module';
|
||||
import { PrismaModule } from './database/prisma.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AbilityModule } from './ability/ability.module';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -38,6 +39,7 @@ import { AbilityModule } from './ability/ability.module';
|
||||
AbilityModule,
|
||||
IntegrationsModule,
|
||||
CoreModule,
|
||||
TenantModule,
|
||||
],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
@ -6,9 +6,16 @@
|
||||
* @returns
|
||||
*/
|
||||
export function uuidToBase36(uuid: string): string {
|
||||
let devId = false;
|
||||
|
||||
if (uuid.startsWith('twenty-')) {
|
||||
devId = true;
|
||||
// Clean dev uuids (twenty-)
|
||||
uuid = uuid.replace('twenty-', '');
|
||||
}
|
||||
const hexString = uuid.replace(/-/g, '');
|
||||
const base10Number = BigInt('0x' + hexString);
|
||||
const base36String = base10Number.toString(36);
|
||||
|
||||
return base36String;
|
||||
return `${devId ? 'twenty_' : ''}${base36String}`;
|
||||
}
|
||||
|
@ -4,4 +4,12 @@ export const baseColumns = {
|
||||
type: 'uuid',
|
||||
generated: 'uuid',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'timestamp',
|
||||
createDate: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'timestamp',
|
||||
updateDate: true,
|
||||
},
|
||||
} as const;
|
||||
|
@ -9,6 +9,7 @@ import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||
import { DataSourceMetadataService } from './data-source-metadata/data-source-metadata.service';
|
||||
import { EntitySchemaGeneratorService } from './entity-schema-generator/entity-schema-generator.service';
|
||||
import { DataSourceService } from './data-source/data-source.service';
|
||||
import { uuidToBase36 } from './data-source/data-source.util';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('metadata')
|
||||
@ -39,6 +40,10 @@ export class MetadataController {
|
||||
entities.push(...dataSourceEntities);
|
||||
}
|
||||
|
||||
this.dataSourceService.createWorkspaceSchema(workspace.id);
|
||||
|
||||
console.log('entities', uuidToBase36(workspace.id), workspace.id);
|
||||
|
||||
this.dataSourceService.connectToWorkspaceDataSource(workspace.id);
|
||||
|
||||
return entities;
|
||||
|
25
server/src/tenant/metadata/metadata.datasource.ts
Normal file
25
server/src/tenant/metadata/metadata.datasource.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config();
|
||||
|
||||
const configService = new ConfigService();
|
||||
|
||||
export const typeORMMetadataModuleOptions: TypeOrmModuleOptions = {
|
||||
url: configService.get<string>('PG_DATABASE_URL')!,
|
||||
type: 'postgres',
|
||||
logging: false,
|
||||
schema: 'metadata',
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: false,
|
||||
migrationsRun: true,
|
||||
migrationsTableName: '_typeorm_migrations',
|
||||
migrations: [__dirname + '/migrations/**/*{.ts,.js}'],
|
||||
};
|
||||
|
||||
export const connectionSource = new DataSource(
|
||||
typeORMMetadataModuleOptions as DataSourceOptions,
|
||||
);
|
@ -1,36 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
|
||||
import { MetadataService } from './metadata.service';
|
||||
import { MetadataController } from './metadata.controller';
|
||||
import { typeORMMetadataModuleOptions } from './metadata.datasource';
|
||||
|
||||
import { DataSourceModule } from './data-source/data-source.module';
|
||||
import { DataSourceMetadataModule } from './data-source-metadata/data-source-metadata.module';
|
||||
import { FieldMetadataModule } from './field-metadata/field-metadata.module';
|
||||
import { ObjectMetadataModule } from './object-metadata/object-metadata.module';
|
||||
import { EntitySchemaGeneratorModule } from './entity-schema-generator/entity-schema-generator.module';
|
||||
import { DataSourceMetadata } from './data-source-metadata/data-source-metadata.entity';
|
||||
import { FieldMetadata } from './field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadata } from './object-metadata/object-metadata.entity';
|
||||
|
||||
const typeORMFactory = async (
|
||||
environmentService: EnvironmentService,
|
||||
): Promise<TypeOrmModuleOptions> => ({
|
||||
url: environmentService.getPGDatabaseUrl(),
|
||||
type: 'postgres',
|
||||
logging: false,
|
||||
schema: 'metadata',
|
||||
entities: [DataSourceMetadata, FieldMetadata, ObjectMetadata],
|
||||
synchronize: true,
|
||||
const typeORMFactory = async (): Promise<TypeOrmModuleOptions> => ({
|
||||
...typeORMMetadataModuleOptions,
|
||||
});
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
useFactory: typeORMFactory,
|
||||
inject: [EnvironmentService],
|
||||
name: 'metadata',
|
||||
}),
|
||||
DataSourceModule,
|
||||
|
@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Migrations1695198840363 implements MigrationInterface {
|
||||
name = 'Migrations1695198840363';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "metadata"."data_source_metadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "url" character varying, "schema" character varying, "type" "metadata"."data_source_metadata_type_enum" NOT NULL DEFAULT 'postgres', "name" character varying, "is_remote" boolean NOT NULL DEFAULT false, "workspace_id" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_923752b7e62a300a4969bd0e038" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "metadata"."field_metadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "object_id" uuid NOT NULL, "type" character varying NOT NULL, "name" character varying NOT NULL, "is_custom" boolean NOT NULL DEFAULT false, "workspace_id" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c75db587904cad6af109b5c65f1" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "metadata"."object_metadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "data_source_id" character varying NOT NULL, "name" character varying NOT NULL, "is_custom" boolean NOT NULL DEFAULT false, "workspace_id" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c8c5f885767b356949c18c201c1" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "metadata"."field_metadata" ADD CONSTRAINT "FK_38179b299795e48887fc99f937a" FOREIGN KEY ("object_id") REFERENCES "metadata"."object_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "metadata"."field_metadata" DROP CONSTRAINT "FK_38179b299795e48887fc99f937a"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "metadata"."object_metadata"`);
|
||||
await queryRunner.query(`DROP TABLE "metadata"."field_metadata"`);
|
||||
await queryRunner.query(`DROP TABLE "metadata"."data_source_metadata"`);
|
||||
}
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MetadataModule } from './metadata/metadata.module';
|
||||
import { UniversalModule } from './universal/universal.module';
|
||||
|
||||
@Module({
|
||||
imports: [MetadataModule],
|
||||
imports: [MetadataModule, UniversalModule],
|
||||
})
|
||||
export class TenantModule {}
|
||||
|
11
server/src/tenant/universal/args/base-universal.args.ts
Normal file
11
server/src/tenant/universal/args/base-universal.args.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class BaseUniversalArgs {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
entity: string;
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { BaseUniversalArgs } from './base-universal.args';
|
||||
import { UniversalEntityInput } from './universal-entity.input';
|
||||
|
||||
@ArgsType()
|
||||
export class DeleteOneUniversalArgs extends BaseUniversalArgs {
|
||||
@Field(() => UniversalEntityInput, { nullable: true })
|
||||
where?: UniversalEntityInput;
|
||||
}
|
35
server/src/tenant/universal/args/find-many-universal.args.ts
Normal file
35
server/src/tenant/universal/args/find-many-universal.args.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { ArgsType, Field, Int } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
import { ConnectionArgs } from 'src/utils/pagination';
|
||||
|
||||
import { UniversalEntityInput } from './universal-entity.input';
|
||||
import { UniversalEntityOrderByRelationInput } from './universal-entity-order-by-relation.input';
|
||||
|
||||
@ArgsType()
|
||||
export class FindManyUniversalArgs extends ConnectionArgs {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
entity: string;
|
||||
|
||||
@Field(() => UniversalEntityInput, { nullable: true })
|
||||
where?: UniversalEntityInput;
|
||||
|
||||
@Field(() => UniversalEntityOrderByRelationInput, { nullable: true })
|
||||
orderBy?: UniversalEntityOrderByRelationInput;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
cursor?: UniversalEntityInput;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
take?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
skip?: number;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
distinct?: Array<string>;
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { BaseUniversalArgs } from './base-universal.args';
|
||||
import { UniversalEntityInput } from './universal-entity.input';
|
||||
|
||||
@ArgsType()
|
||||
export class FindUniqueUniversalArgs extends BaseUniversalArgs {
|
||||
@Field(() => UniversalEntityInput, { nullable: true })
|
||||
where?: UniversalEntityInput;
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
export enum TypeORMSortOrder {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
|
||||
registerEnumType(TypeORMSortOrder, {
|
||||
name: 'TypeORMSortOrder',
|
||||
description: undefined,
|
||||
});
|
||||
|
||||
@InputType()
|
||||
export class UniversalEntityOrderByRelationInput {
|
||||
@Field(() => TypeORMSortOrder, { nullable: true })
|
||||
id?: keyof typeof TypeORMSortOrder;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
data?: Record<string, keyof typeof TypeORMSortOrder>;
|
||||
|
||||
@Field(() => TypeORMSortOrder, { nullable: true })
|
||||
createdAt?: keyof typeof TypeORMSortOrder;
|
||||
|
||||
@Field(() => TypeORMSortOrder, { nullable: true })
|
||||
updatedAt?: keyof typeof TypeORMSortOrder;
|
||||
}
|
18
server/src/tenant/universal/args/universal-entity.input.ts
Normal file
18
server/src/tenant/universal/args/universal-entity.input.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Field, ID, InputType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
@InputType()
|
||||
export class UniversalEntityInput {
|
||||
@Field(() => ID, { nullable: true })
|
||||
id?: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
data?: Record<string, unknown>;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
createdAt?: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
updatedAt?: Date;
|
||||
}
|
13
server/src/tenant/universal/args/update-one-custom.args.ts
Normal file
13
server/src/tenant/universal/args/update-one-custom.args.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { BaseUniversalArgs } from './base-universal.args';
|
||||
import { UniversalEntityInput } from './universal-entity.input';
|
||||
|
||||
@ArgsType()
|
||||
export class UpdateOneCustomArgs extends BaseUniversalArgs {
|
||||
@Field(() => UniversalEntityInput, { nullable: false })
|
||||
data!: UniversalEntityInput;
|
||||
|
||||
@Field(() => UniversalEntityInput, { nullable: true })
|
||||
where?: UniversalEntityInput;
|
||||
}
|
25
server/src/tenant/universal/universal.entity.ts
Normal file
25
server/src/tenant/universal/universal.entity.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { Paginated } from 'src/utils/pagination';
|
||||
|
||||
@ObjectType()
|
||||
export class UniversalEntity {
|
||||
@Field(() => ID, { nullable: false })
|
||||
id!: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: false })
|
||||
data!: Record<string, unknown>;
|
||||
|
||||
@Field(() => Date, { nullable: false })
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: false })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class PaginatedUniversalEntity extends Paginated<UniversalEntity>(
|
||||
UniversalEntity,
|
||||
) {}
|
12
server/src/tenant/universal/universal.module.ts
Normal file
12
server/src/tenant/universal/universal.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DataSourceModule } from 'src/tenant/metadata/data-source/data-source.module';
|
||||
|
||||
import { UniversalService } from './universal.service';
|
||||
import { UniversalResolver } from './universal.resolver';
|
||||
|
||||
@Module({
|
||||
imports: [DataSourceModule],
|
||||
providers: [UniversalService, UniversalResolver],
|
||||
})
|
||||
export class UniversalModule {}
|
26
server/src/tenant/universal/universal.resolver.spec.ts
Normal file
26
server/src/tenant/universal/universal.resolver.spec.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { UniversalResolver } from './universal.resolver';
|
||||
import { UniversalService } from './universal.service';
|
||||
|
||||
describe('UniversalResolver', () => {
|
||||
let resolver: UniversalResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UniversalResolver,
|
||||
{
|
||||
provide: UniversalService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<UniversalResolver>(UniversalResolver);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
});
|
56
server/src/tenant/universal/universal.resolver.ts
Normal file
56
server/src/tenant/universal/universal.resolver.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
|
||||
import { Workspace } from '@prisma/client';
|
||||
|
||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
|
||||
|
||||
import { UniversalEntity, PaginatedUniversalEntity } from './universal.entity';
|
||||
import { UniversalService } from './universal.service';
|
||||
|
||||
import { FindManyUniversalArgs } from './args/find-many-universal.args';
|
||||
import { FindUniqueUniversalArgs } from './args/find-unique-universal.args';
|
||||
import { UpdateOneCustomArgs } from './args/update-one-custom.args';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Resolver(() => UniversalEntity)
|
||||
export class UniversalResolver {
|
||||
constructor(private readonly customService: UniversalService) {}
|
||||
|
||||
@Query(() => PaginatedUniversalEntity)
|
||||
findMany(
|
||||
@Args() args: FindManyUniversalArgs,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<PaginatedUniversalEntity> {
|
||||
return this.customService.findManyUniversal(args, workspace);
|
||||
}
|
||||
|
||||
@Query(() => UniversalEntity)
|
||||
findUnique(
|
||||
@Args() args: FindUniqueUniversalArgs,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<UniversalEntity | undefined> {
|
||||
return this.customService.findUniqueUniversal(args, workspace);
|
||||
}
|
||||
|
||||
@Query(() => UniversalEntity)
|
||||
updateOneCustom(@Args() args: UpdateOneCustomArgs): UniversalEntity {
|
||||
return {
|
||||
id: 'exampleId',
|
||||
data: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => UniversalEntity)
|
||||
deleteOneCustom(@Args() args: UpdateOneCustomArgs): UniversalEntity {
|
||||
return {
|
||||
id: 'exampleId',
|
||||
data: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
27
server/src/tenant/universal/universal.service.spec.ts
Normal file
27
server/src/tenant/universal/universal.service.spec.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { DataSourceService } from 'src/tenant/metadata/data-source/data-source.service';
|
||||
|
||||
import { UniversalService } from './universal.service';
|
||||
|
||||
describe('UniversalService', () => {
|
||||
let service: UniversalService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UniversalService,
|
||||
{
|
||||
provide: DataSourceService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UniversalService>(UniversalService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
100
server/src/tenant/universal/universal.service.ts
Normal file
100
server/src/tenant/universal/universal.service.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
|
||||
import { Workspace } from '@prisma/client';
|
||||
|
||||
import { DataSourceService } from 'src/tenant/metadata/data-source/data-source.service';
|
||||
import { findManyCursorConnection } from 'src/utils/pagination';
|
||||
|
||||
import { UniversalEntity, PaginatedUniversalEntity } from './universal.entity';
|
||||
import {
|
||||
getRawTypeORMOrderByClause,
|
||||
getRawTypeORMWhereClause,
|
||||
} from './universal.util';
|
||||
|
||||
import { FindManyUniversalArgs } from './args/find-many-universal.args';
|
||||
import { FindUniqueUniversalArgs } from './args/find-unique-universal.args';
|
||||
|
||||
@Injectable()
|
||||
export class UniversalService {
|
||||
constructor(private readonly dataSourceService: DataSourceService) {}
|
||||
|
||||
async findManyUniversal(
|
||||
args: FindManyUniversalArgs,
|
||||
workspace: Workspace,
|
||||
): Promise<PaginatedUniversalEntity> {
|
||||
await this.dataSourceService.createWorkspaceSchema(workspace.id);
|
||||
|
||||
const workspaceDataSource =
|
||||
await this.dataSourceService.connectToWorkspaceDataSource(workspace.id);
|
||||
|
||||
let query = workspaceDataSource
|
||||
?.createQueryBuilder()
|
||||
.select()
|
||||
.from(args.entity, args.entity);
|
||||
|
||||
if (!query) {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (query && args.where) {
|
||||
const { where, parameters } = getRawTypeORMWhereClause(
|
||||
args.entity,
|
||||
args.where,
|
||||
);
|
||||
|
||||
query = query.where(where, parameters);
|
||||
}
|
||||
|
||||
if (query && args.orderBy) {
|
||||
const orderBy = getRawTypeORMOrderByClause(args.entity, args.orderBy);
|
||||
|
||||
query = query.orderBy(orderBy);
|
||||
}
|
||||
|
||||
return findManyCursorConnection(query, args, {
|
||||
recordToEdge({ id, createdAt, updatedAt, ...data }) {
|
||||
return {
|
||||
node: {
|
||||
id,
|
||||
data,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findUniqueUniversal(
|
||||
args: FindUniqueUniversalArgs,
|
||||
workspace: Workspace,
|
||||
): Promise<UniversalEntity | undefined> {
|
||||
await this.dataSourceService.createWorkspaceSchema(workspace.id);
|
||||
|
||||
const workspaceDataSource =
|
||||
await this.dataSourceService.connectToWorkspaceDataSource(workspace.id);
|
||||
|
||||
let query = workspaceDataSource
|
||||
?.createQueryBuilder()
|
||||
.select()
|
||||
.from(args.entity, args.entity);
|
||||
|
||||
if (query && args.where) {
|
||||
const { where, parameters } = getRawTypeORMWhereClause(
|
||||
args.entity,
|
||||
args.where,
|
||||
);
|
||||
|
||||
query = query.where(where, parameters);
|
||||
}
|
||||
|
||||
const { id, createdAt, updatedAt, ...data } = await query?.getRawOne();
|
||||
|
||||
return {
|
||||
id,
|
||||
data,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
59
server/src/tenant/universal/universal.util.ts
Normal file
59
server/src/tenant/universal/universal.util.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { snakeCase } from 'src/utils/snake-case';
|
||||
|
||||
import { UniversalEntityInput } from './args/universal-entity.input';
|
||||
import {
|
||||
UniversalEntityOrderByRelationInput,
|
||||
TypeORMSortOrder,
|
||||
} from './args/universal-entity-order-by-relation.input';
|
||||
|
||||
export const getRawTypeORMWhereClause = (
|
||||
entity: string,
|
||||
where?: UniversalEntityInput | undefined,
|
||||
) => {
|
||||
if (!where) {
|
||||
return {
|
||||
where: '',
|
||||
parameters: {},
|
||||
};
|
||||
}
|
||||
|
||||
const { id, data, createdAt, updatedAt } = where;
|
||||
const flattenWhere: any = {
|
||||
...(id ? { id } : {}),
|
||||
...data,
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
where: Object.keys(flattenWhere)
|
||||
.map((key) => `${entity}.${snakeCase(key)} = :${key}`)
|
||||
.join(' AND '),
|
||||
parameters: flattenWhere,
|
||||
};
|
||||
};
|
||||
|
||||
export const getRawTypeORMOrderByClause = (
|
||||
entity: string,
|
||||
orderBy?: UniversalEntityOrderByRelationInput | undefined,
|
||||
) => {
|
||||
if (!orderBy) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { id, data, createdAt, updatedAt } = orderBy;
|
||||
const flattenWhere: any = {
|
||||
...(id ? { id } : {}),
|
||||
...data,
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
|
||||
const orderByClause: Record<string, TypeORMSortOrder> = {};
|
||||
|
||||
for (const key of Object.keys(flattenWhere)) {
|
||||
orderByClause[`${entity}.${snakeCase(key)}`] = flattenWhere[key];
|
||||
}
|
||||
|
||||
return orderByClause;
|
||||
};
|
162
server/src/utils/pagination/find-many-cursor-connection.ts
Normal file
162
server/src/utils/pagination/find-many-cursor-connection.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import {
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
ObjectLiteral,
|
||||
SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
|
||||
import { IEdge } from './interfaces/edge.interface';
|
||||
import { IConnectionArguments } from './interfaces/connection-arguments.interface';
|
||||
import { IOptions } from './interfaces/options.interface';
|
||||
import { IConnection } from './interfaces/connection.interface';
|
||||
import { validateArgs } from './utils/validate-args';
|
||||
import { mergeDefaultOptions } from './utils/default-options';
|
||||
import {
|
||||
isBackwardPagination,
|
||||
isForwardPagination,
|
||||
} from './utils/pagination-direction';
|
||||
import { encodeCursor, extractCursorKeyValue } from './utils/cursor';
|
||||
|
||||
/**
|
||||
* Override cursors options
|
||||
*/
|
||||
export async function findManyCursorConnection<
|
||||
Entity extends ObjectLiteral,
|
||||
Record = Entity,
|
||||
Cursor = { id: string },
|
||||
Node = Record,
|
||||
CustomEdge extends IEdge<Node> = IEdge<Node>,
|
||||
>(
|
||||
query: SelectQueryBuilder<Entity>,
|
||||
args: IConnectionArguments = {},
|
||||
initialOptions?: IOptions<Entity, Record, Cursor, Node, CustomEdge>,
|
||||
): Promise<IConnection<Node, CustomEdge>> {
|
||||
if (!validateArgs(args)) {
|
||||
throw new Error('Should never happen');
|
||||
}
|
||||
|
||||
const options = mergeDefaultOptions(initialOptions);
|
||||
const totalCountQuery = query.clone();
|
||||
const totalCount = await totalCountQuery.getCount();
|
||||
// Only to extract cursor shape
|
||||
const cursorKeys = Object.keys(options.getCursor(undefined) as any);
|
||||
|
||||
let records: Array<Record>;
|
||||
let hasNextPage: boolean;
|
||||
let hasPreviousPage: boolean;
|
||||
|
||||
// Add order by based on the cursor keys
|
||||
for (const key of cursorKeys) {
|
||||
query.addOrderBy(key, 'ASC');
|
||||
}
|
||||
|
||||
if (isForwardPagination(args)) {
|
||||
// Fetch one additional record to determine if there is a next page
|
||||
const take = args.first + 1;
|
||||
|
||||
// Extract cursor map based on the encoded cursor
|
||||
const cursorMap = extractCursorKeyValue(args.after, options);
|
||||
const skip = cursorMap ? 1 : undefined;
|
||||
|
||||
if (cursorMap) {
|
||||
const [keys, values] = cursorMap;
|
||||
|
||||
// Add `cursor` filter in where condition
|
||||
query.andWhere(
|
||||
keys.reduce((acc, key, index) => {
|
||||
return {
|
||||
...acc,
|
||||
[key]: MoreThanOrEqual(values[index]),
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
}
|
||||
|
||||
// Add `take` and `skip` to the query
|
||||
query.take(take).skip(skip);
|
||||
|
||||
// Fetch records
|
||||
records = await options.getRecords(query);
|
||||
|
||||
// See if we are "after" another record, indicating a previous page
|
||||
hasPreviousPage = !!args.after;
|
||||
|
||||
// See if we have an additional record, indicating a next page
|
||||
hasNextPage = records.length > args.first;
|
||||
|
||||
// Remove the extra record (last element) from the results
|
||||
if (hasNextPage) records.pop();
|
||||
} else if (isBackwardPagination(args)) {
|
||||
// Fetch one additional record to determine if there is a previous page
|
||||
const take = -1 * (args.last + 1);
|
||||
|
||||
// Extract cursor map based on the encoded cursor
|
||||
const cursorMap = extractCursorKeyValue(args.before, options);
|
||||
const skip = cursorMap ? 1 : undefined;
|
||||
|
||||
if (cursorMap) {
|
||||
const [keys, values] = cursorMap;
|
||||
|
||||
// Add `cursor` filter in where condition
|
||||
query.andWhere(
|
||||
keys.reduce((acc, key, index) => {
|
||||
return {
|
||||
...acc,
|
||||
[key]: LessThanOrEqual(values[index]),
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
}
|
||||
|
||||
// Add `take` and `skip` to the query
|
||||
query.take(take).skip(skip);
|
||||
|
||||
// Fetch records
|
||||
records = await options.getRecords(query);
|
||||
|
||||
// See if we are "before" another record, indicating a next page
|
||||
hasNextPage = !!args.before;
|
||||
|
||||
// See if we have an additional record, indicating a previous page
|
||||
hasPreviousPage = records.length > args.last;
|
||||
|
||||
// Remove the extra record (first element) from the results
|
||||
if (hasPreviousPage) records.shift();
|
||||
} else {
|
||||
// Fetch records
|
||||
records = await options.getRecords(query);
|
||||
|
||||
hasNextPage = false;
|
||||
hasPreviousPage = false;
|
||||
}
|
||||
|
||||
// The cursors are always the first & last elements of the result set
|
||||
const startCursor =
|
||||
records.length > 0 ? encodeCursor(records[0], options) : undefined;
|
||||
const endCursor =
|
||||
records.length > 0
|
||||
? encodeCursor(records[records.length - 1], options)
|
||||
: undefined;
|
||||
|
||||
// Allow the recordToEdge function to return a custom edge type which will be inferred
|
||||
type EdgeExtended = typeof options.recordToEdge extends (
|
||||
record: Record,
|
||||
) => infer X
|
||||
? X extends CustomEdge
|
||||
? X & { cursor: string }
|
||||
: CustomEdge
|
||||
: CustomEdge;
|
||||
|
||||
const edges = records.map((record) => {
|
||||
return {
|
||||
...options.recordToEdge(record),
|
||||
cursor: encodeCursor(record, options),
|
||||
} as EdgeExtended;
|
||||
});
|
||||
|
||||
return {
|
||||
edges,
|
||||
pageInfo: { hasNextPage, hasPreviousPage, startCursor, endCursor },
|
||||
totalCount,
|
||||
};
|
||||
}
|
2
server/src/utils/pagination/index.ts
Normal file
2
server/src/utils/pagination/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ConnectionCursor, ConnectionArgs, Paginated } from './paginated';
|
||||
export { findManyCursorConnection } from './find-many-cursor-connection';
|
@ -0,0 +1,15 @@
|
||||
export interface IConnectionArguments {
|
||||
first?: number | null;
|
||||
after?: string | null;
|
||||
last?: number | null;
|
||||
before?: string | null;
|
||||
}
|
||||
|
||||
export type ConnectionArgumentsUnion =
|
||||
| ForwardPaginationArguments
|
||||
| BackwardPaginationArguments
|
||||
| NoPaginationArguments;
|
||||
|
||||
export type ForwardPaginationArguments = { first: number; after?: string };
|
||||
export type BackwardPaginationArguments = { last: number; before?: string };
|
||||
export type NoPaginationArguments = Record<string, unknown>;
|
@ -0,0 +1 @@
|
||||
export type ConnectionCursor = string;
|
@ -0,0 +1,8 @@
|
||||
import { IEdge } from './edge.interface';
|
||||
import { IPageInfo } from './page-info.interface';
|
||||
|
||||
export interface IConnection<T, CustomEdge extends IEdge<T> = IEdge<T>> {
|
||||
edges: Array<CustomEdge>;
|
||||
pageInfo: IPageInfo;
|
||||
totalCount: number;
|
||||
}
|
4
server/src/utils/pagination/interfaces/edge.interface.ts
Normal file
4
server/src/utils/pagination/interfaces/edge.interface.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface IEdge<T> {
|
||||
cursor: string;
|
||||
node: T;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export interface IFindManyArguments<Cursor> {
|
||||
cursor?: Cursor;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
}
|
19
server/src/utils/pagination/interfaces/options.interface.ts
Normal file
19
server/src/utils/pagination/interfaces/options.interface.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { GraphQLResolveInfo } from 'graphql';
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { IEdge } from './edge.interface';
|
||||
|
||||
export interface IOptions<
|
||||
Entity extends ObjectLiteral,
|
||||
Record,
|
||||
Cursor,
|
||||
Node,
|
||||
CustomEdge extends IEdge<Node>,
|
||||
> {
|
||||
getRecords?: (args: SelectQueryBuilder<Entity>) => Promise<Record[]>;
|
||||
getCursor?: (record: Record | undefined) => Cursor;
|
||||
encodeCursor?: (cursor: Cursor) => string;
|
||||
decodeCursor?: (cursorString: string) => Cursor;
|
||||
recordToEdge?: (record: Record) => Omit<CustomEdge, 'cursor'>;
|
||||
resolveInfo?: GraphQLResolveInfo | null;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export interface IPageInfo {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor?: string;
|
||||
endCursor?: string;
|
||||
}
|
19
server/src/utils/pagination/page-info.ts
Normal file
19
server/src/utils/pagination/page-info.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IPageInfo } from './interfaces/page-info.interface';
|
||||
import { ConnectionCursor } from './interfaces/connection-cursor.type';
|
||||
|
||||
@ObjectType({ isAbstract: true })
|
||||
export class PageInfo implements IPageInfo {
|
||||
@Field({ nullable: true })
|
||||
public startCursor!: ConnectionCursor;
|
||||
|
||||
@Field({ nullable: true })
|
||||
public endCursor!: ConnectionCursor;
|
||||
|
||||
@Field(() => Boolean)
|
||||
public hasPreviousPage!: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
public hasNextPage!: boolean;
|
||||
}
|
80
server/src/utils/pagination/paginated.ts
Normal file
80
server/src/utils/pagination/paginated.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { Type } from '@nestjs/common';
|
||||
import { ArgsType, Directive, Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { PageInfo } from './page-info';
|
||||
|
||||
import { IConnectionArguments } from './interfaces/connection-arguments.interface';
|
||||
import { IConnection } from './interfaces/connection.interface';
|
||||
import { IEdge } from './interfaces/edge.interface';
|
||||
import { IPageInfo } from './interfaces/page-info.interface';
|
||||
|
||||
export type ConnectionCursor = string;
|
||||
|
||||
/**
|
||||
* ConnectionArguments
|
||||
*/
|
||||
@ArgsType()
|
||||
export class ConnectionArgs implements IConnectionArguments {
|
||||
@Field({ nullable: true, description: 'Paginate before opaque cursor' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
public before?: ConnectionCursor;
|
||||
|
||||
@Field({ nullable: true, description: 'Paginate after opaque cursor' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
public after?: ConnectionCursor;
|
||||
|
||||
@Field({ nullable: true, description: 'Paginate first' })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
public first?: number;
|
||||
|
||||
@Field({ nullable: true, description: 'Paginate last' })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
public last?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated graphQL class inheritance
|
||||
*/
|
||||
export function Paginated<T>(classRef: Type<T>): Type<IConnection<T>> {
|
||||
@ObjectType(`${classRef.name}Edge`, { isAbstract: true })
|
||||
class Edge implements IEdge<T> {
|
||||
public name = `${classRef.name}Edge`;
|
||||
|
||||
@Field({ nullable: true })
|
||||
public cursor!: ConnectionCursor;
|
||||
|
||||
@Field(() => classRef, { nullable: true })
|
||||
@Directive(`@cacheControl(inheritMaxAge: true)`)
|
||||
public node!: T;
|
||||
}
|
||||
|
||||
@ObjectType(`${classRef.name}Connection`, { isAbstract: true })
|
||||
class Connection implements IConnection<T> {
|
||||
public name = `${classRef.name}Connection`;
|
||||
|
||||
@Field(() => [Edge], { nullable: true })
|
||||
@Directive(`@cacheControl(inheritMaxAge: true)`)
|
||||
public edges!: IEdge<T>[];
|
||||
|
||||
@Field(() => PageInfo, { nullable: true })
|
||||
@Directive(`@cacheControl(inheritMaxAge: true)`)
|
||||
public pageInfo!: IPageInfo;
|
||||
|
||||
@Field()
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
return Connection as Type<IConnection<T>>;
|
||||
}
|
||||
|
||||
// export const encodeCursor = <Cursor>(cursor: Cursor) =>
|
||||
// Buffer.from(JSON.stringify(cursor)).toString('base64');
|
||||
|
||||
// export const decodeCursor = <Cursor>(cursor: string) =>
|
||||
// JSON.parse(Buffer.from(cursor, 'base64').toString('ascii')) as Cursor;
|
54
server/src/utils/pagination/utils/cursor.ts
Normal file
54
server/src/utils/pagination/utils/cursor.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { IEdge } from 'src/utils/pagination/interfaces/edge.interface';
|
||||
|
||||
import { MergedOptions } from './default-options';
|
||||
|
||||
export function decodeCursor<
|
||||
Entity extends ObjectLiteral,
|
||||
Record,
|
||||
Cursor,
|
||||
Node,
|
||||
CustomEdge extends IEdge<Node>,
|
||||
>(
|
||||
connectionCursor: string | undefined,
|
||||
options: MergedOptions<Entity, Record, Cursor, Node, CustomEdge>,
|
||||
): Cursor | undefined {
|
||||
if (!connectionCursor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return options.decodeCursor(connectionCursor);
|
||||
}
|
||||
|
||||
export function encodeCursor<
|
||||
Entity extends ObjectLiteral,
|
||||
Record,
|
||||
Cursor,
|
||||
Node,
|
||||
CustomEdge extends IEdge<Node>,
|
||||
>(
|
||||
record: Record,
|
||||
options: MergedOptions<Entity, Record, Cursor, Node, CustomEdge>,
|
||||
): string {
|
||||
return options.encodeCursor(options.getCursor(record));
|
||||
}
|
||||
|
||||
export function extractCursorKeyValue<
|
||||
Entity extends ObjectLiteral,
|
||||
Record,
|
||||
Cursor,
|
||||
Node,
|
||||
CustomEdge extends IEdge<Node>,
|
||||
>(
|
||||
connectionCursor: string | undefined,
|
||||
options: MergedOptions<Entity, Record, Cursor, Node, CustomEdge>,
|
||||
): [string[], unknown[]] | undefined {
|
||||
const cursor = decodeCursor(connectionCursor, options);
|
||||
|
||||
if (!cursor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [Object.keys(cursor), Object.values(cursor)];
|
||||
}
|
42
server/src/utils/pagination/utils/default-options.ts
Normal file
42
server/src/utils/pagination/utils/default-options.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { IEdge } from 'src/utils/pagination/interfaces/edge.interface';
|
||||
import { IOptions } from 'src/utils/pagination/interfaces/options.interface';
|
||||
|
||||
export type MergedOptions<
|
||||
Entity extends ObjectLiteral,
|
||||
Record,
|
||||
Cursor,
|
||||
Node,
|
||||
CustomEdge extends IEdge<Node>,
|
||||
> = Required<IOptions<Entity, Record, Cursor, Node, CustomEdge>>;
|
||||
|
||||
export function mergeDefaultOptions<
|
||||
Entity extends ObjectLiteral,
|
||||
Record,
|
||||
Cursor,
|
||||
Node,
|
||||
CustomEdge extends IEdge<Node>,
|
||||
>(
|
||||
pOptions?: IOptions<Entity, Record, Cursor, Node, CustomEdge>,
|
||||
): MergedOptions<Entity, Record, Cursor, Node, CustomEdge> {
|
||||
return {
|
||||
getRecords: async (query) => {
|
||||
return query.getRawMany();
|
||||
},
|
||||
getCursor: (record: Record | undefined) =>
|
||||
({ id: (record as unknown as { id: string })?.id } as unknown as Cursor),
|
||||
encodeCursor: (cursor: Cursor) =>
|
||||
Buffer.from((cursor as unknown as { id: string }).id.toString()).toString(
|
||||
'base64',
|
||||
),
|
||||
decodeCursor: (cursorString: string) =>
|
||||
({
|
||||
id: Buffer.from(cursorString, 'base64').toString(),
|
||||
} as unknown as Cursor),
|
||||
recordToEdge: (record: Record) =>
|
||||
({ node: record } as unknown as Omit<CustomEdge, 'cursor'>),
|
||||
resolveInfo: null,
|
||||
...pOptions,
|
||||
};
|
||||
}
|
17
server/src/utils/pagination/utils/pagination-direction.ts
Normal file
17
server/src/utils/pagination/utils/pagination-direction.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import {
|
||||
BackwardPaginationArguments,
|
||||
ConnectionArgumentsUnion,
|
||||
ForwardPaginationArguments,
|
||||
} from 'src/utils/pagination/interfaces/connection-arguments.interface';
|
||||
|
||||
export function isForwardPagination(
|
||||
args: ConnectionArgumentsUnion,
|
||||
): args is ForwardPaginationArguments {
|
||||
return 'first' in args && args.first != null;
|
||||
}
|
||||
|
||||
export function isBackwardPagination(
|
||||
args: ConnectionArgumentsUnion,
|
||||
): args is BackwardPaginationArguments {
|
||||
return 'last' in args && args.last != null;
|
||||
}
|
38
server/src/utils/pagination/utils/validate-args.ts
Normal file
38
server/src/utils/pagination/utils/validate-args.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import {
|
||||
ConnectionArgumentsUnion,
|
||||
IConnectionArguments,
|
||||
} from 'src/utils/pagination/interfaces/connection-arguments.interface';
|
||||
|
||||
export function validateArgs(
|
||||
args: IConnectionArguments,
|
||||
): args is ConnectionArgumentsUnion {
|
||||
// Only one of `first` and `last` / `after` and `before` can be set
|
||||
if (args.first != null && args.last != null) {
|
||||
throw new Error('Only one of "first" and "last" can be set');
|
||||
}
|
||||
|
||||
if (args.after != null && args.before != null) {
|
||||
throw new Error('Only one of "after" and "before" can be set');
|
||||
}
|
||||
|
||||
// If `after` is set, `first` has to be set
|
||||
if (args.after != null && args.first == null) {
|
||||
throw new Error('"after" needs to be used with "first"');
|
||||
}
|
||||
|
||||
// If `before` is set, `last` has to be set
|
||||
if (args.before != null && args.last == null) {
|
||||
throw new Error('"before" needs to be used with "last"');
|
||||
}
|
||||
|
||||
// `first` and `last` have to be positive
|
||||
if (args.first != null && args.first <= 0) {
|
||||
throw new Error('"first" has to be positive');
|
||||
}
|
||||
|
||||
if (args.last != null && args.last <= 0) {
|
||||
throw new Error('"last" has to be positive');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
26
server/src/utils/snake-case.ts
Normal file
26
server/src/utils/snake-case.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import isObject from 'lodash.isobject';
|
||||
import lodashSnakeCase from 'lodash.snakecase';
|
||||
import { SnakeCase, SnakeCasedPropertiesDeep } from 'type-fest';
|
||||
|
||||
export const snakeCase = <T>(text: T) =>
|
||||
lodashSnakeCase(text as unknown as string) as SnakeCase<T>;
|
||||
|
||||
export const snakeCaseDeep = <T>(value: T): SnakeCasedPropertiesDeep<T> => {
|
||||
// Check if it's an array
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(snakeCaseDeep) as SnakeCasedPropertiesDeep<T>;
|
||||
}
|
||||
|
||||
// Check if it's an object
|
||||
if (isObject(value)) {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
for (const key in value) {
|
||||
result[snakeCase(key)] = snakeCaseDeep(value[key]);
|
||||
}
|
||||
|
||||
return result as SnakeCasedPropertiesDeep<T>;
|
||||
}
|
||||
|
||||
return value as SnakeCasedPropertiesDeep<T>;
|
||||
};
|
@ -1710,7 +1710,7 @@
|
||||
resolved "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz"
|
||||
integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==
|
||||
|
||||
"@mapbox/node-pre-gyp@^1.0.10":
|
||||
"@mapbox/node-pre-gyp@^1.0.11":
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa"
|
||||
integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==
|
||||
@ -2959,6 +2959,13 @@
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash.snakecase@^4.1.7":
|
||||
version "4.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash.snakecase/-/lodash.snakecase-4.1.7.tgz#2a1ca7cbc08b63e7c3708f6291222e69b0d3216d"
|
||||
integrity sha512-nv9M+JJokFyfZ9QmaWVXZu2DfT40K0GictZaA8SwXczp3oCzMkjp7PtvUBQyvdoG9SnlCpoRXZDIVwQRzJbd9A==
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash@*":
|
||||
version "4.14.195"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632"
|
||||
@ -3888,12 +3895,12 @@ base64url@3.x.x:
|
||||
resolved "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz"
|
||||
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
|
||||
|
||||
bcrypt@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.0.tgz#bbb27665dbc400480a524d8991ac7434e8529e17"
|
||||
integrity sha512-RHBS7HI5N5tEnGTmtR/pppX0mmDSBpQ4aCBsj7CEQfYXDcO74A8sIBYcJMuCsis2E81zDxeENYhv66oZwLiA+Q==
|
||||
bcrypt@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2"
|
||||
integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==
|
||||
dependencies:
|
||||
"@mapbox/node-pre-gyp" "^1.0.10"
|
||||
"@mapbox/node-pre-gyp" "^1.0.11"
|
||||
node-addon-api "^5.0.0"
|
||||
|
||||
binary-extensions@^2.0.0:
|
||||
@ -6768,6 +6775,11 @@ lodash.omit@4.5.0:
|
||||
resolved "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz"
|
||||
integrity sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg==
|
||||
|
||||
lodash.snakecase@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d"
|
||||
integrity sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==
|
||||
|
||||
lodash.sortby@^4.7.0:
|
||||
version "4.7.0"
|
||||
resolved "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz"
|
||||
@ -8550,9 +8562,9 @@ tar-stream@^2.1.4, tar-stream@^2.2.0:
|
||||
readable-stream "^3.1.1"
|
||||
|
||||
tar@^6.1.11:
|
||||
version "6.1.15"
|
||||
resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.15.tgz#c9738b0b98845a3b344d334b8fa3041aaba53a69"
|
||||
integrity sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73"
|
||||
integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==
|
||||
dependencies:
|
||||
chownr "^2.0.0"
|
||||
fs-minipass "^2.0.0"
|
||||
|
Loading…
Reference in New Issue
Block a user