diff --git a/CHANGELOG.md b/CHANGELOG.md index 65447620..7d62ffa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Adds an effective mint filter and sort to the market listing endpoints, so a cli - The effective mint of an asset is its `original_mint` from `atomicassets_original_mints` when a link row with a mint number exists, else its template mint. An asset with neither is ignored. A listing matches when at least one of its assets has an effective mint and every such asset lies in the range, so a listing of an asset with no link is still found by its template mint. (#234) - The listing endpoints above, except `/v1/sales/templates`, accept `sort=effective_mint`, which orders by the lowest effective mint of the listing. It returns only listings where an asset has an effective mint, with or without a bound, and the matching `_count` endpoint counts the same listings. (#234) - A request that uses `min_effective_mint`, `max_effective_mint` or `sort=effective_mint` needs `collection_name` and returns 400 without it, because without that scope each listing needs its own lookup of the link table. The `template_mint` filters and sorts keep their queries. (#234) +- A request that uses one of these three parameters also returns 400 with more than 50 collection names, counted before deduplication, because a longer list leaves the indexed filter path of `/v2/sales`. A request without these parameters accepts any number of names. (#235) +- For a listing with no linked asset, the effective mint filters and sort read the stored template mint range of the listing, the value the `template_mint` filter reads, so the cost does not grow with the asset rows of an unlinked collection. A listing with a linked asset still reads the mint of each of its assets. A listing whose stored range is not written yet reads the mint of each of its assets. On `/v2/sales` the stored range follows a change of an asset mint after the filter table refresh, as for the `template_mint` filter. (#235) ## [2.5.0] diff --git a/src/api/namespaces/atomicmarket/effective-mint-suite.ts b/src/api/namespaces/atomicmarket/effective-mint-suite.ts index df14912d..7fe2597d 100644 --- a/src/api/namespaces/atomicmarket/effective-mint-suite.ts +++ b/src/api/namespaces/atomicmarket/effective-mint-suite.ts @@ -9,12 +9,25 @@ export type MintAssetSpec = { original?: number | null, }; +/** + * The template mint range the filler stores on a listing: the lowest and highest template + * mint of its assets, or 'empty' when no asset has one. + */ +export function storedTemplateRange(assets: MintAssetSpec[]): string { + const mints = assets.map(a => a.template).filter((m): m is number => typeof m === 'number'); + + return mints.length ? `[${Math.min(...mints)},${Math.max(...mints)}]` : 'empty'; +} + export type EffectiveMintSuiteOptions = { + // True when makeListing stores the given range. The sales filter table derives its own. + canOverrideStoredRange?: boolean, client: AtomicMarketTestClient, txit: any, - // listingTemplateMint is the template mint range the listing stores, as [n,n], for the + // listingTemplateMint is the template mint range the listing stores, as [n,n], or null for no + // stored range yet, for the // handlers that read a stored column. The sales filter table derives it from the assets. - makeListing: (collectionName: string, assets: MintAssetSpec[], listingTemplateMint?: number) => Promise, + makeListing: (collectionName: string, assets: MintAssetSpec[], listingTemplateMint?: number | null) => Promise, query: (params: Record) => Promise, count: (params: Record) => Promise, }; @@ -53,7 +66,7 @@ async function expectApiError(promise: Promise, code: number, message: stri } export function defineEffectiveMintSuite(options: EffectiveMintSuiteOptions): void { - const {client, txit, makeListing, query, count} = options; + const {client, txit, makeListing, query, count, canOverrideStoredRange} = options; async function setup(): Promise { return (await client.createCollection()).collection_name; @@ -177,6 +190,144 @@ export function defineEffectiveMintSuite(options: EffectiveMintSuiteOptions): vo .to.deep.equal([both]); }); + txit('matches an unlinked listing by the template mint range it stores', async () => { + const c = await setup(); + const single = await makeListing(c, [{template: 5}]); + const multi = await makeListing(c, [{template: 4}, {template: 6}]); + await makeListing(c, [{template: 4}, {template: 60}]); + await makeListing(c, [{template: 3}]); + + expect((await query({collection_name: c, min_effective_mint: '4', max_effective_mint: '6'})).sort()) + .to.deep.equal([single, multi].sort()); + }); + + txit('reads the aggregate, not the stored range, of a linked listing', async () => { + const c = await setup(); + const originalIn = await makeListing(c, [{original: 5, template: 500}]); + await makeListing(c, [{original: 50, template: 5}]); + + expect(await query({collection_name: c, min_effective_mint: '4', max_effective_mint: '6'})) + .to.deep.equal([originalIn]); + }); + + txit('accepts a bound above the int4 range on an unlinked listing', async () => { + const c = await setup(); + const low = await makeListing(c, [{template: 5}]); + const high = await makeListing(c, [{template: 2000000000}]); + const linked = await makeListing(c, [{original: 3000000000, template: 5}]); + const huge = '9007199254740991'; + + expect((await query({collection_name: c, min_effective_mint: '4', max_effective_mint: huge})).sort()) + .to.deep.equal([low, high, linked].sort()); + expect((await query({collection_name: c, max_effective_mint: huge})).sort()) + .to.deep.equal([low, high, linked].sort()); + expect(await query({collection_name: c, min_effective_mint: huge})).to.deep.equal([]); + }); + + txit('accepts a minimum alone and a maximum alone on unlinked listings', async () => { + const c = await setup(); + const low = await makeListing(c, [{template: 2}]); + const high = await makeListing(c, [{template: 20}]); + await makeListing(c, [{}]); + + expect(await query({collection_name: c, min_effective_mint: '10'})).to.deep.equal([high]); + expect(await query({collection_name: c, max_effective_mint: '10'})).to.deep.equal([low]); + expect((await query({collection_name: c, min_effective_mint: '2', max_effective_mint: '20'})).sort()) + .to.deep.equal([low, high].sort()); + }); + + txit('orders linked and unlinked listings together and counts the same rows', async () => { + const c = await setup(); + const linkedLow = await makeListing(c, [{original: 1, template: 90}]); + const unlinkedLow = await makeListing(c, [{template: 3}]); + const linkedMid = await makeListing(c, [{original: 7}]); + const unlinkedMulti = await makeListing(c, [{template: 12}, {template: 9}]); + await makeListing(c, [{}]); + + const asc = {collection_name: c, sort: 'effective_mint', order: 'asc'}; + + expect(await query(asc)).to.deep.equal([linkedLow, unlinkedLow, linkedMid, unlinkedMulti]); + expect(await query({...asc, order: 'desc'})) + .to.deep.equal([unlinkedMulti, linkedMid, unlinkedLow, linkedLow]); + expect(await count(asc)).to.equal(4); + }); + + // The sales filter table derives its range from the assets, so only the stored-column + // sources can hold a range that differs from them. /v2/sales proves it in its own file. + if (canOverrideStoredRange) { + txit('reads the stored range of an unlinked listing', async () => { + const c = await setup(); + // the stored range holds the mints of the listing, so a range that differs from the + // asset decides, which proves the filter reads the stored column + const storedIn = await makeListing(c, [{template: 500}], 5); + await makeListing(c, [{template: 5}], 500); + + expect(await query({collection_name: c, min_effective_mint: '4', max_effective_mint: '6'})) + .to.deep.equal([storedIn]); + expect(await query({collection_name: c, sort: 'effective_mint', order: 'asc', max_effective_mint: '6'})) + .to.deep.equal([storedIn]); + }); + + txit('reads the assets of a listing whose stored range is not written yet', async () => { + const c = await setup(); + const unlinkedNull = await makeListing(c, [{template: 5}], null); + const linkedNull = await makeListing(c, [{original: 6, template: 500}], null); + const third = await makeListing(c, [{template: 50}], null); + await makeListing(c, [{}], null); + + const range = {collection_name: c, min_effective_mint: '4', max_effective_mint: '7'}; + const sort = {collection_name: c, sort: 'effective_mint', order: 'asc'}; + + expect((await query(range)).sort()).to.deep.equal([unlinkedNull, linkedNull].sort()); + expect(await query({...range, sort: 'effective_mint', order: 'asc'})) + .to.deep.equal([unlinkedNull, linkedNull]); + expect(await count(range)).to.equal(2); + expect(await query(sort)).to.deep.equal([unlinkedNull, linkedNull, third]); + expect(await count(sort)).to.equal(3); + }); + } + + describe('collection name cap', () => { + const others = (n: number): string[] => Array.from({length: n}, (_, i) => + `zz${String.fromCharCode(97 + Math.floor(i / 26))}${String.fromCharCode(97 + (i % 26))}`); + const CAP_MESSAGE = 'The effective mint filters and sort accept at most 50 collection names'; + + txit('accepts 50 collection names with a mint parameter', async () => { + const c = await setup(); + const id = await makeListing(c, [{original: 5}]); + const names = [c, ...others(49)].join(','); + + expect(await query({collection_name: names, min_effective_mint: '1'})).to.deep.equal([id]); + expect(await query({collection_name: names, sort: 'effective_mint'})).to.deep.equal([id]); + }); + + txit('rejects 51 collection names with a mint parameter', async () => { + const c = await setup(); + await makeListing(c, [{original: 5}]); + const names = [c, ...others(50)].join(','); + + await expectApiError(query({collection_name: names, min_effective_mint: '1'}), 400, CAP_MESSAGE); + await expectApiError(query({collection_name: names, max_effective_mint: '9'}), 400, CAP_MESSAGE); + await expectApiError(query({collection_name: names, sort: 'effective_mint'}), 400, CAP_MESSAGE); + await expectApiError(count({collection_name: names, min_effective_mint: '1'}), 400, CAP_MESSAGE); + }); + + txit('counts 51 copies of one name as 51', async () => { + const c = await setup(); + + await expectApiError( + query({collection_name: Array(51).fill(c).join(','), min_effective_mint: '1'}), 400, CAP_MESSAGE + ); + }); + + txit('keeps 51 collection names working without a mint parameter', async () => { + const c = await setup(); + const id = await makeListing(c, [{original: 5}]); + + expect(await query({collection_name: [c, ...others(50)].join(',')})).to.deep.equal([id]); + }); + }); + txit('rejects the mint parameters without collection_name', async () => { await expectApiError(query({min_effective_mint: '1'}), 400, SCOPE_MESSAGE); await expectApiError(query({max_effective_mint: '1'}), 400, SCOPE_MESSAGE); diff --git a/src/api/namespaces/atomicmarket/handlers/auctions.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/auctions.integration.test.ts index e65aae33..59cb9d09 100644 --- a/src/api/namespaces/atomicmarket/handlers/auctions.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/auctions.integration.test.ts @@ -4,7 +4,7 @@ import {initAtomicMarketTest} from '../test'; import {RequestValues} from '../../utils'; import {getTestContext} from '../../../../utils/test'; import {getAuctionAction, getAuctionsAction, getAuctionsCountAction} from './auctions'; -import {createMintAsset, defineEffectiveMintSuite} from '../effective-mint-suite'; +import {createMintAsset, defineEffectiveMintSuite, storedTemplateRange} from '../effective-mint-suite'; import {AuctionApiState} from '../index'; import sinon from 'sinon'; import {clearMarketVersionCache, MARKET_VERSION_CACHE_TTL_MS} from '../market-version'; @@ -337,12 +337,15 @@ describe('auction handler', () => { }); defineEffectiveMintSuite({ + canOverrideStoredRange: true, client, txit, makeListing: async (collectionName, assets, templateMint) => { const listing = await client.createAuction({ collection_name: collectionName, - ...(templateMint === undefined ? {} : {template_mint: `[${templateMint},${templateMint}]`}), + template_mint: templateMint === undefined + ? storedTemplateRange(assets) + : (templateMint === null ? null : `[${templateMint},${templateMint}]`), }); for (const [i, spec] of assets.entries()) { const asset_id = await createMintAsset(client, collectionName, spec); diff --git a/src/api/namespaces/atomicmarket/handlers/buyoffers.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/buyoffers.integration.test.ts index ef0379f0..c8132a04 100644 --- a/src/api/namespaces/atomicmarket/handlers/buyoffers.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/buyoffers.integration.test.ts @@ -3,7 +3,7 @@ import {initAtomicMarketTest} from '../test'; import {RequestValues} from '../../utils'; import {getTestContext} from '../../../../utils/test'; import {getBuyOfferAction, getBuyOffersAction, getBuyOffersCountAction} from './buyoffers'; -import {createMintAsset, defineEffectiveMintSuite} from '../effective-mint-suite'; +import {createMintAsset, defineEffectiveMintSuite, storedTemplateRange} from '../effective-mint-suite'; import {ApiError} from '../../../error'; // TODO add more tests @@ -163,12 +163,15 @@ describe('buy offer handler', () => { }); defineEffectiveMintSuite({ + canOverrideStoredRange: true, client, txit, makeListing: async (collectionName, assets, templateMint) => { const listing = await client.createBuyOffer({ collection_name: collectionName, - ...(templateMint === undefined ? {} : {template_mint: `[${templateMint},${templateMint}]`}), + template_mint: templateMint === undefined + ? storedTemplateRange(assets) + : (templateMint === null ? null : `[${templateMint},${templateMint}]`), }); for (const [i, spec] of assets.entries()) { const asset_id = await createMintAsset(client, collectionName, spec); diff --git a/src/api/namespaces/atomicmarket/handlers/sales.get-sales.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/sales.get-sales.integration.test.ts index 3287c732..b1a144fa 100644 --- a/src/api/namespaces/atomicmarket/handlers/sales.get-sales.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/sales.get-sales.integration.test.ts @@ -1,7 +1,7 @@ import 'mocha'; import {expect} from 'chai'; import {getSaleAction, getSalesAction, getSalesCountAction} from './sales'; -import {createMintAsset, defineEffectiveMintSuite} from '../effective-mint-suite'; +import {createMintAsset, defineEffectiveMintSuite, storedTemplateRange} from '../effective-mint-suite'; import {SaleApiState} from '../index'; import {OfferState} from '../../../../filler/handlers/atomicassets'; import {SaleState} from '../../../../filler/handlers/atomicmarket'; @@ -773,6 +773,7 @@ describe('AtomicMarket Sales API', () => { }); defineEffectiveMintSuite({ + canOverrideStoredRange: true, client, txit, makeListing: async (collectionName, assets, templateMint) => { @@ -784,7 +785,9 @@ describe('AtomicMarket Sales API', () => { return (await client.createSale({ offer_id, collection_name: collectionName, - ...(templateMint === undefined ? {} : {template_mint: `[${templateMint},${templateMint}]`}), + template_mint: templateMint === undefined + ? storedTemplateRange(assets) + : (templateMint === null ? null : `[${templateMint},${templateMint}]`), })).sale_id; }, query: async (values) => await getSalesIds(values), diff --git a/src/api/namespaces/atomicmarket/handlers/sales2.get-sales-templates.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/sales2.get-sales-templates.integration.test.ts index 1f63109f..77ca5f2f 100644 --- a/src/api/namespaces/atomicmarket/handlers/sales2.get-sales-templates.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/sales2.get-sales-templates.integration.test.ts @@ -459,6 +459,40 @@ describe('AtomicMarket Sales API', () => { })).to.deep.equal([both]); }); + txit('reads the stored range of an unlinked listing, not its assets', async () => { + const c = (await client.createCollection()).collection_name; + const storedOut = await templateSale(c, {template: 5}); + const storedIn = await templateSale(c, {template: 500}); + await client.refreshSalesFilters(); + await client.query('UPDATE atomicmarket_sales_filters SET template_mint = $2 WHERE sale_id = $1', [storedOut, '[500,500]']); + await client.query('UPDATE atomicmarket_sales_filters SET template_mint = $2 WHERE sale_id = $1', [storedIn, '[5,5]']); + + // no refresh here, so the stored range the updates wrote is the one read + const result = await getSalesTemplatesV2Action({ + symbol: 'TEST', collection_name: c, min_effective_mint: '4', max_effective_mint: '6', + }, getTestContext(client)); + + expect(result.map((s: any) => s.sale_id)).to.deep.equal([storedIn]); + }); + + txit('rejects 51 collection names with a bound', async () => { + const c = (await client.createCollection()).collection_name; + await templateSale(c, {original: 5}); + const names = [c, ...Array.from({length: 50}, (_, i) => + `zz${String.fromCharCode(97 + Math.floor(i / 26))}${String.fromCharCode(97 + (i % 26))}`)].join(','); + + let err: any; + try { + await getSalesIds({collection_name: names, min_effective_mint: '1'}); + } catch (e) { + err = e; + } + + expect(err).to.be.instanceof(ApiError); + expect(err.code).to.equal(400); + expect(err.message).to.equal('The effective mint filters and sort accept at most 50 collection names'); + }); + txit('requires collection_name and a valid range', async () => { const c = (await client.createCollection()).collection_name; await templateSale(c, {original: 5}); diff --git a/src/api/namespaces/atomicmarket/handlers/sales2.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/sales2.integration.test.ts index eb3b6102..107b07c9 100644 --- a/src/api/namespaces/atomicmarket/handlers/sales2.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/sales2.integration.test.ts @@ -1053,6 +1053,47 @@ describe('AtomicMarket Sales API', () => { }, }); + describe('effective mint stored range of the sales filter table', () => { + async function unlinkedSale(collectionName: string, templateMint: number): Promise { + const { offer_id } = await client.createOffer(); + const asset_id = await createMintAsset(client, collectionName, { template: templateMint }); + await client.createOfferAsset({ offer_id, asset_id }); + + return (await client.createSale({ offer_id, collection_name: collectionName })).sale_id; + } + + async function storeRange(sale_id: number, range: string): Promise { + await client.query('UPDATE atomicmarket_sales_filters SET template_mint = $2 WHERE sale_id = $1', [sale_id, range]); + } + + // The query runs without a refresh, so the stored range the update wrote is the one read. + txit('reads the stored range of an unlinked listing, not its assets', async () => { + const c = (await client.createCollection()).collection_name; + const storedOut = await unlinkedSale(c, 5); + const storedIn = await unlinkedSale(c, 500); + await client.refreshSalesFilters(); + await storeRange(storedOut, '[500,500]'); + await storeRange(storedIn, '[5,5]'); + + const norefresh = { refresh: false }; + const range = { collection_name: c, min_effective_mint: '4', max_effective_mint: '6' }; + + expect(await getSalesIds(range, norefresh)).to.deep.equal([storedIn]); + expect(await getSalesIds({ collection_name: c, sort: 'effective_mint', order: 'asc' }, norefresh)) + .to.deep.equal([storedIn, storedOut]); + }); + + txit('reads the assets of a listing whose stored range is null', async () => { + const c = (await client.createCollection()).collection_name; + const sale = await unlinkedSale(c, 5); + await client.refreshSalesFilters(); + await client.query('UPDATE atomicmarket_sales_filters SET template_mint = NULL WHERE sale_id = $1', [sale]); + + expect(await getSalesIds({ collection_name: c, min_effective_mint: '4', max_effective_mint: '6' }, { refresh: false })) + .to.deep.equal([sale]); + }); + }); + describe('effective mint sort with a state filter', () => { async function listedSale(collectionName: string, spec: MintAssetSpec): Promise { const { offer_id } = await client.createOffer(); diff --git a/src/api/namespaces/atomicmarket/handlers/sales2.ts b/src/api/namespaces/atomicmarket/handlers/sales2.ts index 8fc3efb6..39257d91 100644 --- a/src/api/namespaces/atomicmarket/handlers/sales2.ts +++ b/src/api/namespaces/atomicmarket/handlers/sales2.ts @@ -8,7 +8,9 @@ import {toInt} from '../../../../utils'; import moize from 'moize'; import {filterQueryArgs, FilterValues} from '../../validation'; import {hasAssetFilter} from '../../atomicassets/utils'; -import {buildEffectiveMintFilter, buildEffectiveMintSort, buildTemplateMintFilter, SALES_FILTER_MINT_SOURCE} from '../utils'; +import { + buildEffectiveMintFilter, buildEffectiveMintSort, buildTemplateMintFilter, MAX_INDEXED_COLLECTION_NAMES, SALES_FILTER_MINT_SOURCE +} from '../utils'; type SalesSearchOptions = { values: FilterValues; @@ -334,7 +336,7 @@ async function buildMainFilterV2(search: SalesSearchOptions): Promise { if (value?.length) { // when a single filter has multiple values, search ALL of them if (value.length > 1) { - if (value.length > 50 && filter === 'collection_name') { + if (value.length > MAX_INDEXED_COLLECTION_NAMES && filter === 'collection_name') { // Not a GIN-array predicate and unindexed (compares filter[1] per row), // so the hasMainGinFilter guard's bounded GIN-bitmap path does not exist // here; forcing the hint would only trade the backward btree scan for a diff --git a/src/api/namespaces/atomicmarket/handlers/template-buyoffers.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/template-buyoffers.integration.test.ts index 8dac3a67..041f51d9 100644 --- a/src/api/namespaces/atomicmarket/handlers/template-buyoffers.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/template-buyoffers.integration.test.ts @@ -3,7 +3,7 @@ import {initAtomicMarketTest} from '../test'; import {RequestValues} from '../../utils'; import {getTestContext} from '../../../../utils/test'; import {getTemplateBuyOfferAction, getTemplateBuyOffersAction, getTemplateBuyOffersCountAction} from './template-buyoffers'; -import {createMintAsset, defineEffectiveMintSuite} from '../effective-mint-suite'; +import {createMintAsset, defineEffectiveMintSuite, storedTemplateRange} from '../effective-mint-suite'; import {ApiError} from '../../../error'; // TODO add more tests @@ -234,13 +234,16 @@ describe('template buy offer handler', () => { }); defineEffectiveMintSuite({ + canOverrideStoredRange: true, client, txit, makeListing: async (collectionName, assets, templateMint) => { const {template_id} = await client.createTemplate({collection_name: collectionName}); const listing = await client.createTemplateBuyOffer({ collection_name: collectionName, template_id, - ...(templateMint === undefined ? {} : {template_mint: `[${templateMint},${templateMint}]`}), + template_mint: templateMint === undefined + ? storedTemplateRange(assets) + : (templateMint === null ? null : `[${templateMint},${templateMint}]`), }); for (const [i, spec] of assets.entries()) { const asset_id = await createMintAsset(client, collectionName, spec); diff --git a/src/api/namespaces/atomicmarket/openapi.ts b/src/api/namespaces/atomicmarket/openapi.ts index 204445bc..29a25c5e 100644 --- a/src/api/namespaces/atomicmarket/openapi.ts +++ b/src/api/namespaces/atomicmarket/openapi.ts @@ -392,7 +392,7 @@ export const effectiveMintParameters = [ name: 'min_effective_mint', in: 'query', description: 'Min mint of the listing assets: the original mint of a bridged asset, else the template mint. ' + - 'Requires collection_name.', + 'Requires collection_name, at most 50 names.', required: false, schema: {type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER} }, @@ -400,7 +400,7 @@ export const effectiveMintParameters = [ name: 'max_effective_mint', in: 'query', description: 'Max mint of the listing assets: the original mint of a bridged asset, else the template mint. ' + - 'Requires collection_name.', + 'Requires collection_name, at most 50 names.', required: false, schema: {type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER} } diff --git a/src/api/namespaces/atomicmarket/utils.ts b/src/api/namespaces/atomicmarket/utils.ts index 97f3e921..4f41754f 100644 --- a/src/api/namespaces/atomicmarket/utils.ts +++ b/src/api/namespaces/atomicmarket/utils.ts @@ -79,7 +79,12 @@ async function buildListingFilter(values: FilterValues, query: QueryBuilder, min } /** The rows of one listing's assets, with the asset as `a` and its link row as `om`. */ -export type EffectiveMintSource = {from: string, where: string}; +export type EffectiveMintSource = { + from: string, + where: string, + // Scalar test that no asset of the listing has a link row with an original mint. + unlinked: string, +}; function listingAssetsMintSource( table: string, contractColumn: string, where: string @@ -89,6 +94,9 @@ function listingAssetsMintSource( `JOIN atomicassets_assets a ON a.contract = la.${contractColumn} AND a.asset_id = la.asset_id ` + `LEFT JOIN atomicassets_original_mints om ON om.contract = la.${contractColumn} AND om.asset_id = la.asset_id`, where, + unlinked: `(SELECT COUNT(*) FROM ${table} la ` + + `JOIN atomicassets_original_mints om ON om.contract = la.${contractColumn} AND om.asset_id = la.asset_id ` + + `WHERE ${where} AND om.original_mint IS NOT NULL) = 0`, }; } @@ -113,10 +121,17 @@ export const SALES_FILTER_MINT_SOURCE: EffectiveMintSource = { from: 'atomicassets_assets a ' + 'LEFT JOIN atomicassets_original_mints om ON om.contract = a.contract AND om.asset_id = a.asset_id', where: 'a.contract = listing.assets_contract AND a.asset_id = ANY(listing.asset_ids)', + unlinked: '(SELECT COUNT(*) FROM atomicassets_original_mints om ' + + 'WHERE om.contract = listing.assets_contract AND om.asset_id = ANY(listing.asset_ids) ' + + 'AND om.original_mint IS NOT NULL) = 0', }; const EFFECTIVE_MINT = 'COALESCE(om.original_mint, a.template_mint)'; +// The sales filter query switches from the indexed array predicate to an unindexed per-row +// predicate above this many collection names, so the mint parameters stop at the same count. +export const MAX_INDEXED_COLLECTION_NAMES = 50; + async function requireMintScope(values: FilterValues): Promise { const args = await filterQueryArgs(values, {collection_name: {type: 'list[name]'}}); @@ -125,10 +140,24 @@ async function requireMintScope(values: FilterValues): Promise { if (!args.collection_name.length) { throw new ApiError('The effective mint filters and sort require collection_name', 400); } + + if (args.collection_name.length > MAX_INDEXED_COLLECTION_NAMES) { + throw new ApiError( + `The effective mint filters and sort accept at most ${MAX_INDEXED_COLLECTION_NAMES} collection names`, 400 + ); + } +} + +// No link with a stored range reads that range (exclusive upper bound), so cost stays flat as +// asset rows grow. No link with no range yet, or a linked listing, reads one scalar aggregate. +// An empty range has NULL bounds, which int8range reads as unbounded, so test empty first. +// The link test avoids EXISTS because the planner turns it into a slower join. +const STORED_RANGE = 'int8range(lower(listing.template_mint)::bigint, upper(listing.template_mint)::bigint)'; + +function readsStoredRange(source: EffectiveMintSource): string { + return `listing.template_mint IS NOT NULL AND ${source.unlinked}`; } -// One scalar aggregate per listing: the planner cannot reorder it, and the join -// and array-overlap forms measured far slower on production-size sales tables. export async function buildEffectiveMintFilter( values: FilterValues, query: QueryBuilder, source: EffectiveMintSource ): Promise { @@ -147,13 +176,17 @@ export async function buildEffectiveMintFilter( throw new ApiError('Min effective mint can\'t be greater than max effective mint', 400); } + const bounds = 'int8range(' + + query.addVariable(args.min_effective_mint ?? null) + ', ' + + query.addVariable(args.max_effective_mint ?? null) + ', \'[]\')'; + // HAVING makes a listing without any mint a NULL range, which never matches. query.addCondition( - `(SELECT int8range(MIN(${EFFECTIVE_MINT}), MAX(${EFFECTIVE_MINT}), '[]') ` + + `CASE WHEN ${readsStoredRange(source)} ` + + `THEN listing.template_mint <> 'empty' AND ${STORED_RANGE} <@ ${bounds} ` + + `ELSE (SELECT int8range(MIN(${EFFECTIVE_MINT}), MAX(${EFFECTIVE_MINT}), '[]') ` + `FROM ${source.from} WHERE ${source.where} ` + - `HAVING COUNT(${EFFECTIVE_MINT}) > 0) <@ int8range(` + - query.addVariable(args.min_effective_mint ?? null) + ', ' + - query.addVariable(args.max_effective_mint ?? null) + ', \'[]\')' + `HAVING COUNT(${EFFECTIVE_MINT}) > 0) <@ ${bounds} END` ); } @@ -163,7 +196,8 @@ export async function buildEffectiveMintSort( ): Promise { await requireMintScope(values); - const column = `(SELECT MIN(${EFFECTIVE_MINT}) FROM ${source.from} WHERE ${source.where})`; + const column = `CASE WHEN ${readsStoredRange(source)} THEN lower(listing.template_mint)::bigint ` + + `ELSE (SELECT MIN(${EFFECTIVE_MINT}) FROM ${source.from} WHERE ${source.where}) END`; query.addCondition(`${column} IS NOT NULL`); return column;