From dca3ddb0fe16c67e4535e6b09aa589e3bad4651d Mon Sep 17 00:00:00 2001 From: Timur Ramazanov Date: Mon, 15 Apr 2019 15:19:16 +0300 Subject: [PATCH 1/7] Asset holders query --- src/model/asset_id.ts | 2 ++ src/model/balance.ts | 9 +++++--- src/model/balance_values.ts | 5 ++-- src/model/index.ts | 1 + src/repo/assets.ts | 39 +++++++++++++++++++++++++++++++- src/schema/assets.ts | 6 +++++ src/schema/resolvers/asset.ts | 17 ++++++++++++-- src/schema/resolvers/balance.ts | 11 ++++++--- src/schema/resolvers/util.ts | 4 ++-- src/schema/type_defs.ts | 11 +++++++++ src/util/paging.ts | 24 ++++++++++++++++++++ tests/unit/model/balance.test.ts | 9 ++++---- 12 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 src/model/asset_id.ts diff --git a/src/model/asset_id.ts b/src/model/asset_id.ts new file mode 100644 index 00000000..5c7860ce --- /dev/null +++ b/src/model/asset_id.ts @@ -0,0 +1,2 @@ +// String like USD-GBSTRUSD7IRX73RQZBL3RQUH6KS3O4NYFY3QCALDLZD77XMZOPWAVTUK +export type AssetID = string; diff --git a/src/model/balance.ts b/src/model/balance.ts index 138d3fbd..e5b486d1 100644 --- a/src/model/balance.ts +++ b/src/model/balance.ts @@ -1,5 +1,4 @@ import { Asset } from "stellar-sdk"; -import { toFloatAmountString } from "../util/stellar"; import { AccountID } from "./account_id"; export interface IBalanceBase { @@ -24,10 +23,14 @@ export class Balance implements IBalance { constructor(data: IBalance) { this.account = data.account; - this.limit = toFloatAmountString(data.limit); - this.balance = toFloatAmountString(data.balance); + this.limit = data.limit; + this.balance = data.balance; this.lastModified = data.lastModified; this.authorized = data.authorized; this.asset = data.asset; } + + public get paging_token() { + return Buffer.from(`${this.account}_${this.asset.toString()}_${this.balance}`).toString("base64"); + } } diff --git a/src/model/balance_values.ts b/src/model/balance_values.ts index 0f275dbe..bd8e681d 100644 --- a/src/model/balance_values.ts +++ b/src/model/balance_values.ts @@ -1,5 +1,4 @@ import { Asset } from "stellar-sdk"; -import { toFloatAmountString } from "../util/stellar"; import { IBalanceBase } from "./balance"; export class BalanceValues implements IBalanceBase { @@ -11,8 +10,8 @@ export class BalanceValues implements IBalanceBase { constructor(data: IBalanceBase) { this.account = data.account; - this.limit = toFloatAmountString(data.limit); - this.balance = toFloatAmountString(data.balance); + this.limit = data.limit; + this.balance = data.balance; this.authorized = data.authorized; this.asset = data.asset; } diff --git a/src/model/index.ts b/src/model/index.ts index bd3897be..fbbb533a 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -4,6 +4,7 @@ export * from "./account_thresholds"; export * from "./account_flags"; export * from "./account_values"; export * from "./account_subscription_payload"; +export * from "./asset_id"; export * from "./asset_input"; export * from "./data_entry"; export * from "./data_entry_subscription_payload"; diff --git a/src/repo/assets.ts b/src/repo/assets.ts index 68d1e597..191a9915 100644 --- a/src/repo/assets.ts +++ b/src/repo/assets.ts @@ -1,6 +1,9 @@ import { IDatabase } from "pg-promise"; import squel from "squel"; -import { Asset } from "stellar-base"; +import { Asset } from "stellar-sdk"; +import { PagingParams } from "../datasource/horizon/base"; +import { BalanceFactory } from "../model/factories"; +import { parseCursorPagination, properlyOrdered, SortOrder } from "../util/paging"; export default class AssetsRepo { private db: IDatabase; @@ -39,4 +42,38 @@ export default class AssetsRepo { return res.map(a => new Asset(a.assetcode, a.issuer)); } + + public async findHolders(asset: Asset, paging: PagingParams) { + const queryBuilder = squel + .select() + .from("trustlines") + .where("assetcode = ?", asset.getCode()) + .where("issuer = ?", asset.getIssuer()); + + const { limit, cursor, order } = parseCursorPagination(paging); + + queryBuilder.limit(limit); + + // Order of these stetements is important, + // we must order by balance first to support cursor pagination + queryBuilder.order("balance", order === SortOrder.ASC); + queryBuilder.order("accountid"); + + if (cursor) { + const [, , balance] = Buffer.from(cursor, "base64") + .toString() + .split("_"); + + if (paging.after) { + queryBuilder.where("balance < ?", balance); + } else { + queryBuilder.where("balance > ?", balance); + } + } + + const res = await this.db.manyOrNone(queryBuilder.toString()); + const balances = res.map(r => BalanceFactory.fromDb(r)); + + return properlyOrdered(balances, paging); + } } diff --git a/src/schema/assets.ts b/src/schema/assets.ts index 39042f15..c7b92748 100644 --- a/src/schema/assets.ts +++ b/src/schema/assets.ts @@ -14,6 +14,8 @@ export const typeDefs = gql` issuer: Account "Asset's code" code: AssetCode! + "All accounts that trust this asset, ordered by balance" + holders(first: Int, last: Int, after: String, before: String): BalanceConnection } "Represents single [asset](https://www.stellar.org/developers/guides/concepts/assets.html) on Stellar network with additional statistics, provided by Horizon" @@ -30,6 +32,8 @@ export const typeDefs = gql` numAccounts: Int "Asset's issuer account flags" flags: AccountFlags + "All accounts that trust this asset, ordered by balance" + holders(first: Int, last: Int, after: String, before: String): BalanceConnection } "A list of assets" @@ -50,6 +54,8 @@ export const typeDefs = gql` } type Query { + "Get single asset" + asset(id: AssetID): Asset "Get list of assets" assets( code: AssetCode diff --git a/src/schema/resolvers/asset.ts b/src/schema/resolvers/asset.ts index 7b24a89f..8e2c6486 100644 --- a/src/schema/resolvers/asset.ts +++ b/src/schema/resolvers/asset.ts @@ -1,12 +1,25 @@ +import { Asset } from "stellar-sdk"; +import { db } from "../../database"; import { IHorizonAssetData } from "../../datasource/types"; import { IApolloContext } from "../../graphql_server"; +import { AssetID } from "../../model"; +import { AssetFactory } from "../../model/factories"; import * as resolvers from "./shared"; import { makeConnection } from "./util"; +const holdersResolver = async (root: Asset, args: any, ctx: IApolloContext, info: any) => { + const balances = await db.assets.findHolders(root, args); + + return makeConnection(balances); +}; + export default { - Asset: { issuer: resolvers.account }, - AssetWithInfo: { issuer: resolvers.account }, + Asset: { issuer: resolvers.account, holders: holdersResolver }, + AssetWithInfo: { issuer: resolvers.account, holders: holdersResolver }, Query: { + asset: async (root: any, args: { id: AssetID }, ctx: IApolloContext, info: any) => { + return AssetFactory.fromId(args.id); + }, assets: async (root: any, args: any, ctx: IApolloContext, info: any) => { const { code, issuer } = args; const records: IHorizonAssetData[] = await ctx.dataSources.assets.all( diff --git a/src/schema/resolvers/balance.ts b/src/schema/resolvers/balance.ts index 3bac396f..867c6dc0 100644 --- a/src/schema/resolvers/balance.ts +++ b/src/schema/resolvers/balance.ts @@ -1,7 +1,8 @@ import { withFilter } from "graphql-subscriptions"; import { IApolloContext } from "../../graphql_server"; -import { BalanceSubscriptionPayload } from "../../model"; +import { Balance, BalanceSubscriptionPayload } from "../../model"; import { BALANCE, pubsub } from "../../pubsub"; +import { toFloatAmountString } from "../../util/stellar"; import * as resolvers from "./shared"; import { eventMatches } from "./util"; @@ -24,7 +25,9 @@ export default { Balance: { account: resolvers.account, ledger: resolvers.ledger, - asset: resolvers.asset + asset: resolvers.asset, + limit: (root: Balance) => toFloatAmountString(root.limit), + balance: (root: Balance) => toFloatAmountString(root.balance) }, BalanceSubscriptionPayload: { account: resolvers.account, @@ -32,7 +35,9 @@ export default { }, BalanceValues: { account: resolvers.account, - asset: resolvers.asset + asset: resolvers.asset, + limit: (root: Balance) => toFloatAmountString(root.limit), + balance: (root: Balance) => toFloatAmountString(root.balance) }, Subscription: { balance: balanceSubscription(BALANCE) } }; diff --git a/src/schema/resolvers/util.ts b/src/schema/resolvers/util.ts index c96fdf6c..e192e737 100644 --- a/src/schema/resolvers/util.ts +++ b/src/schema/resolvers/util.ts @@ -22,10 +22,10 @@ export function idOnlyRequested(info: any): boolean { return false; } -export function makeConnection(records: T[], nodeBuilder: (r: T) => R) { +export function makeConnection(records: T[], nodeBuilder?: (r: T) => R) { const edges = records.map(record => { return { - node: nodeBuilder(record), + node: nodeBuilder ? nodeBuilder(record) : record, cursor: record.paging_token }; }); diff --git a/src/schema/type_defs.ts b/src/schema/type_defs.ts index fd88c5bf..5f71533c 100644 --- a/src/schema/type_defs.ts +++ b/src/schema/type_defs.ts @@ -72,6 +72,17 @@ export const typeDefs = gql` ledger: Ledger! } + type BalanceConnection { + pageInfo: PageInfo! + nodes: [Balance] + edges: [BalanceEdge] + } + + type BalanceEdge { + cursor: String! + node: Balance + } + "Represents a current [trustline](https://www.stellar.org/developers/guides/concepts/assets.html#trustlines) state, which is broadcasting to subscribers" type BalanceValues implements IBalance { account: Account diff --git a/src/util/paging.ts b/src/util/paging.ts index e648db50..18d15a0f 100644 --- a/src/util/paging.ts +++ b/src/util/paging.ts @@ -1,3 +1,5 @@ +import { PagingParams } from "../datasource/horizon/base"; + export enum SortOrder { DESC = "desc", ASC = "asc" @@ -10,3 +12,25 @@ export function invertSortOrder(order: SortOrder) { return SortOrder.DESC; } + +export function parseCursorPagination(args: PagingParams) { + const { first, after, last, before, order = SortOrder.DESC } = args; + + if (!first && !last) { + throw new Error("Missing paging parameters"); + } + + return { + limit: first || last, + order: before ? invertSortOrder(order) : order, + cursor: last ? before : after + }; +} + +export function properlyOrdered(records: any[], pagingParams: PagingParams): any[] { + if (pagingParams.last && pagingParams.before) { + return records.reverse(); + } + + return records; +} diff --git a/tests/unit/model/balance.test.ts b/tests/unit/model/balance.test.ts index 7a7863c9..51142754 100644 --- a/tests/unit/model/balance.test.ts +++ b/tests/unit/model/balance.test.ts @@ -2,7 +2,6 @@ import { Asset } from "stellar-sdk"; import { Balance } from "../../../src/model"; import { BalanceFactory } from "../../../src/model/factories"; import { MAX_INT64 } from "../../../src/util"; -import { toFloatAmountString } from "../../../src/util/stellar"; import AccountFactory from "../../factories/account"; const data = { @@ -25,8 +24,8 @@ describe("constructor", () => { it("sets account id", () => expect(subject.account).toEqual(data.accountid)); it("sets lastModified", () => expect(subject.lastModified).toEqual(data.lastmodified)); - it("formats limit", () => expect(subject.limit).toEqual("922337203685.4775807")); - it("formats balance", () => expect(subject.balance).toEqual("960.0000000")); + it("sets limit", () => expect(subject.limit).toEqual("9223372036854775807")); + it("sets balance", () => expect(subject.balance).toEqual("9600000000")); it("sets authorized", () => expect(subject.authorized).toBe(true)); it("sets asset", () => { expect(subject.asset).toBeInstanceOf(Asset); @@ -42,8 +41,8 @@ describe("static buildFakeNative(account)", () => { expect(fake).toMatchObject({ account: account.id, - balance: toFloatAmountString(account.balance), - limit: toFloatAmountString(MAX_INT64), + balance: account.balance, + limit: MAX_INT64, authorized: true, lastModified: account.lastModified }); From f1eea0d9f1f6f12b2f16eb8e942fc0ee9776eddb Mon Sep 17 00:00:00 2001 From: Timur Ramazanov Date: Mon, 15 Apr 2019 17:31:01 +0300 Subject: [PATCH 2/7] Handle zero balances on paging --- src/repo/assets.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/repo/assets.ts b/src/repo/assets.ts index 191a9915..7c28b243 100644 --- a/src/repo/assets.ts +++ b/src/repo/assets.ts @@ -60,14 +60,17 @@ export default class AssetsRepo { queryBuilder.order("accountid"); if (cursor) { - const [, , balance] = Buffer.from(cursor, "base64") + const [accountId, , balance] = Buffer.from(cursor, "base64") .toString() .split("_"); + queryBuilder.where("accountid != ?", accountId); + if (paging.after) { - queryBuilder.where("balance < ?", balance); + // <= and >= allow to handle zero balances + queryBuilder.where("balance <= ?", balance); } else { - queryBuilder.where("balance > ?", balance); + queryBuilder.where("balance >= ?", balance); } } From 79a4ed3ccc8507e45ddbe0d03faf4b7e3b51f33e Mon Sep 17 00:00:00 2001 From: Timur Ramazanov Date: Mon, 15 Apr 2019 17:32:05 +0300 Subject: [PATCH 3/7] `holders` -> `balances` --- src/schema/assets.ts | 4 ++-- src/schema/resolvers/asset.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/schema/assets.ts b/src/schema/assets.ts index c7b92748..4716ad24 100644 --- a/src/schema/assets.ts +++ b/src/schema/assets.ts @@ -15,7 +15,7 @@ export const typeDefs = gql` "Asset's code" code: AssetCode! "All accounts that trust this asset, ordered by balance" - holders(first: Int, last: Int, after: String, before: String): BalanceConnection + balances(first: Int, last: Int, after: String, before: String): BalanceConnection } "Represents single [asset](https://www.stellar.org/developers/guides/concepts/assets.html) on Stellar network with additional statistics, provided by Horizon" @@ -33,7 +33,7 @@ export const typeDefs = gql` "Asset's issuer account flags" flags: AccountFlags "All accounts that trust this asset, ordered by balance" - holders(first: Int, last: Int, after: String, before: String): BalanceConnection + balances(first: Int, last: Int, after: String, before: String): BalanceConnection } "A list of assets" diff --git a/src/schema/resolvers/asset.ts b/src/schema/resolvers/asset.ts index 8e2c6486..b742844f 100644 --- a/src/schema/resolvers/asset.ts +++ b/src/schema/resolvers/asset.ts @@ -14,8 +14,8 @@ const holdersResolver = async (root: Asset, args: any, ctx: IApolloContext, info }; export default { - Asset: { issuer: resolvers.account, holders: holdersResolver }, - AssetWithInfo: { issuer: resolvers.account, holders: holdersResolver }, + Asset: { issuer: resolvers.account, balances: holdersResolver }, + AssetWithInfo: { issuer: resolvers.account, balances: holdersResolver }, Query: { asset: async (root: any, args: { id: AssetID }, ctx: IApolloContext, info: any) => { return AssetFactory.fromId(args.id); From 4bc4cc594e736e90f28c4643e0da01c7bb650f4a Mon Sep 17 00:00:00 2001 From: Timur Ramazanov Date: Mon, 15 Apr 2019 18:21:15 +0300 Subject: [PATCH 4/7] Proper sorting by account in holders pagination --- src/repo/assets.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/repo/assets.ts b/src/repo/assets.ts index 7c28b243..aaeb0f83 100644 --- a/src/repo/assets.ts +++ b/src/repo/assets.ts @@ -64,13 +64,13 @@ export default class AssetsRepo { .toString() .split("_"); - queryBuilder.where("accountid != ?", accountId); - if (paging.after) { // <= and >= allow to handle zero balances queryBuilder.where("balance <= ?", balance); - } else { + queryBuilder.where("accountid > ?", accountId); + } else if (paging.before) { queryBuilder.where("balance >= ?", balance); + queryBuilder.where("accountid < ?", accountId); } } From 9ba34d538cc24cebd9bb25874fcdc881b725f2da Mon Sep 17 00:00:00 2001 From: Timur Ramazanov Date: Mon, 15 Apr 2019 23:11:53 +0300 Subject: [PATCH 5/7] Some more edge cases handled --- src/model/balance.ts | 7 ++++++- src/repo/assets.ts | 35 +++++++++++++++++------------------ src/util/paging.ts | 8 ++------ 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/model/balance.ts b/src/model/balance.ts index e5b486d1..40bef1db 100644 --- a/src/model/balance.ts +++ b/src/model/balance.ts @@ -1,5 +1,5 @@ import { Asset } from "stellar-sdk"; -import { AccountID } from "./account_id"; +import { AccountID, AssetID } from "./"; export interface IBalanceBase { account: AccountID; @@ -30,6 +30,11 @@ export class Balance implements IBalance { this.asset = data.asset; } + public static parsePagingToken(token: string): [AccountID, AssetID, string] { + return Buffer.from(token, "base64") + .toString().split("_") as [AccountID, AssetID, string]; + } + public get paging_token() { return Buffer.from(`${this.account}_${this.asset.toString()}_${this.balance}`).toString("base64"); } diff --git a/src/repo/assets.ts b/src/repo/assets.ts index aaeb0f83..46ab3741 100644 --- a/src/repo/assets.ts +++ b/src/repo/assets.ts @@ -2,6 +2,7 @@ import { IDatabase } from "pg-promise"; import squel from "squel"; import { Asset } from "stellar-sdk"; import { PagingParams } from "../datasource/horizon/base"; +import { Balance } from "../model"; import { BalanceFactory } from "../model/factories"; import { parseCursorPagination, properlyOrdered, SortOrder } from "../util/paging"; @@ -44,33 +45,31 @@ export default class AssetsRepo { } public async findHolders(asset: Asset, paging: PagingParams) { + const { limit, cursor, order } = parseCursorPagination(paging); const queryBuilder = squel .select() .from("trustlines") .where("assetcode = ?", asset.getCode()) - .where("issuer = ?", asset.getIssuer()); - - const { limit, cursor, order } = parseCursorPagination(paging); - - queryBuilder.limit(limit); - - // Order of these stetements is important, - // we must order by balance first to support cursor pagination - queryBuilder.order("balance", order === SortOrder.ASC); - queryBuilder.order("accountid"); + .where("issuer = ?", asset.getIssuer()) + .order("balance", order === SortOrder.ASC) // we must order by balance first to support cursor pagination + .order("accountid", order !== SortOrder.ASC) // inversion of above + .limit(limit); if (cursor) { - const [accountId, , balance] = Buffer.from(cursor, "base64") - .toString() - .split("_"); + const [accountId, , balance] = Balance.parsePagingToken(cursor); if (paging.after) { - // <= and >= allow to handle zero balances - queryBuilder.where("balance <= ?", balance); - queryBuilder.where("accountid > ?", accountId); + if (balance === "0") { + queryBuilder.where("accountid > ?", accountId); + } else { + queryBuilder.where("balance < ?", balance); + } } else if (paging.before) { - queryBuilder.where("balance >= ?", balance); - queryBuilder.where("accountid < ?", accountId); + if (balance === "0") { + queryBuilder.where("(balance = '0' AND accountid < ?) OR balance > '0'", accountId); + } else { + queryBuilder.where("balance > ?", balance); + } } } diff --git a/src/util/paging.ts b/src/util/paging.ts index 18d15a0f..ec17f54a 100644 --- a/src/util/paging.ts +++ b/src/util/paging.ts @@ -22,15 +22,11 @@ export function parseCursorPagination(args: PagingParams) { return { limit: first || last, - order: before ? invertSortOrder(order) : order, + order: last ? invertSortOrder(order) : order, cursor: last ? before : after }; } export function properlyOrdered(records: any[], pagingParams: PagingParams): any[] { - if (pagingParams.last && pagingParams.before) { - return records.reverse(); - } - - return records; + return pagingParams.last ? records.reverse() : records; } From 089820a7e6df6cfa974cbbf62d2bc8f1d0a3c873 Mon Sep 17 00:00:00 2001 From: Timur Ramazanov Date: Tue, 16 Apr 2019 10:44:00 +0300 Subject: [PATCH 6/7] :cop: --- src/model/balance.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/model/balance.ts b/src/model/balance.ts index 40bef1db..101d3e21 100644 --- a/src/model/balance.ts +++ b/src/model/balance.ts @@ -14,6 +14,12 @@ export interface IBalance extends IBalanceBase { } export class Balance implements IBalance { + public static parsePagingToken(token: string): [AccountID, AssetID, string] { + return Buffer.from(token, "base64") + .toString() + .split("_") as [AccountID, AssetID, string]; + } + public account: AccountID; public asset: Asset; public limit: string; @@ -30,11 +36,6 @@ export class Balance implements IBalance { this.asset = data.asset; } - public static parsePagingToken(token: string): [AccountID, AssetID, string] { - return Buffer.from(token, "base64") - .toString().split("_") as [AccountID, AssetID, string]; - } - public get paging_token() { return Buffer.from(`${this.account}_${this.asset.toString()}_${this.balance}`).toString("base64"); } From a98b4184c6a23271dcdfbc5ebe5acecf394d260d Mon Sep 17 00:00:00 2001 From: Timur Ramazanov Date: Wed, 17 Apr 2019 10:12:47 +0300 Subject: [PATCH 7/7] Fix pagination one more time --- src/repo/assets.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/repo/assets.ts b/src/repo/assets.ts index 46ab3741..c0c51d52 100644 --- a/src/repo/assets.ts +++ b/src/repo/assets.ts @@ -59,17 +59,9 @@ export default class AssetsRepo { const [accountId, , balance] = Balance.parsePagingToken(cursor); if (paging.after) { - if (balance === "0") { - queryBuilder.where("accountid > ?", accountId); - } else { - queryBuilder.where("balance < ?", balance); - } + queryBuilder.where("(balance = ? AND accountid > ?) OR balance < ?", balance, accountId, balance); } else if (paging.before) { - if (balance === "0") { - queryBuilder.where("(balance = '0' AND accountid < ?) OR balance > '0'", accountId); - } else { - queryBuilder.where("balance > ?", balance); - } + queryBuilder.where("(balance = ? AND accountid < ?) OR balance > ?", balance, accountId, balance); } }