Skip to content

Commit

Permalink
Fixed condition interpreter
Browse files Browse the repository at this point in the history
Signed-off-by: Alexandros Filios <alexandros.filios@ibm.com>
  • Loading branch information
alexandrosfilios committed Sep 23, 2024
1 parent 5504b61 commit de91619
Show file tree
Hide file tree
Showing 13 changed files with 64 additions and 62 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/IBM/idemix/bccsp/types v0.0.0-20240816143710-3dce4618d760
github.com/IBM/mathlib v0.0.3-0.20231011094432-44ee0eb539da
github.com/hashicorp/go-uuid v1.0.3
github.com/hyperledger-labs/fabric-smart-client v0.3.1-0.20240916124041-9962e1244257
github.com/hyperledger-labs/fabric-smart-client v0.3.1-0.20240923134055-2b88792254d9
github.com/hyperledger-labs/orion-sdk-go v0.2.10
github.com/hyperledger-labs/orion-server v0.2.10
github.com/hyperledger/fabric v1.4.0-rc1.0.20230405174026-695dd57e01c2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1071,8 +1071,8 @@ github.com/hidal-go/hidalgo v0.0.0-20201109092204-05749a6d73df/go.mod h1:bPkrxDl
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/hyperledger-labs/fabric-smart-client v0.3.1-0.20240916124041-9962e1244257 h1:6fyk3QsSNt8WMF2NYT2tIqqDNx+NxKF+Wt3tBi+OGvY=
github.com/hyperledger-labs/fabric-smart-client v0.3.1-0.20240916124041-9962e1244257/go.mod h1:9AELIfs/eawIhoHNKMSmYALaunmpDbs9bdWKyHuJs88=
github.com/hyperledger-labs/fabric-smart-client v0.3.1-0.20240923134055-2b88792254d9 h1:BROaJ8GXqHN2Do4rWSwCaSyYk6o18+vteHtW7O4t0mg=
github.com/hyperledger-labs/fabric-smart-client v0.3.1-0.20240923134055-2b88792254d9/go.mod h1:nHTHGYZ7u0K2X/jtOzCknTXvaoEfFHtsV1Ia48E4NIU=
github.com/hyperledger-labs/orion-sdk-go v0.2.10 h1:lFgWgxyvngIhWnIqymYGBmtmq9D6uC5d0uLG9cbyh5s=
github.com/hyperledger-labs/orion-sdk-go v0.2.10/go.mod h1:iN2xZB964AqwVJwL+EnwPOs8z1EkMEbbIg/qYeC7gDY=
github.com/hyperledger-labs/orion-server v0.2.10 h1:G4zbQEL5Egk0Oj+TwHCZWdTOLDBHOjaAEvYOT4G7ozw=
Expand Down
17 changes: 2 additions & 15 deletions token/services/db/sql/common/querybuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,12 @@ import (
"strconv"
"strings"

"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/db/driver/sql/common"
"github.com/hyperledger-labs/fabric-token-sdk/token/services/db/driver"
)

var b = newTokenInterpreter()

