Skip to content

Commit ef9cfd6

Browse files
committed
[PR2] Apply field masks in SearchUtxos
SearchUtxos returns only the fields its mask selects. It skips the UTxO query when neither items nor next_token is selected and the chain tip queries when ledger_tip is not, and converts each UTxO of the page only for the fields the mask selects. The new fillShared helper runs a shared action once for all the fields that read its result.
1 parent b23ea04 commit ef9cfd6

6 files changed

Lines changed: 250 additions & 18 deletions

File tree

‎cardano-rpc/cardano-rpc.cabal‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ test-suite cardano-rpc-test
249249
Test.Cardano.Rpc.Reflection
250250
Test.Cardano.Rpc.Reflection.DescriptorTable
251251
Test.Cardano.Rpc.Script
252+
Test.Cardano.Rpc.SearchUtxos
252253
Test.Cardano.Rpc.TxOutput
253254
Test.Cardano.Rpc.Type
254255
Test.Cardano.Rpc.WaitForTxStream

‎cardano-rpc/src/Cardano/Rpc/Server/Internal/FieldMask.hs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ module Cardano.Rpc.Server.Internal.FieldMask
2525
, fill
2626
, fillSub
2727
, fillEach
28+
, fillShared
2829
, isSelected
2930
, lensLabel
3031
, normalise
@@ -293,6 +294,18 @@ fillEach doInputs elementFill = FieldFill $ \fieldMask -> do
293294
let buildElement = runIdentity . fillMessage id (subMaskEach @label fieldMask) . elementFill
294295
pure . Ap $ Endo . (Field.field @label .~) . map buildElement <$> doInputs
295296

297+
-- | Fill several fields from the result of one action. The action runs once when
298+
-- the mask selects any of the fields, and never otherwise.
299+
fillShared
300+
:: Functor m
301+
=> m shared
302+
-- ^ Obtains the result the fields share
303+
-> FieldFill ((->) shared) msg
304+
-- ^ Fills the fields from the result of the action
305+
-> FieldFill m msg
306+
fillShared doShared (FieldFill sharedFill) = FieldFill $ \fieldMask ->
307+
Ap . (<$> doShared) . getAp <$> sharedFill fieldMask
308+
296309
-- | Run the effects of the fields the mask selects and set them on 'defMessage',
297310
-- untrimmed. When the mask selects no field, the runner is never called.
298311
fillMessage

‎cardano-rpc/src/Cardano/Rpc/Server/Internal/UtxoRpc/Query.hs‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ module Cardano.Rpc.Server.Internal.UtxoRpc.Query
1919
, readUtxosFieldMask
2020
, readUtxosFill
2121
, searchUtxosMethod
22+
, searchUtxosFieldMask
23+
, searchUtxosFill
2224
, readGenesisMethod
2325
, readEraSummaryMethod
2426
, paginateByTxIn
@@ -157,12 +159,14 @@ maxUtxoQueryKeys = 20_000
157159
-- Filters the UTxO set by a predicate and returns a paginated result.
158160
-- The predicate must contain exact address matches so the query can be
159161
-- narrowed; broad predicates are rejected with @INVALID_ARGUMENT@.
162+
-- Honours the field mask: the UTxO query runs only for @items@ or @next_token@,
163+
-- the chain tip queries only for @ledger_tip@, and each item is built only from
164+
-- the fields selected.
160165
searchUtxosMethod
161166
:: MonadRpc e m
162167
=> Proto UtxoRpc.SearchUtxosRequest
163168
-> m (Proto UtxoRpc.SearchUtxosResponse)
164169
searchUtxosMethod req = do
165-
-- TODO: field masks are ignored for now
166170
let maxItems = req ^. U5c.maxItems
167171
startToken = req ^. U5c.maybe'startToken
168172

@@ -183,24 +187,34 @@ searchUtxosMethod req = do
183187
"predicate too broad: must contain exact address match to avoid fetching the entire UTxO set"
184188

