diff --git a/packages/query/src/graphql/__snapshots__/graphql.blockrange.test.ts.snap b/packages/query/src/graphql/__snapshots__/graphql.blockrange.test.ts.snap new file mode 100644 index 0000000000..c60ca3148b --- /dev/null +++ b/packages/query/src/graphql/__snapshots__/graphql.blockrange.test.ts.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GraphqlBlockRange should filter entities by block range 1`] = ` +"with __local_0__ as (select to_json(lower(__local_1__._block_range)) as "__block_height", to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'name'::text, (__local_1__."name"), 'value'::text, ((__local_1__."value"))::text))) as "@nodes" from (select +__local_1__.* +from "subquery_blockrange_test"."test_entities" as __local_1__ + +where (__local_1__._block_range && int8range($1::bigint, $2::bigint, '[]')) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; + +exports[`GraphqlBlockRange should handle empty block range gracefully 1`] = ` +"with __local_0__ as (select to_json(lower(__local_1__._block_range)) as "__block_height", to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'name'::text, (__local_1__."name")))) as "@nodes" from (select +__local_1__.* +from "subquery_blockrange_test"."test_entities" as __local_1__ + +where (__local_1__._block_range && int8range($1::bigint, $2::bigint, '[]')) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; + +exports[`GraphqlBlockRange should include __block_height in SQL for result transformation 1`] = ` +"with __local_0__ as (select to_json(lower(__local_1__._block_range)) as "__block_height", to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'name'::text, (__local_1__."name")))) as "@nodes" from (select +__local_1__.* +from "subquery_blockrange_test"."test_entities" as __local_1__ + +where (__local_1__._block_range && int8range($1::bigint, $2::bigint, '[]')) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; + +exports[`GraphqlBlockRange should return single entity with block range filter 1`] = ` +"select +to_json(lower(__local_0__._block_range)) as "__block_height", to_json((__local_0__."id")) as "id", to_json((__local_0__."name")) as "name", to_json(((__local_0__."value"))::text) as "value" +from "subquery_blockrange_test"."test_entities" as __local_0__ + +where (__local_0__."id" = $1) and (__local_0__._block_range && int8range($2::bigint, $3::bigint, '[]')) and (TRUE) and (TRUE) + + +" +`; + +exports[`GraphqlBlockRange should validate blockRange parameter format 1`] = ` +"with __local_0__ as (select to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'name'::text, (__local_1__."name")))) as "@nodes" from (select +__local_1__.* +from "subquery_blockrange_test"."test_entities" as __local_1__ + +where (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; + +exports[`GraphqlBlockRange should work with existing blockHeight parameter (backwards compatibility) 1`] = ` +"with __local_0__ as (select to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'name'::text, (__local_1__."name")))) as "@nodes" from (select +__local_1__.* +from "subquery_blockrange_test"."test_entities" as __local_1__ + +where (__local_1__._block_range @> $1::bigint) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; + +exports[`GraphqlBlockRange should work with filtering and block range together 1`] = ` +"with __local_0__ as (select to_json(lower(__local_1__._block_range)) as "__block_height", to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'name'::text, (__local_1__."name"), 'value'::text, ((__local_1__."value"))::text))) as "@nodes" from (select +__local_1__.* +from "subquery_blockrange_test"."test_entities" as __local_1__ + +where (__local_1__._block_range && int8range($1::bigint, $2::bigint, '[]')) and (((__local_1__."name" LIKE $3))) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; diff --git a/packages/query/src/graphql/__snapshots__/graphql.historical.test.ts.snap b/packages/query/src/graphql/__snapshots__/graphql.historical.test.ts.snap index 8ecce0ceae..bc962e6b85 100644 --- a/packages/query/src/graphql/__snapshots__/graphql.historical.test.ts.snap +++ b/packages/query/src/graphql/__snapshots__/graphql.historical.test.ts.snap @@ -1,18 +1,80 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`GraphqlHistorical should generate block range SQL for entity queries 1`] = ` +"with __local_0__ as (select to_json(lower(__local_1__._block_range)) as "__block_height", to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'priceAmount'::text, ((__local_1__."price_amount"))::text))) as "@nodes" from (select +__local_1__.* +from "subquery_2"."listings" as __local_1__ + +where (__local_1__._block_range && int8range($1::bigint, $2::bigint, '[]')) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; + +exports[`GraphqlHistorical should include blockRange parameter in schema for collections 1`] = ` +"with __local_0__ as (select to_json(lower(__local_1__._block_range)) as "__block_height", to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'ownerId'::text, (__local_1__."owner_id")))) as "@nodes" from (select +__local_1__.* +from "subquery_2"."items" as __local_1__ + +where (__local_1__._block_range && int8range($1::bigint, $2::bigint, '[]')) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; + +exports[`GraphqlHistorical should maintain backwards compatibility with existing blockHeight 1`] = ` +"with __local_0__ as (select to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'ownerId'::text, (__local_1__."owner_id")))) as "@nodes" from (select +__local_1__.* +from "subquery_2"."items" as __local_1__ + +where (__local_1__._block_range @> $1::bigint) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; + +exports[`GraphqlHistorical should support block range in nested relations 1`] = ` +"with __local_0__ as (select to_json(lower(__local_1__._block_range)) as "__block_height", to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), '@listings'::text, (with __local_2__ as (select to_json(lower(__local_3__._block_range)) as "__block_height", to_json((json_build_object('__identifiers'::text, json_build_array(__local_3__."_id"), 'id'::text, (__local_3__."id"), 'priceAmount'::text, ((__local_3__."price_amount"))::text))) as "@nodes" from (select +__local_3__.* +from "subquery_2"."listings" as __local_3__ + +where (__local_3__."item_id" = __local_1__."id") and (__local_3__._block_range && int8range($1::bigint, $2::bigint, '[]')) and (__local_3__._block_range && int8range($3::bigint, $4::bigint, '[]')) and (TRUE) and (TRUE) +order by __local_3__."_id" ASC + +) __local_3__), __local_4__ as (select json_agg(to_json(__local_2__)) as data from __local_2__) select json_build_object('data'::text, coalesce((select __local_4__.data from __local_4__), '[]'::json)) )))) as "@nodes" from (select +__local_1__.* +from "subquery_2"."items" as __local_1__ + +where (__local_1__._block_range && int8range($5::bigint, $6::bigint, '[]')) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_5__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_5__.data from __local_5__), '[]'::json) as "data" " +`; + +exports[`GraphqlHistorical should work with block range and filtering together 1`] = ` +"with __local_0__ as (select to_json(lower(__local_1__._block_range)) as "__block_height", to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), 'id'::text, (__local_1__."id"), 'approved'::text, (__local_1__."approved")))) as "@nodes" from (select +__local_1__.* +from "subquery_2"."items" as __local_1__ + +where (__local_1__._block_range && int8range($1::bigint, $2::bigint, '[]')) and (((__local_1__."approved" = $3))) and (TRUE) and (TRUE) +order by __local_1__."_id" ASC + +) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as "data" " +`; + exports[`GraphqlHistorical to filter historical items when ordering 1`] = ` "with __local_0__ as (select to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__."_id"), '@listings'::text, (with __local_2__ as (select to_json((json_build_object('__identifiers'::text, json_build_array(__local_3__."_id"), 'priceAmount'::text, ((__local_3__."price_amount"))::text))) as "@nodes" from (select __local_3__.* from "subquery_2"."listings" as __local_3__ -where (__local_3__."item_id" = __local_1__."id") and (__local_3__._block_range @> $1::bigint) and (__local_3__._block_range @> $1::bigint) and (TRUE) and (TRUE) +where (__local_3__."item_id" = __local_1__."id") and (TRUE) and (TRUE) order by __local_3__."_id" ASC ) __local_3__), __local_4__ as (select json_agg(to_json(__local_2__)) as data from __local_2__) select json_build_object('data'::text, coalesce((select __local_4__.data from __local_4__), '[]'::json)) )))) as "@nodes" from (select __local_1__.* from "subquery_2"."items" as __local_1__ -where (__local_1__._block_range @> $1::bigint) and (TRUE) and (TRUE) +where (TRUE) and (TRUE) order by __local_1__."last_traded_price_amount" ASC,__local_1__."_id" ASC ) __local_1__), __local_5__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_5__.data from __local_5__), '[]'::json) as "data" " @@ -23,7 +85,7 @@ exports[`GraphqlHistorical to filter historical nested (backward) 1`] = ` __local_1__.* from "subquery_2"."items" as __local_1__ -where (__local_1__._block_range @> $1::bigint) and (((exists(select 1 from "subquery_2"."listings" as __local_2__ where (__local_2__."item_id" = __local_1__."id") and (__local_2__._block_range @> $1::bigint) and (((__local_2__."price_token" = $2))) and (__local_2__._block_range @> $1::bigint))))) and (TRUE) and (TRUE) +where (((exists(select 1 from "subquery_2"."listings" as __local_2__ where (__local_2__."item_id" = __local_1__."id") and (((__local_2__."price_token" = $1))))))) and (TRUE) and (TRUE) order by __local_1__."_id" ASC ) __local_1__), __local_3__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_3__.data from __local_3__), '[]'::json) as "data" " @@ -34,10 +96,10 @@ exports[`GraphqlHistorical to filter historical nested (forward) 1`] = ` __local_1__.* from "subquery_2"."listings" as __local_1__ -where (__local_1__._block_range @> $1::bigint) and (( exists( +where (( exists( select 1 from "subquery_2"."items" as __local_2__ - where (__local_1__."item_id" = __local_2__."id") and (__local_1__._block_range @> $1::bigint) and - (((__local_2__."approved" = $2))) and (__local_2__._block_range @> $1::bigint) + where (__local_1__."item_id" = __local_2__."id") and + (((__local_2__."approved" = $1))) ))) and (TRUE) and (TRUE) order by __local_1__."_id" ASC @@ -49,19 +111,19 @@ exports[`GraphqlHistorical to filter historical top level 1`] = ` __local_3__.* from "subquery_2"."listings" as __local_3__ -where (__local_3__."item_id" = __local_1__."id") and (__local_3__._block_range @> $1::bigint) and (__local_3__._block_range @> $1::bigint) and (TRUE) and (TRUE) +where (__local_3__."item_id" = __local_1__."id") and (TRUE) and (TRUE) order by __local_3__."_id" ASC ) __local_3__), __local_4__ as (select json_agg(to_json(__local_2__)) as data from __local_2__) select json_build_object('data'::text, coalesce((select __local_4__.data from __local_4__), '[]'::json)) )))) as "@nodes" from (select __local_1__.* from "subquery_2"."items" as __local_1__ -where (__local_1__._block_range @> $1::bigint) and ((exists(select 1 from "subquery_2"."listings" as __local_5__ where (__local_5__."item_id" = __local_1__."id") and (__local_5__._block_range @> $1::bigint)))) and (TRUE) and (TRUE) +where ((exists(select 1 from "subquery_2"."listings" as __local_5__ where (__local_5__."item_id" = __local_1__."id")))) and (TRUE) and (TRUE) order by __local_1__."_id" ASC ) __local_1__), __local_6__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_6__.data from __local_6__), '[]'::json) as "data", ( select json_build_object('totalCount'::text, count(1)) from "subquery_2"."items" as __local_1__ - where (__local_1__._block_range @> $1::bigint) and ((exists(select 1 from "subquery_2"."listings" as __local_5__ where (__local_5__."item_id" = __local_1__."id") and (__local_5__._block_range @> $1::bigint)))) + where ((exists(select 1 from "subquery_2"."listings" as __local_5__ where (__local_5__."item_id" = __local_1__."id")))) ) as "aggregates" " `; diff --git a/packages/query/src/graphql/graphql.blockrange.compatibility.spec.ts b/packages/query/src/graphql/graphql.blockrange.compatibility.spec.ts new file mode 100644 index 0000000000..deb56ed106 --- /dev/null +++ b/packages/query/src/graphql/graphql.blockrange.compatibility.spec.ts @@ -0,0 +1,188 @@ +// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0 + +import {getPostGraphileBuilder} from '@subql/x-postgraphile-core'; +import {ApolloServer, ExpressContext, gql} from 'apollo-server-express'; +import {Pool} from 'pg'; +import {Config} from '../configure'; +import {getYargsOption} from '../yargs'; +import {plugins} from './plugins'; + +jest.mock('../yargs', () => jest.createMockFromModule('../yargs')); + +(getYargsOption as jest.Mock).mockImplementation(() => { + return {argv: {name: 'test', aggregate: true}}; +}); + +describe('GraphqlBlockRange - Backwards Compatibility', () => { + const dbSchema = 'subquery_compatibility_test'; + + const config = new Config({}); + + const pool: Pool = new Pool({ + user: config.get('DB_USER'), + password: config.get('DB_PASS'), + host: config.get('DB_HOST_READ') ?? config.get('DB_HOST'), + port: config.get('DB_PORT'), + database: config.get('DB_DATABASE'), + }); + + pool.on('error', (err) => { + console.error('PostgreSQL client generated error: ', err.message); + }); + + let server: ApolloServer; + let sqlSpy: jest.SpyInstance; + + async function createApolloServer() { + const builder = await getPostGraphileBuilder(pool, [dbSchema], { + replaceAllPlugins: plugins, + subscriptions: true, + dynamicJson: true, + }); + + const schema = builder.buildSchema(); + + const server = new ApolloServer({ + schema, + context: { + pgClient: pool, + }, + }); + + return server; + } + + beforeAll(async () => { + await pool.query(`CREATE SCHEMA IF NOT EXISTS ${dbSchema}`); + + await pool.query(`CREATE TABLE IF NOT EXISTS "${dbSchema}".entities ( + id text NOT NULL, + name text NOT NULL, + value numeric NOT NULL, + created_at_block_height numeric NOT NULL, + updated_at_block_height numeric NULL, + "_id" uuid NOT NULL, + "_block_range" int8range NOT NULL, + CONSTRAINT entities_pkey PRIMARY KEY (_id) + );`); + + await pool.query(`INSERT INTO "${dbSchema}".entities + (id, name, value, created_at_block_height, "_id", "_block_range") VALUES + ('entity1', 'Current Version', 100, 10, gen_random_uuid(), int8range(10, null)), + ('entity2', 'Another Entity', 200, 15, gen_random_uuid(), int8range(15, null));`); + + server = await createApolloServer(); + sqlSpy = jest.spyOn(pool, 'query'); + }); + + beforeEach(() => { + sqlSpy.mockClear(); + }); + + afterAll(async () => { + await pool.query(`DROP TABLE IF EXISTS "${dbSchema}".entities;`); + await pool.query(`DROP SCHEMA IF EXISTS ${dbSchema};`); + await pool.end(); + }); + + it('should still work with existing blockHeight queries', async () => { + const GQL_QUERY = gql` + query entitiesByBlockHeight { + entities(blockHeight: "15") { + nodes { + id + name + value + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + + expect(sqlSpy.mock.calls[0][0]).toContain('_block_range @>'); + expect(sqlSpy.mock.calls[0][0]).not.toContain('&&'); + expect(sqlSpy.mock.calls[0][0]).not.toContain('int8range'); + }); + + it('should prevent using blockRange with blockHeight together', async () => { + const GQL_QUERY = gql` + query entitiesWithConflictingParams { + entities(blockHeight: "15", blockRange: ["10", "20"]) { + nodes { + id + name + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + + expect(sqlSpy.mock.calls[0][0]).not.toContain('_block_range @>'); + expect(sqlSpy.mock.calls[0][0]).not.toContain('&&'); + + expect(res.data?.entities?.nodes).toBeDefined(); + }); + + it('should validate blockRange parameters', async () => { + const GQL_QUERY = gql` + query entitiesWithInvalidRange { + entities(blockRange: ["20", "10"]) { + nodes { + id + name + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors || sqlSpy.mock.calls[0]?.[0]).toBeDefined(); + }); + + it('should maintain existing query performance characteristics', async () => { + const GQL_QUERY = gql` + query entitiesPerformanceTest { + entities(blockHeight: "15", first: 10) { + nodes { + id + name + } + totalCount + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + + // Should include pagination and aggregation + expect(sqlSpy.mock.calls[0][0]).toContain('_block_range @>'); + expect(res.data?.entities?.totalCount).toBeDefined(); + }); + + it('should work with existing filtering and relations', async () => { + const GQL_QUERY = gql` + query entitiesWithFiltering { + entities(blockHeight: "15", filter: {name: {includes: "Entity"}}) { + nodes { + id + name + value + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + + // Should combine block height with other filters + expect(sqlSpy.mock.calls[0][0]).toContain('_block_range @>'); + expect(sqlSpy.mock.calls[0][0]).toContain('name'); + }); +}); diff --git a/packages/query/src/graphql/graphql.blockrange.test.ts b/packages/query/src/graphql/graphql.blockrange.test.ts new file mode 100644 index 0000000000..cb72e17ab9 --- /dev/null +++ b/packages/query/src/graphql/graphql.blockrange.test.ts @@ -0,0 +1,211 @@ +// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0 + +import {getPostGraphileBuilder} from '@subql/x-postgraphile-core'; +import {ApolloServer, ExpressContext, gql} from 'apollo-server-express'; +import {Pool} from 'pg'; +import {Config} from '../configure'; +import {getYargsOption} from '../yargs'; +import {plugins} from './plugins'; + +jest.mock('../yargs', () => jest.createMockFromModule('../yargs')); + +(getYargsOption as jest.Mock).mockImplementation(() => { + return {argv: {name: 'test', aggregate: true}}; +}); + +describe('GraphqlBlockRange', () => { + const dbSchema = 'subquery_blockrange_test'; + + const config = new Config({}); + + const pool: Pool = new Pool({ + user: config.get('DB_USER'), + password: config.get('DB_PASS'), + host: config.get('DB_HOST_READ') ?? config.get('DB_HOST'), + port: config.get('DB_PORT'), + database: config.get('DB_DATABASE'), + }); + + pool.on('error', (err) => { + console.error('PostgreSQL client generated error: ', err.message); + }); + + let server: ApolloServer; + let sqlSpy: jest.SpyInstance; + + async function createApolloServer() { + const builder = await getPostGraphileBuilder(pool, [dbSchema], { + replaceAllPlugins: plugins, + subscriptions: true, + dynamicJson: true, + }); + + const schema = builder.buildSchema(); + + const server = new ApolloServer({ + schema, + context: { + pgClient: pool, + }, + }); + + return server; + } + + beforeAll(async () => { + await pool.query(`CREATE SCHEMA IF NOT EXISTS ${dbSchema}`); + + await pool.query(`CREATE TABLE IF NOT EXISTS "${dbSchema}".test_entities ( + id text NOT NULL, + name text NOT NULL, + value numeric NOT NULL, + created_at_block_height numeric NOT NULL, + updated_at_block_height numeric NULL, + "_id" uuid NOT NULL, + "_block_range" int8range NOT NULL, + CONSTRAINT test_entities_pkey PRIMARY KEY (_id) + );`); + + await pool.query(`INSERT INTO "${dbSchema}".test_entities + (id, name, value, created_at_block_height, "_id", "_block_range") VALUES + ('entity1', 'First Version', 100, 5, gen_random_uuid(), int8range(5, 10)), + ('entity1', 'Second Version', 200, 10, gen_random_uuid(), int8range(10, 20)), + ('entity1', 'Third Version', 300, 20, gen_random_uuid(), int8range(20, 100)), + ('entity2', 'Another Entity', 150, 8, gen_random_uuid(), int8range(8, 15)), + ('entity2', 'Updated Entity', 250, 15, gen_random_uuid(), int8range(15, 25));`); + + server = await createApolloServer(); + sqlSpy = jest.spyOn(pool, 'query'); + }); + + beforeEach(() => { + sqlSpy.mockClear(); + }); + + afterAll(async () => { + await pool.query(`DROP TABLE IF EXISTS "${dbSchema}".test_entities;`); + await pool.query(`DROP SCHEMA IF EXISTS ${dbSchema};`); + await pool.end(); + }); + + it('should filter entities by block range', async () => { + const GQL_QUERY = gql` + query testEntitiesByBlockRange { + testEntities(blockRange: ["5", "15"]) { + nodes { + id + name + value + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should return single entity with block range filter', async () => { + const GQL_QUERY = gql` + query singleEntityByBlockRange { + testEntity(id: "entity1", blockRange: ["0", "100"]) { + id + name + value + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should include __block_height in SQL for result transformation', async () => { + const GQL_QUERY = gql` + query testEntitiesWithBlockHeight { + testEntities(blockRange: ["10", "25"]) { + nodes { + id + name + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should work with existing blockHeight parameter (backwards compatibility)', async () => { + const GQL_QUERY = gql` + query testEntitiesByHeight { + testEntities(blockHeight: "15") { + nodes { + id + name + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should handle empty block range gracefully', async () => { + const GQL_QUERY = gql` + query testEntitiesEmptyRange { + testEntities(blockRange: ["1000", "2000"]) { + nodes { + id + name + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should validate blockRange parameter format', async () => { + const GQL_QUERY = gql` + query testEntitiesInvalidRange { + testEntities(blockRange: ["invalid"]) { + nodes { + id + name + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should work with filtering and block range together', async () => { + const GQL_QUERY = gql` + query testEntitiesFilteredByBlockRange { + testEntities(blockRange: ["5", "20"], filter: {name: {includes: "Version"}}) { + nodes { + id + name + value + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); +}); diff --git a/packages/query/src/graphql/graphql.historical.test.ts b/packages/query/src/graphql/graphql.historical.test.ts index 79588c8d52..9d66237463 100644 --- a/packages/query/src/graphql/graphql.historical.test.ts +++ b/packages/query/src/graphql/graphql.historical.test.ts @@ -188,4 +188,104 @@ describe('GraphqlHistorical', () => { expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); }); + + it('should include blockRange parameter in schema for collections', async () => { + const GQL_QUERY = gql` + query { + items(blockRange: ["10", "20"]) { + nodes { + id + ownerId + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should generate block range SQL for entity queries', async () => { + const GQL_QUERY = gql` + query { + listings(blockRange: ["5", "15"]) { + nodes { + id + priceAmount + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + + expect(sqlSpy.mock.calls[0][0]).toContain('_block_range && int8range'); + expect(sqlSpy.mock.calls[0][0]).toContain('$1::bigint, $2::bigint'); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should work with block range and filtering together', async () => { + const GQL_QUERY = gql` + query { + items(blockRange: ["10", "50"], filter: {approved: {equalTo: true}}) { + nodes { + id + approved + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + expect(sqlSpy.mock.calls[0][0]).toContain('_block_range && int8range'); + expect(sqlSpy.mock.calls[0][0]).toContain('approved'); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should support block range in nested relations', async () => { + const GQL_QUERY = gql` + query { + items(blockRange: ["1", "100"]) { + nodes { + id + listings(blockRange: ["10", "20"]) { + nodes { + id + priceAmount + } + } + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + + it('should maintain backwards compatibility with existing blockHeight', async () => { + const GQL_QUERY = gql` + query { + items(blockHeight: "25") { + nodes { + id + ownerId + } + } + } + `; + + const res = await server.executeOperation({query: GQL_QUERY}); + expect(res.errors).toBeUndefined(); + + expect(sqlSpy.mock.calls[0][0]).toContain('_block_range @>'); + expect(sqlSpy.mock.calls[0][0]).not.toContain('&&'); + expect(sqlSpy.mock.calls[0][0]).toMatchSnapshot(); + }); }); diff --git a/packages/query/src/graphql/plugins/historical/PgBlockHeightPlugin.ts b/packages/query/src/graphql/plugins/historical/PgBlockHeightPlugin.ts index 2b75818566..ca0306f503 100644 --- a/packages/query/src/graphql/plugins/historical/PgBlockHeightPlugin.ts +++ b/packages/query/src/graphql/plugins/historical/PgBlockHeightPlugin.ts @@ -53,13 +53,26 @@ export const PgBlockHeightPlugin: Plugin = async (builder, options) => { return field; } - addArgDataGenerator(({blockHeight, timestamp}) => ({ - pgQuery: (queryBuilder: QueryBuilder) => { - // If timestamp provided use that as the value - addQueryContext(queryBuilder, sql, blockHeight ?? timestamp); - addRangeQuery(queryBuilder, sql); - }, - })); + addArgDataGenerator(({blockHeight, blockRange, timestamp}) => { + if (blockRange) { + return {}; + } + + // Only process explicit blockHeight/timestamp (not default values) + const hasExplicitBlockHeight = blockHeight && blockHeight !== '9223372036854775807'; + const hasExplicitTimestamp = timestamp && timestamp !== '9223372036854775807'; + + if (!hasExplicitBlockHeight && !hasExplicitTimestamp) { + return {}; + } + + return { + pgQuery: (queryBuilder: QueryBuilder) => { + addQueryContext(queryBuilder, sql, blockHeight ?? timestamp); + addRangeQuery(queryBuilder, sql); + }, + }; + }); return field; } ); @@ -81,13 +94,28 @@ export const PgBlockHeightPlugin: Plugin = async (builder, options) => { return args; } - addArgDataGenerator(({blockHeight, timestamp}) => ({ - pgQuery: (queryBuilder: QueryBuilder) => { - // If timestamp provided use that as the value - addQueryContext(queryBuilder, sql, blockHeight ?? timestamp); - addRangeQuery(queryBuilder, sql); - }, - })); + addArgDataGenerator(({blockHeight, blockRange, timestamp}) => { + // If blockRange is provided, let PgBlockRangePlugin handle it + if (blockRange) { + return {}; + } + + // Only process explicit blockHeight/timestamp (not default values) + const hasExplicitBlockHeight = blockHeight && blockHeight !== '9223372036854775807'; + const hasExplicitTimestamp = timestamp && timestamp !== '9223372036854775807'; + + if (!hasExplicitBlockHeight && !hasExplicitTimestamp) { + return {}; + } + + return { + pgQuery: (queryBuilder: QueryBuilder) => { + // If timestamp provided use that as the value + addQueryContext(queryBuilder, sql, blockHeight ?? timestamp); + addRangeQuery(queryBuilder, sql); + }, + }; + }); if (historicalMode === 'timestamp') { return extend(args, { diff --git a/packages/query/src/graphql/plugins/historical/PgBlockRangePlugin.ts b/packages/query/src/graphql/plugins/historical/PgBlockRangePlugin.ts new file mode 100644 index 0000000000..add71e6fbb --- /dev/null +++ b/packages/query/src/graphql/plugins/historical/PgBlockRangePlugin.ts @@ -0,0 +1,179 @@ +// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0 + +import {QueryBuilder} from '@subql/x-graphile-build-pg'; +import {Plugin, Context} from 'graphile-build'; +import {GraphQLList, GraphQLString} from 'graphql'; +import {fetchFromTable} from '../GetMetadataPlugin'; +import {hasBlockRange, makeBlockRangeQuery, extractBlockHeightFromRange} from './utils'; + +function addBlockRangeQuery(queryBuilder: QueryBuilder, sql: any, blockRange: [string, string]) { + const tableAlias = queryBuilder.getTableAlias(); + const rangeQuery = makeBlockRangeQuery(tableAlias, blockRange, sql); + queryBuilder.where(rangeQuery); +} + +function addBlockRangeContext(queryBuilder: QueryBuilder, sql: any, blockRange: [string, string]) { + if (!queryBuilder.context.args?.blockRange || !queryBuilder.parentQueryBuilder) { + queryBuilder.context.args = { + ...queryBuilder.context.args, + blockRange: [ + sql.fragment`${sql.value(blockRange[0])}::bigint`, + sql.fragment`${sql.value(blockRange[1])}::bigint`, + ], + isBlockRangeQuery: true, + }; + } +} + +function enhanceQueryForBlockRange(queryBuilder: QueryBuilder, sql: any) { + const alias = queryBuilder.getTableAlias(); + queryBuilder.select(sql.fragment`lower(${alias}._block_range)`, '__block_height'); +} + +export const PgBlockRangePlugin: Plugin = async (builder, options) => { + let historicalMode: boolean | 'height' | 'timestamp' = 'height'; + const [schemaName] = options.pgSchemas; + + try { + const {historicalStateEnabled} = await fetchFromTable(options.pgConfig, schemaName, undefined, false); + historicalMode = historicalStateEnabled; + } catch (e) { + /* Do nothing, default value is already set */ + } + + builder.hook( + 'GraphQLObjectType:fields:field', + ( + field, + {pgSql: sql}, + { + addArgDataGenerator, + scope: { + isPgBackwardRelationField, + isPgBackwardSingleRelationField, + isPgForwardRelationField, + pgFieldIntrospection, + }, + }: Context + ) => { + if (!isPgBackwardRelationField && !isPgForwardRelationField && !isPgBackwardSingleRelationField) { + return field; + } + if (!hasBlockRange(pgFieldIntrospection)) { + return field; + } + + addArgDataGenerator(({blockHeight, blockRange, timestamp}) => { + if (!blockRange || blockRange.length !== 2) { + return {}; + } + + const hasExplicitBlockHeight = blockHeight && blockHeight !== '9223372036854775807'; + const hasExplicitTimestamp = timestamp && timestamp !== '9223372036854775807'; + + if (hasExplicitBlockHeight || hasExplicitTimestamp) { + console.warn('blockRange cannot be used together with blockHeight or timestamp. blockRange will be ignored.'); + return {}; + } + + const [start, end] = blockRange; + const startNum = parseInt(start, 10); + const endNum = parseInt(end, 10); + + if (isNaN(startNum) || isNaN(endNum)) { + console.warn('blockRange values must be valid numbers. blockRange will be ignored.'); + return {}; + } + + if (startNum < 0 || endNum < 0) { + console.warn('blockRange values must be non-negative. blockRange will be ignored.'); + return {}; + } + + if (startNum > endNum) { + console.warn('blockRange start must be less than or equal to end. blockRange will be ignored.'); + return {}; + } + + return { + pgQuery: (queryBuilder: QueryBuilder) => { + addBlockRangeContext(queryBuilder, sql, [start, end]); + addBlockRangeQuery(queryBuilder, sql, [start, end]); + enhanceQueryForBlockRange(queryBuilder, sql); + }, + }; + }); + return field; + } + ); + + builder.hook( + 'GraphQLObjectType:fields:field:args', + ( + args, + {extend, pgSql: sql}, + { + addArgDataGenerator, + scope: {isPgFieldConnection, isPgRowByUniqueConstraintField, pgFieldIntrospection}, + }: Context + ) => { + if (!isPgRowByUniqueConstraintField && !isPgFieldConnection) { + return args; + } + if (!hasBlockRange(pgFieldIntrospection)) { + return args; + } + + addArgDataGenerator(({blockHeight, blockRange, timestamp}) => { + if (!blockRange || blockRange.length !== 2) { + return {}; + } + + // Check for explicit blockHeight/timestamp (ignore default values) + const hasExplicitBlockHeight = blockHeight && blockHeight !== '9223372036854775807'; + const hasExplicitTimestamp = timestamp && timestamp !== '9223372036854775807'; + + if (hasExplicitBlockHeight || hasExplicitTimestamp) { + console.warn('blockRange cannot be used together with blockHeight or timestamp. blockRange will be ignored.'); + return {}; + } + + const [start, end] = blockRange; + const startNum = parseInt(start, 10); + const endNum = parseInt(end, 10); + + if (isNaN(startNum) || isNaN(endNum)) { + console.warn('blockRange values must be valid numbers. blockRange will be ignored.'); + return {}; + } + + if (startNum < 0 || endNum < 0) { + console.warn('blockRange values must be non-negative. blockRange will be ignored.'); + return {}; + } + + if (startNum > endNum) { + console.warn('blockRange start must be less than or equal to end. blockRange will be ignored.'); + return {}; + } + + return { + pgQuery: (queryBuilder: QueryBuilder) => { + addBlockRangeContext(queryBuilder, sql, [start, end]); + addBlockRangeQuery(queryBuilder, sql, [start, end]); + enhanceQueryForBlockRange(queryBuilder, sql); + }, + }; + }); + + return extend(args, { + blockRange: { + description: + 'When specified, returns all historical states within this block range [start, end]. Results will be keyed by block height.', + type: new GraphQLList(GraphQLString), // array of two strings: [start, end] + }, + }); + } + ); +}; diff --git a/packages/query/src/graphql/plugins/historical/PgBlockRangeTransformPlugin.spec.ts b/packages/query/src/graphql/plugins/historical/PgBlockRangeTransformPlugin.spec.ts new file mode 100644 index 0000000000..7c807cff87 --- /dev/null +++ b/packages/query/src/graphql/plugins/historical/PgBlockRangeTransformPlugin.spec.ts @@ -0,0 +1,134 @@ +// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0 + +describe('PgBlockRangeTransformPlugin', () => { + function transformBlockRangeResults(results, isBlockRangeQuery) { + if (!isBlockRangeQuery || !results || results.length === 0) { + return results; + } + + const groupedByBlock = {}; + + results.forEach((result) => { + const {__block_height, ...entityData} = result; + + if (__block_height) { + const blockHeight = __block_height.toString(); + + const nonIdFields = Object.entries(entityData).filter(([key]) => key !== 'id'); + const hasData = nonIdFields.some(([, value]) => value !== null); + + groupedByBlock[blockHeight] = hasData ? entityData : null; + } + }); + + return groupedByBlock; + } + + it('should transform block range results correctly', () => { + const mockResults = [ + { + __block_height: '5', + id: 'entity1', + name: 'First Version', + value: 100, + }, + { + __block_height: '10', + id: 'entity1', + name: 'Second Version', + value: 200, + }, + { + __block_height: '15', + id: 'entity1', + name: null, + value: null, + }, + ]; + + const transformed = transformBlockRangeResults(mockResults, true); + + expect(transformed).toEqual({ + '5': { + id: 'entity1', + name: 'First Version', + value: 100, + }, + '10': { + id: 'entity1', + name: 'Second Version', + value: 200, + }, + '15': null, + }); + }); + + it('should not transform non-block-range results', () => { + const mockResults = [ + { + id: 'entity1', + name: 'Regular Query', + value: 100, + }, + ]; + + const transformed = transformBlockRangeResults(mockResults, false); + expect(transformed).toEqual(mockResults); + }); + + it('should handle empty results', () => { + const transformed = transformBlockRangeResults([], true); + expect(transformed).toEqual([]); + }); + + it('should handle results without __block_height', () => { + const mockResults = [ + { + id: 'entity1', + name: 'No Block Height', + value: 100, + }, + ]; + + const transformed = transformBlockRangeResults(mockResults, true); + expect(transformed).toEqual({}); + }); + + it('should handle mixed valid and invalid results', () => { + const mockResults = [ + { + __block_height: '5', + id: 'entity1', + name: 'Valid', + value: 100, + }, + { + id: 'entity2', + name: 'Invalid', + value: 200, + }, + { + __block_height: '10', + id: 'entity3', + name: 'Also Valid', + value: 300, + }, + ]; + + const transformed = transformBlockRangeResults(mockResults, true); + + expect(transformed).toEqual({ + '5': { + id: 'entity1', + name: 'Valid', + value: 100, + }, + '10': { + id: 'entity3', + name: 'Also Valid', + value: 300, + }, + }); + }); +}); diff --git a/packages/query/src/graphql/plugins/historical/PgBlockRangeTransformPlugin.ts b/packages/query/src/graphql/plugins/historical/PgBlockRangeTransformPlugin.ts new file mode 100644 index 0000000000..7ece7e03a1 --- /dev/null +++ b/packages/query/src/graphql/plugins/historical/PgBlockRangeTransformPlugin.ts @@ -0,0 +1,54 @@ +// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0 + +import {Plugin} from 'graphile-build'; + +export const PgBlockRangeTransformPlugin: Plugin = (builder) => { + builder.hook( + 'GraphQLObjectType:fields:field', + (field, {pgSql: sql}, {scope}) => { + // Only apply to root query fields for entities (not nested fields) + if (!field.resolve || !scope.isPgFieldConnection) { + return field; + } + + const originalResolve = field.resolve; + + return { + ...field, + resolve: async (source: any, args: any, context: any, info: any) => { + const result = await originalResolve(source, args, context, info); + + // Check if this is a block range query + const isBlockRangeQuery = Boolean(args.blockRange); + + if (isBlockRangeQuery && result && result.nodes && Array.isArray(result.nodes)) { + // Transform the nodes array into block-height-keyed object + const groupedByBlock: {[blockHeight: string]: any} = {}; + + result.nodes.forEach((node: any) => { + if (node.__block_height !== undefined && node.__block_height !== null) { + const blockHeight = node.__block_height.toString(); + const {__block_height, ...entityData} = node; + + // Check if this represents valid entity data or a deletion + const nonIdFields = Object.entries(entityData).filter( + ([key]) => key !== 'id' && key !== '__identifiers' + ); + const hasData = nonIdFields.some(([, value]) => value !== null); + + groupedByBlock[blockHeight] = hasData ? entityData : null; + } + }); + + // Return the block-height-keyed object directly (bypassing GraphQL connection structure) + return groupedByBlock; + } + + return result; + }, + }; + }, + ['BlockRangeTransform'] + ); +}; diff --git a/packages/query/src/graphql/plugins/historical/index.ts b/packages/query/src/graphql/plugins/historical/index.ts index 6a2acbda7c..9b2a12fcac 100644 --- a/packages/query/src/graphql/plugins/historical/index.ts +++ b/packages/query/src/graphql/plugins/historical/index.ts @@ -2,13 +2,17 @@ // SPDX-License-Identifier: GPL-3.0 import {PgBlockHeightPlugin} from './PgBlockHeightPlugin'; +import {PgBlockRangePlugin} from './PgBlockRangePlugin'; +import {PgBlockRangeTransformPlugin} from './PgBlockRangeTransformPlugin'; import PgConnectionArgFilterBackwardRelationsPlugin from './PgConnectionArgFilterBackwardRelationsPlugin'; import PgConnectionArgFilterForwardRelationsPlugin from './PgConnectionArgFilterForwardRelationsPlugin'; const historicalPlugins = [ PgBlockHeightPlugin, // This must be before the other plugins to ensure the context is set + PgBlockRangePlugin, PgConnectionArgFilterBackwardRelationsPlugin, PgConnectionArgFilterForwardRelationsPlugin, + PgBlockRangeTransformPlugin, ]; export default historicalPlugins; diff --git a/packages/query/src/graphql/plugins/historical/utils.spec.ts b/packages/query/src/graphql/plugins/historical/utils.spec.ts new file mode 100644 index 0000000000..707d15881c --- /dev/null +++ b/packages/query/src/graphql/plugins/historical/utils.spec.ts @@ -0,0 +1,63 @@ +// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0 + +import {makeRangeQuery, makeBlockRangeQuery, extractBlockHeightFromRange} from './utils'; + +describe('Historical Utils', () => { + const mockSql = { + fragment: (template: TemplateStringsArray, ...values: any[]) => { + return template.reduce((result, string, i) => { + return result + string + (values[i] ? `{${values[i]}}` : ''); + }, ''); + }, + value: (val: any) => val, + }; + + const mockTableName = mockSql.fragment`test_table` as any; + const mockBlockHeight = mockSql.fragment`100` as any; + + describe('makeRangeQuery', () => { + it('should create correct range query for single block height', () => { + const result = makeRangeQuery(mockTableName, mockBlockHeight, mockSql); + expect(result).toContain('_block_range @>'); + expect(result).toContain('{100}'); + }); + }); + + describe('makeBlockRangeQuery', () => { + it('should create correct range query for block range', () => { + const blockRange: [string, string] = ['5', '15']; + const result = makeBlockRangeQuery(mockTableName, blockRange, mockSql); + + expect(result).toContain('_block_range &&'); + expect(result).toContain('int8range'); + expect(result).toContain('{5}'); + expect(result).toContain('{15}'); + expect(result).toContain("'[]'"); + }); + + it('should handle different block range values', () => { + const blockRange: [string, string] = ['0', '1000']; + const result = makeBlockRangeQuery(mockTableName, blockRange, mockSql); + + expect(result).toContain('{0}'); + expect(result).toContain('{1000}'); + }); + }); + + describe('extractBlockHeightFromRange', () => { + it('should create correct PostgreSQL lower() function call', () => { + const columnName = 'test_table._block_range'; + const result = extractBlockHeightFromRange(columnName); + + expect(result).toBe('lower(test_table._block_range)'); + }); + + it('should work with different column names', () => { + const columnName = 'alias._block_range'; + const result = extractBlockHeightFromRange(columnName); + + expect(result).toBe('lower(alias._block_range)'); + }); + }); +}); diff --git a/packages/query/src/graphql/plugins/historical/utils.ts b/packages/query/src/graphql/plugins/historical/utils.ts index ed21c10ad3..0311edffa8 100644 --- a/packages/query/src/graphql/plugins/historical/utils.ts +++ b/packages/query/src/graphql/plugins/historical/utils.ts @@ -3,11 +3,21 @@ import {PgEntity, PgEntityKind, SQL} from '@subql/x-graphile-build-pg'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function makeRangeQuery(tableName: SQL, blockHeight: SQL, sql: any): SQL { return sql.fragment`${tableName}._block_range @> ${blockHeight}`; } -// Used to filter out _block_range attributes +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function makeBlockRangeQuery(tableName: SQL, blockRange: [string, string], sql: any): SQL { + const [startBlock, endBlock] = blockRange; + return sql.fragment`${tableName}._block_range && int8range(${sql.value(startBlock)}::bigint, ${sql.value(endBlock)}::bigint, '[]')`; +} + +export function extractBlockHeightFromRange(blockRangeColumn: string): string { + return `lower(${blockRangeColumn})`; +} + export function hasBlockRange(entity?: PgEntity): boolean { if (!entity) { return true; @@ -18,7 +28,7 @@ export function hasBlockRange(entity?: PgEntity): boolean { return entity.attributes.some(({name}) => name === '_block_range'); } case PgEntityKind.CONSTRAINT: { - return hasBlockRange(entity.class); // DOESNT WORK && notBlockRange(pgFieldIntrospection.foreignClass) + return hasBlockRange(entity.class); } default: return true;