func movementConditionsSql(params driver.QueryMovementsParams) (string, []any) {
func movementConditionsSql(params driver.QueryMovementsParams) string {
sb := strings.Builder{}

where, args := common.Where(b.HasMovementsParams(params))
sb.WriteString(where)

// Order by stored_at
if params.SearchDirection == driver.FromBeginning {
sb.WriteString(" ORDER BY stored_at ASC")
Expand All @@ -36,14 +30,7 @@ func movementConditionsSql(params driver.QueryMovementsParams) (string, []any) {
sb.WriteString(strconv.Itoa(params.NumRecords))
}

return sb.String(), args
}

// tokenQuerySql requires a join with the token ownership table if WalletID is not empty
func tokenQuerySql(params driver.QueryTokenDetailsParams, tokenTable, ownerTable string) (string, string, []any) {
w, ps := common.Where(b.HasTokenDetails(params, tokenTable))

return w, joinOnTokenID(tokenTable, ownerTable), ps
return sb.String()
}

func joinOnTxID(table, other string) string {
Expand Down
12 changes: 8 additions & 4 deletions token/services/db/sql/common/querybuilder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"github.com/test-go/testify/assert"
)

var b = NewTokenInterpreter(common.NewInterpreter())

func TestTransactionSql(t *testing.T) {
now := time.Now().Local().UTC()
lastYear := now.AddDate(-1, 0, 0)
Expand Down Expand Up @@ -225,7 +227,8 @@ func TestMovementConditions(t *testing.T) {

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actualSql, actualArgs := movementConditionsSql(tc.params)
where, actualArgs := common.Where(b.HasMovementsParams(tc.params))
actualSql := where + movementConditionsSql(tc.params)
assert.Equal(t, tc.expectedSql, actualSql)
compareArgs(t, tc.expectedArgs, actualArgs)
})
Expand Down Expand Up @@ -307,16 +310,17 @@ func TestTokenSql(t *testing.T) {
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actualSql, _, actualArgs := tokenQuerySql(tc.params, "", "")
actualSql, actualArgs := common.Where(b.HasTokenDetails(tc.params, ""))
assert.Equal(t, tc.expectedSql, actualSql, tc.name)
compareArgs(t, tc.expectedArgs, actualArgs)
})
}
// with join
where, join, args := tokenQuerySql(driver.QueryTokenDetailsParams{
where, args := common.Where(b.HasTokenDetails(driver.QueryTokenDetailsParams{
IDs: []*token.ID{{TxId: "a", Index: 1}},
WalletID: "me",
}, "A", "B")
}, "A"))
join := joinOnTokenID("A", "B")
assert.Equal(t, "WHERE (owner = true AND (A.tx_id, A.idx) IN (($1, $2)) AND (wallet_id = $3 OR owner_wallet_id = $4) AND is_deleted = false)", where, "join")
assert.Equal(t, "LEFT JOIN B ON A.tx_id = B.tx_id AND A.idx = B.idx", join, "join")
assert.Len(t, args, 4)
Expand Down
4 changes: 2 additions & 2 deletions token/services/db/sql/common/tcondition.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ type TokenInterpreter interface {
HasTransactionParams(params driver.QueryTransactionsParams, table string) common.Condition
}

