Skip to content

Commit 031106e

Browse files
greatertomimkazlauskas
authored andcommitted
feat: add NFT metadata projection
- add Mappers.withCIP67 - add cip67 assetName to withMint Mapper - add Mapper.withNftMetadata - add storeNftMetadata operator - refactor entities by adding OnDeleteCascadeRelationOptions relation option
1 parent 270d1ab commit 031106e

35 files changed

+1317
-56
lines changed

packages/cardano-services/src/Projection/createTypeormProjection.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,14 @@ export const createTypeormProjection = ({
108108
logger.debug(`Creating projection with policyIds ${JSON.stringify(handlePolicyIds)}`);
109109
logger.debug(`Using a ${blocksBufferLength} blocks buffer`);
110110

111-
const { mappers, entities, stores, extensions } = prepareTypeormProjection({
112-
buffer,
113-
options: projectionOptions,
114-
projections
115-
});
111+
const { mappers, entities, stores, extensions } = prepareTypeormProjection(
112+
{
113+
buffer,
114+
options: projectionOptions,
115+
projections
116+
},
117+
{ logger }
118+
);
116119
const dataSource$ = createObservableDataSource({
117120
buffer,
118121
connectionConfig$,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
import { NftMetadataEntity } from '@cardano-sdk/projection-typeorm';
3+
4+
export class NftMetadataTableMigration1690269355640 implements MigrationInterface {
5+
static entity = NftMetadataEntity;
6+
7+
public async up(queryRunner: QueryRunner): Promise<void> {
8+
await queryRunner.query('CREATE TYPE "public"."nft_metadata_type_enum" AS ENUM(\'CIP-0025\', \'CIP-0068\')');
9+
await queryRunner.query(
10+
'CREATE TABLE "nft_metadata" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "description" character varying, "image" character varying NOT NULL, "media_type" character varying, "files" jsonb, "type" "public"."nft_metadata_type_enum" NOT NULL, "other_properties" jsonb, "parent_asset_id" character varying NOT NULL, "user_token_asset_id" character varying, "created_at_slot" integer NOT NULL, CONSTRAINT "PK_nft_metadata_id" PRIMARY KEY ("id"))'
11+
);
12+
await queryRunner.query('ALTER TABLE "asset" ADD "nft_metadata_id" integer');
13+
await queryRunner.query(
14+
'ALTER TABLE "asset" ADD CONSTRAINT "UQ_asset_nft_metadata_id}" UNIQUE ("nft_metadata_id")'
15+
);
16+
await queryRunner.query(
17+
'ALTER TABLE "nft_metadata" ADD CONSTRAINT "FK_nft_metadata_parent_asset_id" FOREIGN KEY ("parent_asset_id") REFERENCES "asset"("id") ON DELETE CASCADE ON UPDATE NO ACTION'
18+
);
19+
await queryRunner.query(
20+
'ALTER TABLE "nft_metadata" ADD CONSTRAINT "FK_nft_metadata_user_token_asset_id" FOREIGN KEY ("user_token_asset_id") REFERENCES "asset"("id") ON DELETE SET NULL ON UPDATE NO ACTION'
21+
);
22+
await queryRunner.query(
23+
'ALTER TABLE "nft_metadata" ADD CONSTRAINT "FK_nft_metadata_created_at_slot" FOREIGN KEY ("created_at_slot") REFERENCES "block"("slot") ON DELETE CASCADE ON UPDATE NO ACTION'
24+
);
25+
await queryRunner.query(
26+
'ALTER TABLE "asset" ADD CONSTRAINT "FK_asset_nft_metadata_id" FOREIGN KEY ("nft_metadata_id") REFERENCES "nft_metadata"("id") ON DELETE SET NULL ON UPDATE NO ACTION'
27+
);
28+
}
29+
30+
public async down(queryRunner: QueryRunner): Promise<void> {
31+
await queryRunner.query('ALTER TABLE "asset" DROP CONSTRAINT "FK_asset_nft_metadata_id"');
32+
await queryRunner.query('ALTER TABLE "nft_metadata" DROP CONSTRAINT "FK_nft_metadata_created_at_slot"');
33+
await queryRunner.query('ALTER TABLE "nft_metadata" DROP CONSTRAINT "FK_nft_metadata_user_token_asset_id"');
34+
await queryRunner.query('ALTER TABLE "nft_metadata" DROP CONSTRAINT "FK_nft_metadata_parent_asset_id"');
35+
await queryRunner.query('ALTER TABLE "asset" DROP CONSTRAINT "UQ_asset_nft_metadata_id}"');
36+
await queryRunner.query('ALTER TABLE "asset" DROP COLUMN "nft_metadata_id"');
37+
await queryRunner.query('DROP TABLE "nft_metadata"');
38+
await queryRunner.query('DROP TYPE "public"."nft_metadata_type_enum"');
39+
}
40+
}

packages/cardano-services/src/Projection/migrations/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CostPledgeNumericMigration1689091319930 } from './1689091319930-cost-pl
55
import { FkPoolRegistrationMigration1682519108369 } from './1682519108369-fk-pool-registration';
66
import { FkPoolRetirementMigration1682519108370 } from './1682519108370-fk-pool-retirement';
77
import { HandleTableMigration1686138943349 } from './1686138943349-handle-table';
8+
import { NftMetadataTableMigration1690269355640 } from './1690269355640-nft-metadata-table';
89
import { OutputTableMigration1682519108367 } from './1682519108367-output-table';
910
import { PoolMetadataTableMigration1682519108363 } from './1682519108363-pool-metadata-table';
1011
import { PoolMetricsMigrations1685011799580 } from './1685011799580-stake-pool-metrics-table';
@@ -31,5 +32,6 @@ export const migrations: ProjectionMigration[] = [
3132
FkPoolRetirementMigration1682519108370,
3233
PoolMetricsMigrations1685011799580,
3334
HandleTableMigration1686138943349,
34-
CostPledgeNumericMigration1689091319930
35+
CostPledgeNumericMigration1689091319930,
36+
NftMetadataTableMigration1690269355640
3537
];

packages/cardano-services/src/Projection/prepareTypeormProjection.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
CurrentPoolMetricsEntity,
66
DataSourceExtensions,
77
HandleEntity,
8+
NftMetadataEntity,
89
OutputEntity,
910
PoolMetadataEntity,
1011
PoolRegistrationEntity,
@@ -16,6 +17,7 @@ import {
1617
storeAssets,
1718
storeBlock,
1819
storeHandles,
20+
storeNftMetadata,
1921
storeStakePoolMetadataJob,
2022
storeStakePools,
2123
storeUtxo
@@ -24,6 +26,7 @@ import { Cardano } from '@cardano-sdk/core';
2426
import { Mappers as Mapper } from '@cardano-sdk/projection';
2527
import { POOLS_METRICS_INTERVAL_DEFAULT } from '../Program/programs/types';
2628
import { Sorter } from '@hapi/topo';
29+
import { WithLogger } from '@cardano-sdk/util';
2730
import { passthrough } from '@cardano-sdk/util-rxjs';
2831

2932
/**
@@ -46,7 +49,11 @@ const requiredExtensions = (projectionNames: ProjectionName[]): DataSourceExtens
4649
pgBoss: projectionNames.includes(ProjectionName.StakePoolMetadataJob)
4750
});
4851

49-
const createMapperOperators = (projectionNames: ProjectionName[], { handlePolicyIds }: ProjectionOptions) => {
52+
const createMapperOperators = (
53+
projectionNames: ProjectionName[],
54+
{ handlePolicyIds }: ProjectionOptions,
55+
{ logger }: WithLogger
56+
) => {
5057
const applyUtxoAndMintFilters = handlePolicyIds && !projectionNames.includes(ProjectionName.UTXO);
5158
const filterUtxo = applyUtxoAndMintFilters
5259
? Mapper.filterProducedUtxoByAssetPolicyId({ policyIds: handlePolicyIds })
@@ -58,9 +65,11 @@ const createMapperOperators = (projectionNames: ProjectionName[], { handlePolicy
5865
return {
5966
filterMint,
6067
filterUtxo,
68+
withCIP67: Mapper.withCIP67(),
6169
withCertificates: Mapper.withCertificates(),
6270
withHandles,
6371
withMint: Mapper.withMint(),
72+
withNftMetadata: Mapper.withNftMetadata({ logger }),
6473
withStakePools: Mapper.withStakePools(),
6574
withUtxo: Mapper.withUtxo()
6675
};
@@ -73,6 +82,7 @@ export const storeOperators = {
7382
storeAssets: storeAssets(),
7483
storeBlock: storeBlock(),
7584
storeHandles: storeHandles(),
85+
storeNftMetadata: storeNftMetadata(),
7686
storePoolMetricsUpdateJob: createStorePoolMetricsUpdateJob(POOLS_METRICS_INTERVAL_DEFAULT)(),
7787
storeStakePoolMetadataJob: storeStakePoolMetadataJob(),
7888
storeStakePools: storeStakePools(),
@@ -88,6 +98,7 @@ const entities = {
8898
blockData: BlockDataEntity,
8999
currentPoolMetrics: CurrentPoolMetricsEntity,
90100
handle: HandleEntity,
101+
nftMetadata: NftMetadataEntity,
91102
output: OutputEntity,
92103
poolMetadata: PoolMetadataEntity,
93104
poolRegistration: PoolRegistrationEntity,
@@ -104,14 +115,15 @@ const storeEntities: Partial<Record<StoreName, EntityName[]>> = {
104115
storeAssets: ['asset'],
105116
storeBlock: ['block'],
106117
storeHandles: ['handle', 'asset', 'tokens', 'output'],
118+
storeNftMetadata: ['asset'],
107119
storePoolMetricsUpdateJob: ['stakePool', 'currentPoolMetrics', 'poolMetadata'],
108120
storeStakePoolMetadataJob: ['stakePool', 'currentPoolMetrics', 'poolMetadata'],
109121
storeStakePools: ['stakePool', 'currentPoolMetrics', 'poolMetadata'],
110122
storeUtxo: ['tokens', 'output']
111123
};
112124

113125
const entityInterDependencies: Partial<Record<EntityName, EntityName[]>> = {
114-
asset: ['block'],
126+
asset: ['block', 'nftMetadata'],
115127
blockData: ['block'],
116128
currentPoolMetrics: ['stakePool'],
117129
handle: ['asset'],
@@ -146,13 +158,16 @@ export const getEntities = (entityNames: EntityName[]): Entity[] => {
146158
const mapperInterDependencies: Partial<Record<MapperName, MapperName[]>> = {
147159
filterMint: ['withMint'],
148160
filterUtxo: ['withUtxo'],
161+
withCIP67: ['withUtxo'],
149162
withHandles: ['withMint', 'filterMint', 'withUtxo', 'filterUtxo'],
163+
withNftMetadata: ['withCIP67', 'withMint'],
150164
withStakePools: ['withCertificates']
151165
};
152166

153167
const storeMapperDependencies: Partial<Record<StoreName, MapperName[]>> = {
154168
storeAssets: ['withMint'],
155169
storeHandles: ['withHandles'],
170+
storeNftMetadata: ['withNftMetadata'],
156171
storeStakePoolMetadataJob: ['withStakePools'],
157172
storeStakePools: ['withStakePools'],
158173
storeUtxo: ['withUtxo']
@@ -161,14 +176,15 @@ const storeMapperDependencies: Partial<Record<StoreName, MapperName[]>> = {
161176
const storeInterDependencies: Partial<Record<StoreName, StoreName[]>> = {
162177
storeAssets: ['storeBlock'],
163178
storeHandles: ['storeUtxo'],
179+
storeNftMetadata: ['storeAssets'],
164180
storePoolMetricsUpdateJob: ['storeBlock'],
165181
storeStakePoolMetadataJob: ['storeBlock'],
166182
storeStakePools: ['storeBlock'],
167183
storeUtxo: ['storeBlock', 'storeAssets']
168184
};
169185

170186
const projectionStoreDependencies: Record<ProjectionName, StoreName[]> = {
171-
handle: ['storeHandles'],
187+
handle: ['storeHandles', 'storeNftMetadata'],
172188
'stake-pool': ['storeStakePools'],
173189
'stake-pool-metadata-job': ['storeStakePoolMetadataJob'],
174190
'stake-pool-metrics-job': ['storePoolMetricsUpdateJob'],
@@ -252,12 +268,15 @@ export interface PrepareTypeormProjectionProps {
252268
* Selects a required set of entities, mappers and store operators
253269
* based on 'projections' and presence of 'buffer':
254270
*/
255-
export const prepareTypeormProjection = ({ projections, buffer, options = {} }: PrepareTypeormProjectionProps) => {
271+
export const prepareTypeormProjection = (
272+
{ projections, buffer, options = {} }: PrepareTypeormProjectionProps,
273+
dependencies: WithLogger
274+
) => {
256275
const mapperSorter = new Sorter<MapperOperator>();
257276
const storeSorter = new Sorter<StoreOperator>();
258277
const entitySorter = new Sorter<Entity>();
259278

260-
const mapperOperators = createMapperOperators(projections, options);
279+
const mapperOperators = createMapperOperators(projections, options, dependencies);
261280

262281
for (const projection of projections) {
263282
for (const storeName of projectionStoreDependencies[projection]) {

packages/cardano-services/test/Projection/createTypeormProjection.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('createTypeormProjection', () => {
3636
await lastValueFrom(projection$);
3737

3838
// Check data in the database
39-
const { entities } = prepareTypeormProjection({ buffer, projections });
39+
const { entities } = prepareTypeormProjection({ buffer, projections }, { logger });
4040
const dataSource = createDataSource({ connectionConfig: projectorConnectionConfig, entities, logger });
4141
await dataSource.initialize();
4242
const queryRunner = dataSource.createQueryRunner();

packages/cardano-services/test/Projection/prepareTypeormProjection.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,28 @@ import { TypeormStabilityWindowBuffer } from '@cardano-sdk/projection-typeorm';
33
import { dummyLogger } from 'ts-log';
44

55
const prepare = (projections: ProjectionName[], useBuffer?: boolean) => {
6-
const { __debug } = prepareTypeormProjection({
7-
buffer: useBuffer ? new TypeormStabilityWindowBuffer({ logger: dummyLogger }) : undefined,
8-
projections
9-
});
6+
const { __debug } = prepareTypeormProjection(
7+
{
8+
buffer: useBuffer ? new TypeormStabilityWindowBuffer({ logger: dummyLogger }) : undefined,
9+
projections
10+
},
11+
{ logger: dummyLogger }
12+
);
1013
return __debug;
1114
};
1215

1316
describe('prepareTypeormProjection', () => {
1417
describe('computes required entities, mappers and stores based on selected projections and presence of a buffer', () => {
1518
test('utxo (without buffer)', () => {
1619
const { entities, mappers, stores } = prepare([ProjectionName.UTXO]);
17-
expect(new Set(entities)).toEqual(new Set(['tokens', 'block', 'asset', 'output']));
20+
expect(new Set(entities)).toEqual(new Set(['tokens', 'block', 'asset', 'nftMetadata', 'output']));
1821
expect(mappers).toEqual(['withMint', 'withUtxo']);
1922
expect(stores).toEqual(['storeBlock', 'storeAssets', 'storeUtxo']);
2023
});
2124

2225
test('utxo (with buffer)', () => {
2326
const { entities, mappers, stores } = prepare([ProjectionName.UTXO], true);
24-
expect(new Set(entities)).toEqual(new Set(['tokens', 'block', 'asset', 'output', 'blockData']));
27+
expect(new Set(entities)).toEqual(new Set(['tokens', 'block', 'asset', 'nftMetadata', 'output', 'blockData']));
2528
expect(mappers).toEqual(['withMint', 'withUtxo']);
2629
// 'null' is expected here because buffer.storeBlockData is not a common operator,
2730
// but is a method of the buffer. As a result it's not part of the predefined operators object.

packages/e2e/test/projection/single-tenant-utxo.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ const createDataSource = () =>
4747
Postgres.BlockDataEntity,
4848
Postgres.AssetEntity,
4949
Postgres.TokensEntity,
50-
Postgres.OutputEntity
50+
Postgres.OutputEntity,
51+
Postgres.NftMetadataEntity
5152
],
5253
logger,
5354
options: {

packages/projection-typeorm/src/createDataSource.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ const namingOverrides: Partial<NamingStrategyInterface> = {
7171
},
7272
tableName(targetName, userSpecifiedName) {
7373
return userSpecifiedName || toTableName(snakeCase(targetName));
74+
},
75+
uniqueConstraintName(tableName, columnNames) {
76+
return `UQ_${toTableName(tableName)}_${columnNames.join('_')}}`;
7477
}
7578
};
7679

packages/projection-typeorm/src/entity/Asset.entity.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { BlockEntity } from './Block.entity';
22
import { Cardano } from '@cardano-sdk/core';
3-
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
4-
import { DeleteCascadeRelationOptions } from './util';
3+
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryColumn } from 'typeorm';
4+
import { NftMetadataEntity } from './NftMetadata.entity';
5+
import { OnDeleteCascadeRelationOptions, OnDeleteSetNullRelationOptions } from './util';
56
import { parseBigInt } from './transformers';
67

78
@Entity()
@@ -13,7 +14,10 @@ export class AssetEntity {
1314
type: 'decimal'
1415
})
1516
supply?: bigint;
16-
@ManyToOne(() => BlockEntity, DeleteCascadeRelationOptions)
17+
@ManyToOne(() => BlockEntity, OnDeleteCascadeRelationOptions)
1718
@JoinColumn()
1819
firstMintBlock?: BlockEntity;
20+
@OneToOne(() => NftMetadataEntity, OnDeleteSetNullRelationOptions)
21+
@JoinColumn()
22+
nftMetadata?: NftMetadataEntity;
1923
}

packages/projection-typeorm/src/entity/BlockData.entity.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BlockEntity } from './Block.entity';
22
import { Cardano } from '@cardano-sdk/core';
33
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
4-
import { DeleteCascadeRelationOptions } from './util';
4+
import { OnDeleteCascadeRelationOptions } from './util';
55
import { json, serializableObj, stringBytea } from './transformers';
66

77
@Entity()
@@ -10,7 +10,7 @@ export class BlockDataEntity {
1010
@PrimaryColumn()
1111
blockHeight?: number;
1212

13-
@OneToOne(() => BlockEntity, DeleteCascadeRelationOptions)
13+
@OneToOne(() => BlockEntity, OnDeleteCascadeRelationOptions)
1414
@JoinColumn({ referencedColumnName: 'height' })
1515
block?: BlockEntity;
1616

0 commit comments

Comments
 (0)