185189
nodeConnInfo <- grab
186-
AnyCardanoEra era <- liftIO . throwExceptT $ determineEra nodeConnInfo
187-
eon <- forEraInEon @Era era (error "Minimum Conway era required") pure
188-
189-
let target = VolatileTip
190-
(utxo, ledgerTip) <- liftIO . (throwEither =<<) $ executeLocalStateQueryExpr nodeConnInfo target $ do
191-
utxo <- throwEither =<< throwEither =<< queryUtxo (convert eon) utxoFilter
192-
ledgerTip <- queryLedgerTip
193-
pure (utxo, ledgerTip)
194-
195-
obtainCommonConstraints eon $ do
196-
let (page, nextTok) =
197-
paginateByTxIn (matchesCompiledPredicate compiledPredicate) (unUTxO utxo) startToken maxItems
190+
let doQueryPage :: LocalQuery ([UtxoFields], Maybe Text)
191+
doQueryPage = queryInCurrentEra $ \eon -> obtainCommonConstraints eon $ do
192+
utxo <- throwEither =<< throwEither =<< queryUtxo (convert eon) utxoFilter
193+
let (page, nextTok) =
194+
paginateByTxIn (matchesCompiledPredicate compiledPredicate) (unUTxO utxo) startToken maxItems
195+
pure (map (uncurry txInTxOutToUtxoFields) page, nextTok)
196+
fmap Proto . runFill (runLocalQuery nodeConnInfo) (searchUtxosFieldMask req) $
197+
searchUtxosFill doQueryPage queryLedgerTip
198+
199+
-- | The field mask of a @SearchUtxos@ request, rooted at the response.
200+
searchUtxosFieldMask
201+
:: Proto UtxoRpc.SearchUtxosRequest
202+
-- ^ The request carrying the field mask
203+
-> FieldMaskTree UtxoRpc.SearchUtxosResponse
204+
searchUtxosFieldMask req = resolve $ getProto req ^. U5c.fieldMask . FieldMask.paths
198205

199-
pure $
200-
defMessage
201-
& U5c.ledgerTip .~ Proto ledgerTip
202-
& U5c.items .~ map (uncurry txInTxOutToAnyUtxoData) page
203-
& U5c.maybe'nextToken .~ nextTok
206+
-- | The fields of a @SearchUtxos@ response, each from the query that obtains it.
207+
-- The page query runs once for @items@ and @next_token@ together.
208+
searchUtxosFill
209+
:: Applicative m
210+
=> m ([UtxoFields], Maybe Text)
211+
-- ^ Queries one page of matching UTxO entries and the token of the next page
212+
-> m UtxoRpc.ChainPoint
213+
-- ^ Queries the chain tip
214+
-> FieldFill m UtxoRpc.SearchUtxosResponse
215+
searchUtxosFill doQueryPage doQueryTip =
216+
fillShared doQueryPage (fillEach @"items" fst utxoFill <> fill @"maybe'nextToken" snd)
217+
<> fill @"ledgerTip" doQueryTip
204218

205219
-- | Handle the @ReadGenesis@ RPC method.
206220
-- Returns the chain's identity - the Shelley genesis hash and the CAIP-2 chain

‎cardano-rpc/src/Cardano/Rpc/Server/Internal/UtxoRpc/Type.hs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module Cardano.Rpc.Server.Internal.UtxoRpc.Type
88
, eraSummariesToProto
99
, UtxoFields (..)
1010
, utxoToUtxoFields
11+
, txInTxOutToUtxoFields
1112
, utxoFill
1213
, utxoToUtxoRpcAnyUtxoData
1314
, txInTxOutToAnyUtxoData

‎cardano-rpc/test/cardano-rpc-test/Test/Cardano/Rpc/FieldMask.hs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Cardano.Rpc.Proto.Api.UtxoRpc.Query
1414
( ReadGenesisResponse
1515
, ReadParamsResponse
1616
, ReadUtxosResponse
17+
, SearchUtxosResponse
1718
)
1819
import Cardano.Rpc.Proto.Api.UtxoRpc.Query qualified as U5c
1920
import Cardano.Rpc.Proto.Api.UtxoRpc.Submit qualified as Submit
@@ -582,6 +583,32 @@ hprop_field_mask_fill_each_keeps_elements_without_filled_fields = H.propertyOnce
582583
let response = runFillPure (resolve ["items.block_ref"]) . itemsFill $ pure itemInputs
583584
response ^. U5c.items === [defMessage, defMessage]
584585