func newTokenInterpreter() TokenInterpreter {
return &tokenInterpreter{Interpreter: common.NewInterpreter()}
func NewTokenInterpreter(ci common.Interpreter) TokenInterpreter {
return &tokenInterpreter{Interpreter: ci}
}

type tokenInterpreter struct {
Expand Down
49 changes: 28 additions & 21 deletions token/services/db/sql/common/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type tokenTables struct {
Certifications string
}

func NewTokenDB(db *sql.DB, opts NewDBOpts) (driver.TokenDB, error) {
func NewTokenDB(db *sql.DB, opts NewDBOpts, ci TokenInterpreter) (driver.TokenDB, error) {
tables, err := GetTableNames(opts.TablePrefix)
if err != nil {
return nil, errors.Wrapf(err, "failed to get table names")
Expand All @@ -41,7 +41,7 @@ func NewTokenDB(db *sql.DB, opts NewDBOpts) (driver.TokenDB, error) {
Ownership: tables.Ownership,
PublicParams: tables.PublicParams,
Certifications: tables.Certifications,
})
}, ci)
if opts.CreateSchema {
if err = common.InitSchema(db, tokenDB.GetSchema()); err != nil {
return nil, err
Expand All @@ -53,12 +53,14 @@ func NewTokenDB(db *sql.DB, opts NewDBOpts) (driver.TokenDB, error) {
type TokenDB struct {
db *sql.DB
table tokenTables
ci TokenInterpreter
}

func newTokenDB(db *sql.DB, tables tokenTables) *TokenDB {
func newTokenDB(db *sql.DB, tables tokenTables, ci TokenInterpreter) *TokenDB {
return &TokenDB{
db: db,
table: tables,
ci: ci,
}
}

Expand All @@ -85,7 +87,7 @@ func (db *TokenDB) DeleteTokens(deletedBy string, ids ...*token.ID) error {
if len(ids) == 0 {
return nil
}
cond := b.HasTokens("tx_id", "idx", ids...)
cond := db.ci.HasTokens("tx_id", "idx", ids...)
args := append([]any{deletedBy, time.Now().UTC()}, cond.Params()...)
offset := 3
where := cond.ToString(&offset)
Expand Down Expand Up @@ -123,10 +125,12 @@ func (db *TokenDB) UnspentTokensIterator() (tdriver.UnspentTokensIterator, error
// The token type can be empty. In that case, tokens of any type are returned.
func (db *TokenDB) UnspentTokensIteratorBy(ctx context.Context, walletID, tokenType string) (tdriver.UnspentTokensIterator, error) {
span := trace.SpanFromContext(ctx)
where, join, args := tokenQuerySql(driver.QueryTokenDetailsParams{
where, args := common.Where(db.ci.HasTokenDetails(driver.QueryTokenDetailsParams{
WalletID: walletID,
TokenType: tokenType,
}, db.table.Tokens, db.table.Ownership)
}, db.table.Tokens))
join := joinOnTokenID(db.table.Tokens, db.table.Ownership)

query := fmt.Sprintf("SELECT %s.tx_id, %s.idx, owner_raw, token_type, quantity FROM %s %s %s",
db.table.Tokens, db.table.Tokens, db.table.Tokens, join, where)

Expand All @@ -141,7 +145,7 @@ func (db *TokenDB) UnspentTokensIteratorBy(ctx context.Context, walletID, tokenT
// UnspentTokensInWalletIterator returns the minimum information about the tokens needed for the selector
func (db *TokenDB) SpendableTokensIteratorBy(ctx context.Context, walletID string, typ string) (tdriver.SpendableTokensIterator, error) {
span := trace.SpanFromContext(ctx)
where, args := common.Where(b.HasTokenDetails(driver.QueryTokenDetailsParams{
where, args := common.Where(db.ci.HasTokenDetails(driver.QueryTokenDetailsParams{
WalletID: walletID,
TokenType: typ,
}, ""))
Expand All @@ -162,10 +166,11 @@ func (db *TokenDB) SpendableTokensIteratorBy(ctx context.Context, walletID strin

// Balance returns the sun of the amounts, with 64 bits of precision, of the tokens with type and EID equal to those passed as arguments.
func (db *TokenDB) Balance(walletID, typ string) (uint64, error) {
where, join, args := tokenQuerySql(driver.QueryTokenDetailsParams{
where, args := common.Where(db.ci.HasTokenDetails(driver.QueryTokenDetailsParams{
WalletID: walletID,
TokenType: typ,
}, db.table.Tokens, db.table.Ownership)
}, db.table.Tokens))
join := joinOnTokenID(db.table.Tokens, db.table.Ownership)
query := fmt.Sprintf("SELECT SUM(amount) FROM %s %s %s", db.table.Tokens, join, where)

logger.Debug(query, args)
Expand Down Expand Up @@ -234,8 +239,8 @@ func (db *TokenDB) ListAuditTokens(ids ...*token.ID) ([]*token.Token, error) {
if len(ids) == 0 {
return []*token.Token{}, nil
}
where, args := common.Where(b.And(
b.HasTokens("tx_id", "idx", ids...),
where, args := common.Where(db.ci.And(
db.ci.HasTokens("tx_id", "idx", ids...),
common.ConstCondition("auditor = true"),
))

Expand Down Expand Up @@ -372,7 +377,7 @@ func (db *TokenDB) getLedgerToken(ids []*token.ID) ([][]byte, error) {
if len(ids) == 0 {
return [][]byte{}, nil
}
where, args := common.Where(b.HasTokens("tx_id", "idx", ids...))
where, args := common.Where(db.ci.HasTokens("tx_id", "idx", ids...))

query := fmt.Sprintf("SELECT tx_id, idx, ledger FROM %s %s", db.table.Tokens, where)
logger.Debug(query, args)
Expand Down Expand Up @@ -414,7 +419,7 @@ func (db *TokenDB) getLedgerTokenAndMeta(ctx context.Context, ids []*token.ID) (
if len(ids) == 0 {
return [][]byte{}, [][]byte{}, nil
}
where, args := common.Where(b.HasTokens("tx_id", "idx", ids...))
where, args := common.Where(db.ci.HasTokens("tx_id", "idx", ids...))

query := fmt.Sprintf("SELECT tx_id, idx, ledger, ledger_metadata FROM %s %s", db.table.Tokens, where)
span.AddEvent("query", tracing.WithAttributes(tracing.String(QueryLabel, query)))
Expand Down Expand Up @@ -460,8 +465,8 @@ func (db *TokenDB) GetTokens(inputs ...*token.ID) ([]*token.Token, error) {
if len(inputs) == 0 {
return []*token.Token{}, nil
}
where, args := common.Where(b.And(
b.HasTokens("tx_id", "idx", inputs...),
where, args := common.Where(db.ci.And(
db.ci.HasTokens("tx_id", "idx", inputs...),
common.ConstCondition("is_deleted = false"),
common.ConstCondition("owner = true"),
))
Expand Down Expand Up @@ -534,7 +539,8 @@ func (db *TokenDB) GetTokens(inputs ...*token.ID) ([]*token.Token, error) {
// Filters work cumulatively and may be left empty. If a token is owned by two enrollmentIDs and there
// is no filter on enrollmentID, the token will be returned twice (once for each owner).
func (db *TokenDB) QueryTokenDetails(params driver.QueryTokenDetailsParams) ([]driver.TokenDetails, error) {
where, join, args := tokenQuerySql(params, db.table.Tokens, db.table.Ownership)
where, args := common.Where(db.ci.HasTokenDetails(params, db.table.Tokens))
join := joinOnTokenID(db.table.Tokens, db.table.Ownership)

query := fmt.Sprintf("SELECT %s.tx_id, %s.idx, owner_identity, owner_type, wallet_id, token_type, amount, is_deleted, spent_by, stored_at FROM %s %s %s",
db.table.Tokens, db.table.Tokens, db.table.Tokens, join, where)
Expand Down Expand Up @@ -577,7 +583,7 @@ func (db *TokenDB) WhoDeletedTokens(inputs ...*token.ID) ([]string, []bool, erro
if len(inputs) == 0 {
return []string{}, []bool{}, nil
}
where, args := common.Where(b.HasTokens("tx_id", "idx", inputs...))
where, args := common.Where(db.ci.HasTokens("tx_id", "idx", inputs...))

query := fmt.Sprintf("SELECT tx_id, idx, spent_by, is_deleted FROM %s %s", db.table.Tokens, where)
logger.Debug(query, args)
Expand Down Expand Up @@ -708,7 +714,7 @@ func (db *TokenDB) ExistsCertification(tokenID *token.ID) bool {
if tokenID == nil {
return false
}
where, args := common.Where(b.HasTokens("tx_id", "idx", tokenID))
where, args := common.Where(db.ci.HasTokens("tx_id", "idx", tokenID))

query := fmt.Sprintf("SELECT certification FROM %s %s", db.table.Certifications, where)
logger.Debug(query, args)
Expand All @@ -733,7 +739,7 @@ func (db *TokenDB) GetCertifications(ids []*token.ID) ([][]byte, error) {
if len(ids) == 0 {
return nil, nil
}
where, args := common.Where(b.HasTokens("tx_id", "idx", ids...))
where, args := common.Where(db.ci.HasTokens("tx_id", "idx", ids...))
query := fmt.Sprintf("SELECT tx_id, idx, certification FROM %s %s ", db.table.Certifications, where)

rows, err := db.db.Query(query, args...)
Expand Down Expand Up @@ -852,10 +858,11 @@ type TokenTransaction struct {

func (t *TokenTransaction) GetToken(ctx context.Context, txID string, index uint64, includeDeleted bool) (*token.Token, []string, error) {
span := trace.SpanFromContext(ctx)
where, join, args := tokenQuerySql(driver.QueryTokenDetailsParams{
where, args := common.Where(t.db.ci.HasTokenDetails(driver.QueryTokenDetailsParams{
IDs: []*token.ID{{TxId: txID, Index: index}},
IncludeDeleted: includeDeleted,
}, t.db.table.Tokens, t.db.table.Ownership)
}, t.db.table.Tokens))
join := joinOnTokenID(t.db.table.Tokens, t.db.table.Ownership)

query := fmt.Sprintf("SELECT owner_raw, token_type, quantity, %s.wallet_id, owner_wallet_id FROM %s %s %s", t.db.table.Ownership, t.db.table.Tokens, join, where)
span.AddEvent("query", tracing.WithAttributes(tracing.String(QueryLabel, query)))
Expand Down
2 changes: 1 addition & 1 deletion token/services/db/sql/common/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func initTokenDB(driverName common.SQLDriverType, dataSourceName, tablePrefix st
DataSource: dataSourceName,
TablePrefix: tablePrefix,
CreateSchema: true,
})
}, NewTokenInterpreter(common.NewInterpreter()))
if err != nil {
return nil, err
}
Expand Down
21 changes: 12 additions & 9 deletions token/services/db/sql/common/transactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,26 @@ type transactionTables struct {
type TransactionDB struct {
db *sql.DB
table transactionTables
ci TokenInterpreter
}

func newTransactionDB(db *sql.DB, tables transactionTables) *TransactionDB {
func newTransactionDB(db *sql.DB, tables transactionTables, ci TokenInterpreter) *TransactionDB {
return &TransactionDB{
db: db,
table: tables,
ci: ci,
}
}

func NewAuditTransactionDB(sqlDB *sql.DB, opts NewDBOpts) (driver.AuditTransactionDB, error) {
func NewAuditTransactionDB(sqlDB *sql.DB, opts NewDBOpts, ci TokenInterpreter) (driver.AuditTransactionDB, error) {
return NewTransactionDB(sqlDB, NewDBOpts{
DataSource: opts.DataSource,
TablePrefix: opts.TablePrefix + "_aud",
CreateSchema: opts.CreateSchema,
})
}, ci)
}

func NewTransactionDB(db *sql.DB, opts NewDBOpts) (driver.TokenTransactionDB, error) {
func NewTransactionDB(db *sql.DB, opts NewDBOpts, ci TokenInterpreter) (driver.TokenTransactionDB, error) {
tables, err := GetTableNames(opts.TablePrefix)
if err != nil {
return nil, errors.Wrapf(err, "failed to get table names")
Expand All @@ -62,7 +64,7 @@ func NewTransactionDB(db *sql.DB, opts NewDBOpts) (driver.TokenTransactionDB, er
Requests: tables.Requests,
Validations: tables.Validations,
TransactionEndorseAck: tables.TransactionEndorseAck,
})
}, ci)
if opts.CreateSchema {
if err = common.InitSchema(db, []string{transactionsDB.GetSchema()}...); err != nil {
return nil, err
Expand All @@ -88,7 +90,8 @@ func (db *TransactionDB) GetTokenRequest(txID string) ([]byte, error) {
}

func (db *TransactionDB) QueryMovements(params driver.QueryMovementsParams) (res []*driver.MovementRecord, err error) {
conditions, args := movementConditionsSql(params)
where, args := common.Where(db.ci.HasMovementsParams(params))
conditions := where + movementConditionsSql(params)
query := fmt.Sprintf("SELECT %s.tx_id, enrollment_id, token_type, amount, %s.status FROM %s %s %s",
db.table.Movements, db.table.Requests,
db.table.Movements, joinOnTxID(db.table.Movements, db.table.Requests), conditions)
Expand Down Expand Up @@ -128,7 +131,7 @@ func (db *TransactionDB) QueryMovements(params driver.QueryMovementsParams) (res
}

func (db *TransactionDB) QueryTransactions(params driver.QueryTransactionsParams) (driver.TransactionIterator, error) {
conditions, args := common.Where(b.HasTransactionParams(params, db.table.Transactions))
conditions, args := common.Where(db.ci.HasTransactionParams(params, db.table.Transactions))
query := fmt.Sprintf(
"SELECT %s.tx_id, action_type, sender_eid, recipient_eid, token_type, amount, %s.status, %s.application_metadata, stored_at FROM %s %s %s",
db.table.Transactions, db.table.Requests, db.table.Requests,
Expand Down Expand Up @@ -161,7 +164,7 @@ func (db *TransactionDB) GetStatus(txID string) (driver.TxStatus, string, error)
}

func (db *TransactionDB) QueryValidations(params driver.QueryValidationRecordsParams) (driver.ValidationRecordsIterator, error) {
conditions, args := common.Where(b.HasValidationParams(params))
conditions, args := common.Where(db.ci.HasValidationParams(params))
query := fmt.Sprintf("SELECT %s.tx_id, %s.request, metadata, %s.status, %s.stored_at FROM %s %s %s",
db.table.Validations, db.table.Requests, db.table.Requests, db.table.Validations,
db.table.Validations, joinOnTxID(db.table.Validations, db.table.Requests), conditions)
Expand All @@ -177,7 +180,7 @@ func (db *TransactionDB) QueryValidations(params driver.QueryValidationRecordsPa

// QueryTokenRequests returns an iterator over the token requests matching the passed params
func (db *TransactionDB) QueryTokenRequests(params driver.QueryTokenRequestsParams) (driver.TokenRequestIterator, error) {
conditions, args := common.Where(b.InInts("status", params.Statuses))
conditions, args := common.Where(db.ci.InInts("status", params.Statuses))

query := fmt.Sprintf("SELECT tx_id, request, status FROM %s %s", db.table.Requests, conditions)
logger.Debug(query, args)
Expand Down
2 changes: 1 addition & 1 deletion token/services/db/sql/common/transactions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func initTransactionsDB(driverName common.SQLDriverType, dataSourceName, tablePr
DataSource: dataSourceName,
TablePrefix: tablePrefix,
CreateSchema: true,
})
}, NewTokenInterpreter(common.NewInterpreter()))
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion token/services/db/sql/postgres/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
)

func NewTokenDB(db *sql.DB, opts common.NewDBOpts) (driver.TokenDB, error) {
return common.NewTokenDB(db, opts)
return common.NewTokenDB(db, opts, common.NewTokenInterpreter(postgres.NewInterpreter()))
}

type TokenNotifier struct {
Expand Down
4 changes: 2 additions & 2 deletions token/services/db/sql/postgres/transactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func OpenAuditTransactionDB(k common.Opts) (driver.AuditTransactionDB, error) {
}

func NewAuditTransactionDB(db *sql.DB, opts common.NewDBOpts) (driver.AuditTransactionDB, error) {
return common.NewAuditTransactionDB(db, opts)
return common.NewAuditTransactionDB(db, opts, common.NewTokenInterpreter(postgres.NewInterpreter()))
}

func OpenTransactionDB(k common.Opts) (driver.TokenTransactionDB, error) {
Expand All @@ -35,5 +35,5 @@ func OpenTransactionDB(k common.Opts) (driver.TokenTransactionDB, error) {
}

func NewTransactionDB(db *sql.DB, opts common.NewDBOpts) (driver.TokenTransactionDB, error) {
return common.NewTransactionDB(db, opts)
return common.NewTransactionDB(db, opts, common.NewTokenInterpreter(postgres.NewInterpreter()))
}
Loading

0 comments on commit de91619

Please sign in to comment.