diff --git a/schema.gql b/schema.gql index 126ad4ae6..5b7e84b37 100644 --- a/schema.gql +++ b/schema.gql @@ -57,6 +57,7 @@ input AddLikeArgs { type AnalyticsAggregateValue { avg: Float count: Float + marketplacesData: [MarketplaceData] max: Float min: Float series: String @@ -839,6 +840,11 @@ type Marketplace { url: String! } +type MarketplaceData { + key: String! + value: Float +} + type MarketplaceEdge { cursor: String node: Marketplace diff --git a/src/common/persistence/timescaledb/analytics-data.getter.service.ts b/src/common/persistence/timescaledb/analytics-data.getter.service.ts index 5d37c40e4..4ca106b0f 100644 --- a/src/common/persistence/timescaledb/analytics-data.getter.service.ts +++ b/src/common/persistence/timescaledb/analytics-data.getter.service.ts @@ -3,7 +3,7 @@ import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { computeTimeInterval } from 'src/utils/analytics.utils'; import { AnalyticsArgs } from './entities/analytics.query'; -import { FloorPriceDaily, SumDaily } from './entities/sum-daily.entity'; +import { FloorPriceDaily, SumDaily, SumMarketplaceDaily } from './entities/sum-daily.entity'; import { AnalyticsAggregateValue } from 'src/modules/analytics/models/analytics-aggregate-value'; @Injectable() @@ -11,6 +11,8 @@ export class AnalyticsDataGetterService { constructor( @InjectRepository(SumDaily, 'timescaledb') private readonly sumDaily: Repository, + @InjectRepository(SumMarketplaceDaily, 'timescaledb') + private readonly sumMarketplaceDaily: Repository, @InjectRepository(FloorPriceDaily, 'timescaledb') private readonly floorPriceDaily: Repository, ) {} @@ -37,15 +39,18 @@ export class AnalyticsDataGetterService { return [[new AnalyticsAggregateValue({ value: 0, series: series })], 1]; } - return [response?.map((row) => AnalyticsAggregateValue.fromTimescaleObjext(row)) ?? [], count ?? 0]; + return [response?.map((row) => AnalyticsAggregateValue.fromTimescaleObject(row)) ?? [], count ?? 0]; } - async getVolumeData({ time, series, metric, start }: AnalyticsArgs): Promise { + async getVolumeDataWithMarketplaces({ time, series, metric, start }: AnalyticsArgs): Promise { const [startDate, endDate] = computeTimeInterval(time, start); - const query = await this.sumDaily + const query = await this.sumMarketplaceDaily .createQueryBuilder() .select("time_bucket_gapfill('1 day', time) as timestamp") .addSelect('sum(sum) as value') + .addSelect( + 'sum(sum) as value, sum(xoxno) as xoxno, sum(frameit) as frameit, sum(elrondapes) as elrondapes, sum(deadrare) as deadrare, sum(hoghomies) as hoghomies, sum(elrondnftswap) as elrondnftswap, sum(aquaverse) as aquaverse, sum(holoride) as holoride, sum(eneftor) as eneftor,sum(ici) as ici ', + ) .where('key = :metric', { metric }) .andWhere('series = :series', { series }) .andWhere(endDate ? 'time BETWEEN :startDate AND :endDate' : 'time >= :startDate', { startDate, endDate }) @@ -53,7 +58,7 @@ export class AnalyticsDataGetterService { .groupBy('timestamp') .getRawMany(); - return query?.map((row) => AnalyticsAggregateValue.fromTimescaleObjext(row)) ?? []; + return query?.map((row) => AnalyticsAggregateValue.fromTimescaleObjectWithMarketplaces(row)) ?? []; } async getFloorPriceData({ time, series, metric, start }: AnalyticsArgs): Promise { @@ -69,6 +74,6 @@ export class AnalyticsDataGetterService { .groupBy('timestamp') .getRawMany(); - return query?.map((row) => AnalyticsAggregateValue.fromTimescaleObjext(row)) ?? []; + return query?.map((row) => AnalyticsAggregateValue.fromTimescaleObject(row)) ?? []; } } diff --git a/src/common/persistence/timescaledb/entities/sum-daily.entity.ts b/src/common/persistence/timescaledb/entities/sum-daily.entity.ts index f608bc38a..5dbdb6557 100644 --- a/src/common/persistence/timescaledb/entities/sum-daily.entity.ts +++ b/src/common/persistence/timescaledb/entities/sum-daily.entity.ts @@ -31,6 +31,67 @@ export class SumDaily { } } +@ViewEntity({ + expression: ` + SELECT + time_bucket('1 day', timestamp) AS time, series, key, + SUM(CASE WHEN "marketplaceKey" ='frameit' THEN value ELSE 0 END) as frameit, + SUM(CASE WHEN "marketplaceKey" ='xoxno' THEN value ELSE 0 END) as xoxno, + SUM(CASE WHEN "marketplaceKey" ='elrondapes' THEN value ELSE 0 END) as elrondapes, + SUM(CASE WHEN "marketplaceKey" ='deadrare' THEN value ELSE 0 END) as deadrare, + SUM(CASE WHEN "marketplaceKey" ='hoghomies' THEN value ELSE 0 END) as hoghomies, + SUM(CASE WHEN "marketplaceKey" ='elrondnftswap' THEN value ELSE 0 END) as elrondnftswap, + SUM(CASE WHEN "marketplaceKey" ='aquaverse' THEN value ELSE 0 END) as aquaverse, + SUM(CASE WHEN "marketplaceKey" ='holoride' THEN value ELSE 0 END) as holoride, + SUM(CASE WHEN "marketplaceKey" ='eneftor' THEN value ELSE 0 END) as eneftor, + SUM(CASE WHEN "marketplaceKey" ='ici' THEN value ELSE 0 END) as ici, + sum(value) AS sum + FROM "hyper_nfts_analytics" + WHERE key = 'volumeUSD' + GROUP BY time, series, key; + `, + materialized: true, + name: 'nfts_sum_marketplace_daily', +}) +export class SumMarketplaceDaily { + @ViewColumn() + @PrimaryColumn() + time: Date = new Date(); + + @ViewColumn() + sum = '0'; + @ViewColumn() + frameit = '0'; + @ViewColumn() + xoxno = '0'; + @ViewColumn() + elrondapes = '0'; + @ViewColumn() + deadrare = '0'; + @ViewColumn() + hoghomies = '0'; + @ViewColumn() + elrondnftswap = '0'; + @ViewColumn() + aquaverse = '0'; + @ViewColumn() + holoride = '0'; + @ViewColumn() + eneftor = '0'; + @ViewColumn() + ici = '0'; + + @ViewColumn() + series?: string; + + @ViewColumn() + key?: string; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + @ViewEntity({ expression: ` SELECT diff --git a/src/common/persistence/timescaledb/migrations/1695294912071-AddDailyViewWithMarketplaceInfo.ts b/src/common/persistence/timescaledb/migrations/1695294912071-AddDailyViewWithMarketplaceInfo.ts new file mode 100644 index 000000000..e567f4084 --- /dev/null +++ b/src/common/persistence/timescaledb/migrations/1695294912071-AddDailyViewWithMarketplaceInfo.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDailyViewWithMarketplaceInfo1695294912071 implements MigrationInterface { + name = 'AddDailyViewWithMarketplaceInfo1695294912071'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE MATERIALIZED VIEW "nfts_sum_marketplace_daily" WITH (timescaledb.continuous) AS + SELECT + time_bucket('1 day', timestamp) AS time, series, key, + SUM(CASE WHEN "marketplaceKey" ='frameit' THEN value ELSE 0 END) as frameit, + SUM(CASE WHEN "marketplaceKey" ='xoxno' THEN value ELSE 0 END) as xoxno, + SUM(CASE WHEN "marketplaceKey" ='elrondapes' THEN value ELSE 0 END) as elrondapes, + SUM(CASE WHEN "marketplaceKey" ='deadrare' THEN value ELSE 0 END) as deadrare, + SUM(CASE WHEN "marketplaceKey" ='hoghomies' THEN value ELSE 0 END) as hoghomies, + SUM(CASE WHEN "marketplaceKey" ='elrondnftswap' THEN value ELSE 0 END) as elrondnftswap, + SUM(CASE WHEN "marketplaceKey" ='aquaverse' THEN value ELSE 0 END) as aquaverse, + SUM(CASE WHEN "marketplaceKey" ='holoride' THEN value ELSE 0 END) as holoride, + SUM(CASE WHEN "marketplaceKey" ='eneftor' THEN value ELSE 0 END) as eneftor, + SUM(CASE WHEN "marketplaceKey" ='ici' THEN value ELSE 0 END) as ici, + sum(value) AS sum + FROM "hyper_nfts_analytics" + WHERE key = 'volumeUSD' + GROUP BY time, series, key; + `); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'MATERIALIZED_VIEW', + 'nfts_sum_marketplace_daily', + "SELECT\n time_bucket('1 day', timestamp) AS time, series, key,\n SUM(CASE WHEN \"marketplaceKey\" ='frameit' THEN value ELSE 0 END) as frameit,\n SUM(CASE WHEN \"marketplaceKey\" ='xoxno' THEN value ELSE 0 END) as xoxno,\n SUM(CASE WHEN \"marketplaceKey\" ='elrondapes' THEN value ELSE 0 END) as elrondapes,\n SUM(CASE WHEN \"marketplaceKey\" ='deadrare' THEN value ELSE 0 END) as deadrare,\n SUM(CASE WHEN \"marketplaceKey\" ='hoghomies' THEN value ELSE 0 END) as hoghomies,\n SUM(CASE WHEN \"marketplaceKey\" ='elrondnftswap' THEN value ELSE 0 END) as elrondnftswap,\n SUM(CASE WHEN \"marketplaceKey\" ='aquaverse' THEN value ELSE 0 END) as aquaverse,\n SUM(CASE WHEN \"marketplaceKey\" ='holoride' THEN value ELSE 0 END) as holoride,\n SUM(CASE WHEN \"marketplaceKey\" ='eneftor' THEN value ELSE 0 END) as eneftor,\n SUM(CASE WHEN \"marketplaceKey\" ='ici' THEN value ELSE 0 END) as ici,\n sum(value) AS sum\n FROM \"hyper_nfts_analytics\"\n WHERE key = 'volumeUSD'\n GROUP BY time, series, key;", + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, [ + 'MATERIALIZED_VIEW', + 'nfts_sum_marketplace_daily', + 'public', + ]); + await queryRunner.query(`DROP MATERIALIZED VIEW "nfts_sum_marketplace_daily"`); + } +} diff --git a/src/common/persistence/timescaledb/tests/analytics-data.getter.spec.ts b/src/common/persistence/timescaledb/tests/analytics-data.getter.spec.ts index 922fe2629..134b4d8ca 100644 --- a/src/common/persistence/timescaledb/tests/analytics-data.getter.spec.ts +++ b/src/common/persistence/timescaledb/tests/analytics-data.getter.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AnalyticsDataGetterService } from '../analytics-data.getter.service'; -import { FloorPriceDaily, SumDaily } from '../entities/sum-daily.entity'; +import { FloorPriceDaily, SumDaily, SumMarketplaceDaily } from '../entities/sum-daily.entity'; import { getRepositoryToken } from '@nestjs/typeorm'; import { AnalyticsAggregateValue } from 'src/modules/analytics/models/analytics-aggregate-value'; @@ -24,6 +24,11 @@ describe('Analytics Data Getter Service', () => { provide: getRepositoryToken(SumDaily, 'timescaledb'), useFactory: () => ({}), }, + { + provide: getRepositoryToken(SumMarketplaceDaily, 'timescaledb'), + useFactory: () => ({}), + }, + { provide: getRepositoryToken(FloorPriceDaily, 'timescaledb'), useFactory: () => ({}), @@ -175,10 +180,10 @@ describe('Analytics Data Getter Service', () => { }); }); - describe('getVolumeData', () => { + describe('getVolumeDataWithMarketplaces', () => { it('returns empty list when no data for specific collection', async () => { const getRawMany = jest.fn().mockReturnValueOnce([]); - const sumDailyRepository = module.get(getRepositoryToken(SumDaily, 'timescaledb')); + const sumDailyRepository = module.get(getRepositoryToken(SumMarketplaceDaily, 'timescaledb')); sumDailyRepository.createQueryBuilder = jest.fn(() => ({ select, offset, @@ -190,7 +195,7 @@ describe('Analytics Data Getter Service', () => { orderBy, groupBy, })); - const result = await service.getVolumeData({ + const result = await service.getVolumeDataWithMarketplaces({ time: '10d', metric: 'test', series: 'test', @@ -209,7 +214,7 @@ describe('Analytics Data Getter Service', () => { new AnalyticsAggregateValue({ value: 2, series: 'test' }), new AnalyticsAggregateValue({ value: 121, series: 'test' }), ]; - const sumDailyRepository = module.get(getRepositoryToken(SumDaily, 'timescaledb')); + const sumDailyRepository = module.get(getRepositoryToken(SumMarketplaceDaily, 'timescaledb')); sumDailyRepository.createQueryBuilder = jest.fn(() => ({ select, offset, @@ -222,7 +227,7 @@ describe('Analytics Data Getter Service', () => { groupBy, })); - const response = await service.getVolumeData({ + const response = await service.getVolumeDataWithMarketplaces({ time: '10d', metric: 'test', series: 'test', diff --git a/src/common/persistence/timescaledb/timescaledb.module.ts b/src/common/persistence/timescaledb/timescaledb.module.ts index cdb06828b..d934e6a40 100644 --- a/src/common/persistence/timescaledb/timescaledb.module.ts +++ b/src/common/persistence/timescaledb/timescaledb.module.ts @@ -6,7 +6,7 @@ import { ApiConfigService } from 'src/modules/common/api-config/api.config.servi import { AnalyticsDataGetterService } from './analytics-data.getter.service'; import { AnalyticsDataSetterService } from './analytics-data.setter.service'; import { XNftsAnalyticsEntity } from './entities/analytics.entity'; -import { FloorPriceDaily, SumDaily } from './entities/sum-daily.entity'; +import { FloorPriceDaily, SumDaily, SumMarketplaceDaily } from './entities/sum-daily.entity'; import { SumWeekly } from './entities/sum-weekly.entity'; @Module({ @@ -34,7 +34,7 @@ import { SumWeekly } from './entities/sum-weekly.entity'; }), inject: [ApiConfigService], }), - TypeOrmModule.forFeature([XNftsAnalyticsEntity, SumDaily, SumWeekly, FloorPriceDaily], 'timescaledb'), + TypeOrmModule.forFeature([XNftsAnalyticsEntity, SumDaily, SumMarketplaceDaily, SumWeekly, FloorPriceDaily], 'timescaledb'), ], providers: [AnalyticsDataGetterService, AnalyticsDataSetterService], exports: [AnalyticsDataGetterService, AnalyticsDataSetterService], diff --git a/src/modules/analytics/analytics.getter.service.ts b/src/modules/analytics/analytics.getter.service.ts index 0152a10f7..119ad2578 100644 --- a/src/modules/analytics/analytics.getter.service.ts +++ b/src/modules/analytics/analytics.getter.service.ts @@ -19,7 +19,7 @@ export class AnalyticsGetterService { return await this.cacheService.getOrSet( cacheKey, () => - this.analyticsQuery.getVolumeData({ + this.analyticsQuery.getVolumeDataWithMarketplaces({ series, metric, time, diff --git a/src/modules/analytics/models/analytics-aggregate-value.ts b/src/modules/analytics/models/analytics-aggregate-value.ts index 9108ce720..7836e37c1 100644 --- a/src/modules/analytics/models/analytics-aggregate-value.ts +++ b/src/modules/analytics/models/analytics-aggregate-value.ts @@ -1,4 +1,4 @@ -import { Field, Float, ObjectType } from '@nestjs/graphql'; +import { Field, Float, Int, ObjectType } from '@nestjs/graphql'; import * as moment from 'moment'; @ObjectType() @@ -23,6 +23,8 @@ export class AnalyticsAggregateValue { @Field(() => Float, { nullable: true }) avg?: number; + @Field(() => [MarketplaceData], { nullable: 'itemsAndList' }) + marketplacesData: MarketplaceData[]; constructor(init?: Partial) { Object.assign(this, init); @@ -39,12 +41,55 @@ export class AnalyticsAggregateValue { avg: row.avg ?? 0, }); } + static fromTimescaleObject(row: any) { + return new AnalyticsAggregateValue({ + series: row.series, + time: moment.utc(row.timestamp ?? row.time).format('yyyy-MM-DD HH:mm:ss'), + value: row.value ?? 0, + }); + } + + static fromTimescaleObjectWithMarketplaces(row: any) { + const rowProperties = proxiedPropertiesOf(row); - static fromTimescaleObjext(row: any) { return new AnalyticsAggregateValue({ series: row.series, time: moment.utc(row.timestamp ?? row.time).format('yyyy-MM-DD HH:mm:ss'), value: row.value ?? 0, + marketplacesData: [ + new MarketplaceData({ key: rowProperties.xoxno, value: row.xoxno ?? 0 }), + new MarketplaceData({ key: rowProperties.frameit, value: row.frameit ?? 0 }), + new MarketplaceData({ key: rowProperties.deadrare, value: row.deadrare ?? 0 }), + new MarketplaceData({ key: rowProperties.elrondapes, value: row.elrondapes ?? 0 }), + new MarketplaceData({ key: rowProperties.elrondnftswap, value: row.elrondnftswap ?? 0 }), + new MarketplaceData({ key: rowProperties.eneftor, value: row.eneftor ?? 0 }), + new MarketplaceData({ key: rowProperties.hoghomies, value: row.hoghomies ?? 0 }), + new MarketplaceData({ key: rowProperties.holoride, value: row.holoride ?? 0 }), + new MarketplaceData({ key: rowProperties.aquaverse, value: row.aquaverse ?? 0 }), + new MarketplaceData({ key: rowProperties.ici, value: row.ici ?? 0 }), + ], }); } } + +@ObjectType() +export class MarketplaceData { + @Field() + key: String; + @Field(() => Float, { nullable: true }) + value: number; + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export function proxiedPropertiesOf(obj?: TObj) { + return new Proxy( + {}, + { + get: (_, prop) => prop, + }, + ) as { + [P in keyof TObj]?: P; + }; +}