586+
hprop_field_mask_fill_shared_runs_action_once_for_all_its_fields :: Property
587+
hprop_field_mask_fill_shared_runs_action_once_for_all_its_fields = H.propertyOnce $ do
588+
let (response, ran) = runWriter . runFill id SelectAll . pageFill $ recorded "page" (itemInputs, Just "next")
589+
ran === ["page"]
590+
length (response ^. U5c.items) === 2
591+
response ^. U5c.maybe'nextToken === Just "next"
592+
593+
hprop_field_mask_fill_shared_fills_only_selected_fields :: Property
594+
hprop_field_mask_fill_shared_fills_only_selected_fields = H.propertyOnce $ do
595+
let run inputs page = runWriter . runFill id (resolve inputs) . pageFill $ recorded "page" page
596+
(tokenOnly, ranForToken) <- H.evalNF $ run ["next_token"] (error "forced", Just "next")
597+
ranForToken === ["page"]
598+
tokenOnly === (defMessage & U5c.nextToken .~ "next")
599+
(itemsOnly, ranForItems) <- H.evalNF $ run ["items.txo_ref"] (itemInputs, error "forced")
600+
ranForItems === ["page"]
601+
itemsOnly ^. U5c.items === [defMessage & U5c.txoRef .~ ref | (ref, _) <- itemInputs]
602+
itemsOnly ^. U5c.maybe'nextToken === Nothing
603+
604+
hprop_field_mask_fill_shared_never_runs_action_for_unselected_fields :: Property
605+
hprop_field_mask_fill_shared_never_runs_action_for_unselected_fields = H.propertyOnce $ do
606+
let (response, ran) =
607+
runWriter . runFill session (resolve ["ledger_tip"]) . pageFill . recorded "page" $
608+
error "forced"
609+
response === defMessage
610+
ran === []
611+
585612
chainPointFixture :: U5c.ChainPoint
586613
chainPointFixture =
587614
defMessage
@@ -674,6 +701,14 @@ itemsFill
674701
-> FieldFill m ReadUtxosResponse
675702
itemsFill doInputs = fillEach @"items" doInputs itemFill
676703

