When we migrated domains from gamepedia.com
to fandom.com
, there was a lot of unexpected activity due to Cargo. One particularly slow query on every player page caused some significant platform-wide slowdowns at this point, and I ended up rewriting the query to improve performance. In the process of rewriting I also added some interesting general-case application logic to my Cargo wrapper. I’ll present both the SQL optimization as well as the wrapper change here.
(Note: All code in this post was originally written by me for Leaguepedia and is licensed under CC BY-SA 3.0.)
The original query
Why are there code snippets from two modules here? Originally, I was subclassing Module:NewsCurrentStatusAbstract
, which was able to filter through news & roster change lines to grab the current status of a player on a team, or a team of players, and print out the result. It was the way I was planning to print current team rosters, but that ended up falling through after I decided to rely on the Tenures
table instead of tracking changes through NewsItems
and RosterChanges
.
I still may need to use this approach for rosters if I implement a feature that lets you show the historical state of a team by querying up until a specific date - however, in the process of refactoring this single query, I combined the two modules because the team subclass simply isn’t in use and it was too messy to bother with, and Module:NewsCurrentStatusAbstract
is now deleted; I certainly won’t return to having PlayerCurrentTeam
subclass something else, even if I have another module with a similar approach, as I felt it was too messy and hard to follow the logic.
Anyway, here’s the query.
In Module:NewsCurrentStatusAbstract
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
function p:getQuery(subject)
local query = {
tables = self:getTables(),
join = self:getJoin(),
where = self:getWhere(subject),
fields = self:getFields(),
orderBy = 'News.Date_Sort ASC, News.N_LineInDate ASC, RC.N_LineInNews ASC',
complexTypes = {
Role = {
type = 'RoleList',
args = {
'Roles',
modifier = 'RoleModifier',
}
}
}
}
util_cargo.logQuery(query)
return query
end
function p:getTables() end
function p:getJoin() end
function p:getWhere() end
function p:getFields()
local ret = {
'RC.Date_Sort',
'RC.Player',
'RC.Team',
'RC.Roles',
'RC.RoleModifier',
'RC.Direction',
'RC.CurrentTeamPriority=Priority',
'News.Date_Display',
'News.Region',
'News._pageName',
}
return ret
end
|
And in Module:PlayerCurrentTeam
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
function PCT:getTables()
local ret = {
'NewsItems=News',
'RosterChanges=RC',
'PlayerRedirects=PR1',
'Contracts',
'PlayerRedirects=PR2',
'TeamRedirects=TRed1', -- join from contracts to ST1
'TeamRedirects=TRed2', -- join from news to ST2
'SisterTeams=ST1', -- the sister team page (if one exists) of the contract team
'SisterTeams=ST2', -- the sister team page (if one exists) of the news team
'ResidencyChanges=ResC',
'PlayerRedirects=PR3',
}
return ret
end
function PCT:getJoin()
local ret = {
'News.NewsId=RC.NewsId',
'News.NewsId=Contracts.NewsId',
'RC.Player=PR1.AllName',
'Contracts.Player=PR2.AllName',
-- contracts need to respect contracts from the same org
'Contracts.Team=TRed1.AllName',
'TRed1._pageName=ST1.Team',
-- roster changes need to discover the org so we can pull contracts
'RC.Team=TRed2.AllName',
'TRed2._pageName=ST2.Team',
-- residency changes are just the same news id that's it
'News.NewsId=ResC.NewsId',
'ResC.Player=PR3.AllName',
}
return ret
end
function PCT:getWhere(player)
local where = {
('PR1.OverviewPage="%s"'):format(player),
('PR2.OverviewPage="%s"'):format(player),
('PR3.OverviewPage="%s"'):format(player),
}
return util_cargo.concatWhereOr(where)
end
function PCT:getFields()
local fields = self:super('getFields')
fields[#fields+1] = 'Contracts.ContractEnd'
fields[#fields+1] = 'COALESCE(ST1._pageName, Contracts.Team, ST2._pageName, RC.Team)=SisterTeamPage'
fields[#fields+1] = 'COALESCE(PR1.OverviewPage, PR2.OverviewPage)=PlayerLink'
fields[#fields+1] = 'Contracts.IsRemoval=IsContractRemoval [boolean]'
fields[#fields+1] = 'ResC.ResidencyOld'
fields[#fields+1] = 'RC.Preload'
fields[#fields+1] = 'RC.Direction'
return fields
end
|
Let’s specifically look at the tables:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
function PCT:getTables()
local ret = {
'NewsItems=News',
'RosterChanges=RC',
'PlayerRedirects=PR1',
'Contracts',
'PlayerRedirects=PR2',
'TeamRedirects=TRed1', -- join from contracts to ST1
'TeamRedirects=TRed2', -- join from news to ST2
'SisterTeams=ST1', -- the sister team page (if one exists) of the contract team
'SisterTeams=ST2', -- the sister team page (if one exists) of the news team
'ResidencyChanges=ResC',
'PlayerRedirects=PR3',
}
return ret
end
|
Yikes, that’s THREE entire copies of PlayerRedirects, a table that has 17,536 rows at the time of publication of this article. And in the where
condition, I’m OR
-ing them all.
The expected results sets are also completely disjoint. One copy of PlayerRedirects
gets data about Contracts
; another about ResidencyChanges
; and finally, the last one about RosterChanges
. It’s impossible that any single row will ever have non-null values from each of these tables at the same time. This is a huge performance sink for no reason.
The change to make
Because the three parts of the query being OR
-ed together all represent disjoint rows in the join
, I wanted to break up the query into three entirely disjoint parts and then UNION
them all together to get my final result. No big deal; UNION
is a common SQL operator.
There’s just one teeny tiny problem, which is that Cargo doesn’t support UNION
at all. So even if I could write the SQL query I needed, I couldn’t write the Cargo query I needed. Instead I’d have to make three completely separate queries and then write the union part in Lua application logic.
Of course I wanted to do this UNION
in the general case and put it in my Cargo wrapper, so I’d need to come up with some general-case syntax for it, add support to Module:CargoUtil
, and then implement what I’d decided on in the PlayerCurrentTeam
module.
Oh, also I was on a “literally asap” deadline. And I had a migraine, but who’s keeping score?
The change
Module:CargoUtil
Originally, p.queryAndCast
looked like this:
1
2
3
4
5
6
7
8
9
10
|
function p.queryAndCast(query)
local copyQuery = h.getFinalizedCopyQuery(query)
local result = mw.ext.cargo.query(
copyQuery.tables,
copyQuery.fields,
copyQuery
)
h.cast(result, copyQuery)
return h.groupOneToManyFields(result, copyQuery)
end
|
After the update, it looked like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
function p.queryAndCast(query)
if not query.union then
return h.queryAndCastOne(query)
end
local result = util_map.inPlace(query.union, h.queryAndCastOne)
result = util_table.mergeArrays(unpack(result))
if query.sortKey then
util_sort.tablesByKeys(result, query.sortKey, query.sortOrder)
end
return result
end
function h.queryAndCastOne(query)
local copyQuery = h.getFinalizedCopyQuery(query)
local result = mw.ext.cargo.query(
copyQuery.tables,
copyQuery.fields,
copyQuery
)
h.cast(result, copyQuery)
return h.groupOneToManyFields(result, copyQuery)
end
|
If there’s no union
param defined, then I immediately return the same result as before. If there is, then I map the union to query each one individually, unpack the result, and then sort it according to its sortKey
and sortOrder
parameters.
I deliberately avoided reusing orderBy
here because that parameter is recognized by Cargo; I wanted to avoid overloading this term.
Module:PlayerCurrentTeam
Now let’s look at the structure of the query this setup induces.
First, here’s the general form of the query, without any of the specifics:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
local QB = LCS.class()
local QBRosters = QB:extends()
local QBContracts = QB:extends()
local QBResidency = QB:extends()
function PCT:getQuery(subject)
local rosBuilder = QBRosters()
local contractsBuilder = QBContracts()
local resBuilder = QBResidency()
local query = {
union = {
{
tables = rosBuilder:getTables(),
join = rosBuilder:getJoin(),
where = rosBuilder:getWhere(subject),
fields = rosBuilder:getFields(),
complexTypes = {
Role = {
type = 'RoleList',
args = {
'Roles',
modifier = 'RoleModifier',
}
}
},
},
{
tables = contractsBuilder:getTables(),
join = contractsBuilder:getJoin(),
where = contractsBuilder:getWhere(subject),
fields = contractsBuilder:getFields(),
},
{
tables = resBuilder:getTables(),
join = resBuilder:getJoin(),
where = resBuilder:getWhere(subject),
fields = resBuilder:getFields(),
},
},
sortKey = { 'Date_Sort', 'N_LineInDate', 'N_LineInNews' },
sortOrder = { true, true, true },
}
return query
end
|
As you can see, I’m nesting the union two levels deep instead of one. I’m actually a bit unhappy with this result, and in the future I’m going to adjust it so that only one additional level of nesting is needed; however, this is what I came up with at the time.
Other than the second layer of nesting, this is a pretty clean format. I have an OOP approach here to constructing each of the independent queries, using LuaClassSystem, though that’s not necessary; I could set up each of the parts of the query in any way I wanted. (The complexTypes
thing is just an elaborate method for casting the Roles
field as a RoleList
with the additional parameter that modifier
is given by the field RoleModifier
. And by “elaborate” I mean, “as simple as I could make it without doing any string parsing, which is by necessity a very verbose syntax.”)
Here’s the rest of the query:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
|
function QB:getWhere(player)
local where = {
('PR.OverviewPage="%s"'):format(player),
}
return util_cargo.concatWhere(where)
end
function QB:getFields()
local ret = {
'News.Date_Display',
'News.Region',
'News._pageName',
'News.Date_Sort',
'News.N_LineInDate',
'PR.OverviewPage=PlayerLink',
}
return ret
end
function QBRosters:getTables()
local ret = {
'NewsItems=News',
'RosterChanges=RC',
'PlayerRedirects=PR',
'TeamRedirects=TRed',
'SisterTeams=ST',
}
return ret
end
function QBRosters:getJoin()
local ret = {
'News.NewsId=RC.NewsId',
'RC.Player=PR.AllName',
-- roster changes need to discover the org so we can pull contracts
'RC.Team=TRed.AllName',
'TRed._pageName=ST.Team',
}
return ret
end
function QBRosters:getFields()
local fields = self:super('getFields')
local tbl = {
'RC.Player',
'RC.Team',
'RC.Roles',
'RC.RoleModifier',
'RC.Direction',
'RC.CurrentTeamPriority=Priority',
'RC.Preload',
'RC.Direction',
'RC.N_LineInNews',
'COALESCE(ST._pageName, RC.Team)=SisterTeamPage',
}
util_table.mergeArrays(fields, tbl)
return fields
end
function QBContracts:getTables()
local ret = {
'NewsItems=News',
'Contracts',
'PlayerRedirects=PR',
'TeamRedirects=TRed',
'SisterTeams=ST',
}
return ret
end
function QBContracts:getJoin()
local ret = {
'News.NewsId=Contracts.NewsId',
'Contracts.Player=PR.AllName',
-- contracts need to respect contracts from the same org
'Contracts.Team=TRed.AllName',
'TRed._pageName=ST.Team',
}
return ret
end
function QBContracts:getFields()
local fields = self:super('getFields')
local tbl = {
'COALESCE(ST._pageName, Contracts.Team)=SisterTeamPage',
'Contracts.IsRemoval=IsContractRemoval [boolean]',
'Contracts.ContractEnd',
}
util_table.mergeArrays(fields, tbl)
return fields
end
function QBResidency:getTables()
local ret = {
'NewsItems=News',
'ResidencyChanges=ResC',
'PlayerRedirects=PR',
}
return ret
end
function QBResidency:getJoin()
local ret = {
-- residency changes are just the same news id that's it
'News.NewsId=ResC.NewsId',
'ResC.Player=PR.AllName',
}
return ret
end
function QBResidency:getFields()
local fields = self:super('getFields')
local tbl = {
'ResC.ResidencyOld',
}
util_table.mergeArrays(fields, tbl)
return fields
end
|
The use of LuaClassSystem lets me have the common fields:
1
2
3
4
5
6
7
8
9
10
11
|
function QB:getFields()
local ret = {
'News.Date_Display',
'News.Region',
'News._pageName',
'News.Date_Sort',
'News.N_LineInDate',
'PR.OverviewPage=PlayerLink',
}
return ret
end
|
defined only a single time, and then appended to in each of the individual queries when I subclass. Originally I was also overwriting a parent method for the join, but the fact that join conditions need to be stated in order made that too cumbersome to deal with, so I didn’t bother in that case.
Conclusion
Normally, your goal is to create one Cargo query to fetch all your data at once. However, if you have a bunch of independent results fetched at the same time across a large result set with multiple JOIN
s and several OR
s, it might be that you’re constructing a less efficient query than you could be, and you’d be better off with the UNION
of disjoint queries, each with a single independent clause! In this case, I removed all critical performance issues by splitting up this query, and generated some permanent infrastructure that I was able to apply to other queries as well, which I’ll visit in future blog posts.
Full module code
Here’s the full code of Module:PlayerCurrentTeam:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
|
local util_args = require('Module:ArgsUtil')
local util_cargo = require("Module:CargoUtil")
local util_esports = require("Module:EsportsUtil")
local util_html = require("Module:HtmlUtil")
local util_map = require("Module:MapUtil")
local util_sort = require("Module:SortUtil")
local util_source = require("Module:SourceUtil")
local util_table = require("Module:TableUtil")
local util_text = require("Module:TextUtil")
local util_title = require("Module:TitleUtil")
local util_vars = require("Module:VarsUtil")
local i18n = require('Module:i18nUtil')
local LCS = require('Module:LuaClassSystem')
local OD = require('Module:OrderedDict')
local PCT = LCS.class()
local QB = LCS.class()
local CONTRACT_MAINTAINED_ON_LEAVE = {
from_sister = true,
to_sister = true,
from_academy = true,
to_academy = true,
from_main = true,
to_main = true,
gcd_from_academy = true,
gcd_from_main = true,
gcd_to_academy = true,
gcd_to_main = true,
}
local h = {}
local p = {}
-- for testing to be called from MW
function p.test(frame)
local args = util_args.merge()
local result = p.main(args[1])
local output = mw.html.create('table')
:addClass('wikitable')
for _, row in ipairs(result) do
output:tag('tr')
:tag('td'):wikitext(row.team)
:tag('td'):wikitext(tostring(row.role))
:tag('td'):wikitext(row.role:images())
end
return output
end
-- intended to be called by Infobox/Player
-- so this does not apply any processing to the output
-- and instead just returns a data table
function p.main(player)
return PCT:init(player)
end
function PCT:init(subject)
self.CONTRACT_DATES = {}
self.PREVIOUS_RESIDENCIES = {}
local listOfChanges = self:queryForChanges(subject)
self:processChangesRows(listOfChanges)
if #listOfChanges == 0 then return self:defaultOutput() end
local netStatuses = self:computeNetStatuses(listOfChanges)
local listOfSubjects = self:getFinalListOfSubjects(netStatuses)
return self:makeOutput(listOfSubjects, listOfChanges)
end
function PCT:defaultOutput()
return { last = {}, contractDates = {} }
end
function PCT:queryForChanges(subject)
return util_cargo.queryAndCast(self:getQuery(subject))
end
-- start cargo shit
local QBRosters = QB:extends()
local QBContracts = QB:extends()
local QBResidency = QB:extends()
function PCT:getQuery(subject)
local rosBuilder = QBRosters()
local contractsBuilder = QBContracts()
local resBuilder = QBResidency()
local query = {
union = {
{
tables = rosBuilder:getTables(),
join = rosBuilder:getJoin(),
where = rosBuilder:getWhere(subject),
fields = rosBuilder:getFields(),
complexTypes = {
Role = {
type = 'RoleList',
args = {
'Roles',
modifier = 'RoleModifier',
}
}
},
},
{
tables = contractsBuilder:getTables(),
join = contractsBuilder:getJoin(),
where = contractsBuilder:getWhere(subject),
fields = contractsBuilder:getFields(),
},
{
tables = resBuilder:getTables(),
join = resBuilder:getJoin(),
where = resBuilder:getWhere(subject),
fields = resBuilder:getFields(),
},
},
sortKey = { 'Date_Sort', 'N_LineInDate', 'N_LineInNews' },
sortOrder = { true, true, true },
}
return query
end
function QB:getWhere(player)
local where = {
('PR.OverviewPage="%s"'):format(player),
}
return util_cargo.concatWhere(where)
end
function QB:getFields()
local ret = {
'News.Date_Display',
'News.Region',
'News._pageName',
'News.Date_Sort',
'News.N_LineInDate',
'PR.OverviewPage=PlayerLink',
}
return ret
end
function QBRosters:getTables()
local ret = {
'NewsItems=News',
'RosterChanges=RC',
'PlayerRedirects=PR',
'TeamRedirects=TRed',
'SisterTeams=ST',
}
return ret
end
function QBRosters:getJoin()
local ret = {
'News.NewsId=RC.NewsId',
'RC.Player=PR.AllName',
-- roster changes need to discover the org so we can pull contracts
'RC.Team=TRed.AllName',
'TRed._pageName=ST.Team',
}
return ret
end
function QBRosters:getFields()
local fields = self:super('getFields')
local tbl = {
'RC.Player',
'RC.Team',
'RC.Roles',
'RC.RoleModifier',
'RC.Direction',
'RC.CurrentTeamPriority=Priority',
'RC.Preload',
'RC.Direction',
'RC.N_LineInNews',
'COALESCE(ST._pageName, RC.Team)=SisterTeamPage',
}
util_table.mergeArrays(fields, tbl)
return fields
end
function QBContracts:getTables()
local ret = {
'NewsItems=News',
'Contracts',
'PlayerRedirects=PR',
'TeamRedirects=TRed',
'SisterTeams=ST',
}
return ret
end
function QBContracts:getJoin()
local ret = {
'News.NewsId=Contracts.NewsId',
'Contracts.Player=PR.AllName',
-- contracts need to respect contracts from the same org
'Contracts.Team=TRed.AllName',
'TRed._pageName=ST.Team',
}
return ret
end
function QBContracts:getFields()
local fields = self:super('getFields')
local tbl = {
'COALESCE(ST._pageName, Contracts.Team)=SisterTeamPage',
'Contracts.IsRemoval=IsContractRemoval [boolean]',
'Contracts.ContractEnd',
}
util_table.mergeArrays(fields, tbl)
return fields
end
function QBResidency:getTables()
local ret = {
'NewsItems=News',
'ResidencyChanges=ResC',
'PlayerRedirects=PR',
}
return ret
end
function QBResidency:getJoin()
local ret = {
-- residency changes are just the same news id that's it
'News.NewsId=ResC.NewsId',
'ResC.Player=PR.AllName',
}
return ret
end
function QBResidency:getFields()
local fields = self:super('getFields')
local tbl = {
'ResC.ResidencyOld',
}
util_table.mergeArrays(fields, tbl)
return fields
end
-- end cargo shit
function PCT:processChangesRows(listOfChanges)
util_map.selfRowsInPlace(self, listOfChanges, self.processOneChangeRow)
end
function PCT:processOneChangeRow(row)
row.Subject = row.Team
row.team = row.Team
row.role = row.Role
if h.isContractRemoval(row) then
self.CONTRACT_DATES[row.SisterTeamPage] = nil
end
if row.ContractEnd then
self.CONTRACT_DATES[row.SisterTeamPage] = row.ContractEnd
end
-- this is plaintext so that we can cast it easily as a list when we're done
self.PREVIOUS_RESIDENCIES[#self.PREVIOUS_RESIDENCIES+1] = row.ResidencyOld
end
function h.isContractRemoval(row)
-- In contrast to "Module:PlayerTeamHistoryAbstract" we look at the preload to decide whether
-- or not we have to remove contracts. PTHA instead does this weird reverse-lookup thingy
-- working backwards through history in two phases. PTHA's approach is significantly more complex
-- but maybe not unreasonable because it's doing a ton of other things at the same time. But
-- this approach is hopefully sufficient for this smaller, less-complex use case here.
if row.IsContractRemoval then return true end
if row.Direction == 'Join' then return false end
if not row.Preload then return false end
return not CONTRACT_MAINTAINED_ON_LEAVE[row.Preload]
end
-- get dictionary keyed by subject
function PCT:computeNetStatuses(listOfChanges)
local currentStatuses = {}
for _, row in ipairs(listOfChanges) do
self:updateEntryForThis(currentStatuses, row)
end
return currentStatuses
end
function PCT:updateEntryForThis(currentStatuses, row)
if currentStatuses[row.Subject] then
row.Priority = row.Priority or currentStatuses[row.Subject].Priority
end
if row.Subject then
currentStatuses[row.Subject] = row
end
end
-- get ordered list of subjects
function PCT:getFinalListOfSubjects(netStatuses)
local subjects = {}
for _, status in pairs(netStatuses) do
self:addSubjectToOutputIfNeeded(subjects, status)
end
util_sort.tablesByKeys(subjects, 'Priority', true)
return subjects
end
function PCT:addSubjectToOutputIfNeeded(subjects, status)
if not self:doWeAddSubjectToOutput(status) then return end
subjects[#subjects+1] = status
end
function PCT:doWeAddSubjectToOutput(status)
return status.Direction == 'Join'
end
-- make output
function PCT:makeOutput(listOfSubjects, listOfChanges)
listOfSubjects.last = h.getLastTeamList(listOfChanges)
listOfSubjects.contractDates = self.CONTRACT_DATES
listOfSubjects.resPrevList = self.PREVIOUS_RESIDENCIES
return listOfSubjects
end
function h.getLastTeamList(listOfChanges)
return h.getLastTeamInfo(listOfChanges[#listOfChanges])
end
function h.getLastTeamInfo(row)
local ret = {
team = row.Team,
role = row.Role,
}
return ret
end
return p
|