704+
-- | Fill @items@ and @next_token@ from one page query.
705+
pageFill
706+
:: Functor m
707+
=> m ([(U5c.TxoRef, ByteString)], Maybe Text)
708+
-- ^ Obtains the page
709+
-> FieldFill m SearchUtxosResponse
710+
pageFill doPage = fillShared doPage $ fillEach @"items" fst itemFill <> fill @"maybe'nextToken" snd
711+
677712
itemFill :: (U5c.TxoRef, ByteString) -> FieldFill Identity U5c.AnyUtxoData
678713
itemFill (ref, bytes) = fill @"txoRef" (pure ref) <> fill @"nativeBytes" (pure bytes)
679714

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE OverloadedStrings #-}
3+
{-# LANGUAGE TypeApplications #-}
4+
5+
-- | Tests for how @SearchUtxos@ builds its response from a field mask.
6+
module Test.Cardano.Rpc.SearchUtxos where
7+
8+
import Cardano.Api (TxIn)
9+
import Cardano.Api.Parser.Text qualified as P
10+
import Cardano.Api.Tx (parseTxIn)
11+
import Cardano.Rpc.Proto.Api.UtxoRpc.Query qualified as U5c
12+
import Cardano.Rpc.Server.Internal.FieldMask
13+
import Cardano.Rpc.Server.Internal.UtxoRpc.Query
14+
( paginateByTxIn
15+
, searchUtxosFieldMask
16+
, searchUtxosFill
17+
)
18+
import Cardano.Rpc.Server.Internal.UtxoRpc.Type
19+
20+
import RIO
21+
22+
import Data.Map.Strict qualified as Map
23+
import Data.ProtoLens (defMessage)
24+
import Data.Text qualified as T
25+
26+
import Test.Cardano.Rpc.RecordedQuery
27+
28+
import Hedgehog as H
29+
import Hedgehog.Extras qualified as H
30+
31+
import Proto.Google.Protobuf.FieldMask_Fields qualified as FieldMask (paths)
32+
33+
hprop_field_mask_search_utxos_unselected_item_fields_are_not_evaluated :: Property
34+
hprop_field_mask_search_utxos_unselected_item_fields_are_not_evaluated = H.propertyOnce $ do
35+
let lazyFields =
36+
[ UtxoFields (utxoFieldsRef f) (error "forced") (error "forced")
37+
| f <- realFields
38+
]
39+
(response, ran) <- H.evalNF $ build ["items.txo_ref"] lazyFields realToken
40+
ran === ["session", "page"]
41+
map (view U5c.txoRef) (response ^. U5c.items) === map utxoFieldsRef realFields
42+
H.assertWith response $ all (isNothing . view U5c.maybe'cardano) . view U5c.items
43+
H.assertWith response $ all ((== mempty) . view U5c.nativeBytes) . view U5c.items
44+
H.assertWith response $ isNothing . view U5c.maybe'ledgerTip
45+
H.assertWith response $ isNothing . view U5c.maybe'nextToken
46+
47+
hprop_field_mask_search_utxos_ledger_tip_runs_only_the_tip_query :: Property
48+
hprop_field_mask_search_utxos_ledger_tip_runs_only_the_tip_query = H.propertyOnce $ do
49+
let (response, ran) = build ["ledger_tip"] realFields realToken
50+
ran === ["session", "ledgerTip"]
51+
response === (defMessage & U5c.ledgerTip .~ realTip)
52+
53+
hprop_field_mask_search_utxos_next_token_runs_only_the_page_query :: Property
54+
hprop_field_mask_search_utxos_next_token_runs_only_the_page_query = H.propertyOnce $ do
55+
(response, ran) <- H.evalNF $ build ["next_token"] (error "forced") realToken
56+
ran === ["session", "page"]
57+
response ^. U5c.maybe'nextToken === realToken
58+
H.assertWith response $ null . view U5c.items
59+
H.assertWith response $ isNothing . view U5c.maybe'ledgerTip
60+
61+
hprop_field_mask_search_utxos_items_and_next_token_share_one_page_query :: Property
62+
hprop_field_mask_search_utxos_items_and_next_token_share_one_page_query = H.propertyOnce $ do
63+
let (response, ran) = build ["items", "next_token"] realFields realToken
64+
ran === ["session", "page"]
65+
length (response ^. U5c.items) === 2
66+
response ^. U5c.maybe'nextToken === realToken
67+
68+
hprop_field_mask_search_utxos_nothing_selected_runs_no_query :: Property
69+
hprop_field_mask_search_utxos_nothing_selected_runs_no_query = H.propertyOnce $ do
70+
build ["bogus"] realFields realToken === (defMessage, [])
71+
72+
hprop_field_mask_search_utxos_gated_paths_set_their_field :: Property
73+
hprop_field_mask_search_utxos_gated_paths_set_their_field = H.propertyOnce $ do
74+
responseFor ["ledger_tip"] ^. U5c.maybe'ledgerTip === Just realTip
75+
H.assertWith (responseFor ["ledger_tip"]) $ null . view U5c.items
76+
responseFor ["next_token"] ^. U5c.maybe'nextToken === realToken
77+
H.assertWith (responseFor ["next_token"]) $ null . view U5c.items
78+
map (view U5c.txoRef) (responseFor ["items.txo_ref"] ^. U5c.items) === map utxoFieldsRef realFields
79+
map (view U5c.nativeBytes) (responseFor ["items.native_bytes"] ^. U5c.items)
80+
=== map utxoFieldsNativeBytes realFields
81+
map (view U5c.cardano) (responseFor ["items.cardano"] ^. U5c.items)
82+
=== map utxoFieldsCardano realFields
83+
-- The whole items field selects every item field.
84+
map (view U5c.nativeBytes) (responseFor ["items"] ^. U5c.items)
85+
=== map utxoFieldsNativeBytes realFields
86+
87+
hprop_field_mask_search_utxos_select_all_sets_every_field :: Property
88+
hprop_field_mask_search_utxos_select_all_sets_every_field = H.propertyOnce $ do
89+
let (fullResponse, ran) = build [] realFields realToken
90+
expectedItems =
91+
[ defMessage
92+
& U5c.txoRef .~ utxoFieldsRef f
93+
& U5c.nativeBytes .~ utxoFieldsNativeBytes f
94+
& U5c.cardano .~ utxoFieldsCardano f
95+
| f <- realFields
96+
]
97+
ran === ["session", "page", "ledgerTip"]
98+
fullResponse ^. U5c.items === expectedItems
99+
fullResponse ^. U5c.maybe'ledgerTip === Just realTip
100+
fullResponse ^. U5c.maybe'nextToken === realToken
101+
102+
hprop_field_mask_search_utxos_nested_path_trims_cardano :: Property
103+
hprop_field_mask_search_utxos_nested_path_trims_cardano = H.propertyOnce $ do
104+
let trimmed = responseFor ["items.cardano.address"]
105+
expectedItems =
106+
[ defMessage & U5c.cardano . U5c.address .~ utxoFieldsCardano f ^. U5c.address
107+
| f <- realFields
108+
]
109+
trimmed ^. U5c.items === expectedItems
110+
H.assertWith trimmed $ isNothing . view U5c.maybe'ledgerTip
111+
H.assertWith trimmed $ isNothing . view U5c.maybe'nextToken
112+
113+
-- | The handler's own mask wiring: request paths select, an empty request selects all.
114+
hprop_field_mask_search_utxos_request_mask_selects_requested_paths :: Property
115+
hprop_field_mask_search_utxos_request_mask_selects_requested_paths = H.propertyOnce $ do
116+
let request = defMessage & U5c.fieldMask . FieldMask.paths .~ ["next_token"]
117+
fieldMask = searchUtxosFieldMask request
118+
H.assertWith fieldMask $ isSelected @"nextToken"
119+
H.assertWith fieldMask $ not . isSelected @"items"
120+
H.assertWith fieldMask $ not . isSelected @"ledgerTip"
121+
searchUtxosFieldMask defMessage === SelectAll
122+
123+
-- | The fill passes the pagination token through unchanged whenever the mask selects it.
124+
hprop_field_mask_search_utxos_next_token_is_the_same_under_any_mask :: Property
125+
hprop_field_mask_search_utxos_next_token_is_the_same_under_any_mask = H.propertyOnce $ do
126+
let entries = Map.fromList [mkEntry i | i <- [0 .. 4 :: Int]]
127+
(page, token) = paginateByTxIn (const True) entries Nothing 2
128+
respond paths = fst $ build paths (map snd page) token
129+
length page === 2
130+
H.assertWith token isJust
131+
respond ["next_token"] ^. U5c.maybe'nextToken === token
132+
respond [] ^. U5c.maybe'nextToken === token
133+
length (respond ["items"] ^. U5c.items) === 2
134+
where
135+
mkEntry i =
136+
( mkTxIn $ T.replicate 64 "1" <> "#" <> T.pack (show i)
137+
, UtxoFields
138+
{ utxoFieldsRef = defMessage & U5c.index .~ fromIntegral i
139+
, utxoFieldsNativeBytes = "native"
140+
, utxoFieldsCardano = defMessage & U5c.address .~ "address"
141+
}
142+
)
143+
144+
-- | The response for the mask paths from 'realFields' and 'realToken'.
145+
responseFor
146+
:: [Text]
147+
-- ^ Field mask paths, rooted at the response
148+
-> U5c.SearchUtxosResponse
149+
responseFor paths = fst $ build paths realFields realToken
150+
151+
-- | Run the response fill for the mask paths, recording the session and each query it runs.
152+
build
153+
:: [Text]
154+
-- ^ Field mask paths, rooted at the response
155+
-> [UtxoFields]
156+
-- ^ The UTxO entries of the page that the recorded page query returns
157+
-> Maybe Text
158+
-- ^ The next page token that the recorded page query returns
159+
-> (U5c.SearchUtxosResponse, [Text])
160+
build paths items nextToken =
161+
runRecordedFill paths $
162+
searchUtxosFill (recorded "page" (items, nextToken)) (recorded "ledgerTip" realTip)
163+
164+
mkTxIn :: Text -> TxIn
165+
mkTxIn = either error id . P.runParser parseTxIn
166+
167+
realToken :: Maybe Text
168+
realToken = Just "next-token"

0 commit comments

Comments
 (0)