Skip to content

Commit 0222f58

Browse files
committed
fix(model): scope a nested include's inner join to its own parent join
`findAll(include="...")` copied every INNER JOIN into every LEFT OUTER JOIN. With a single outer join — the shape both issue #449 and issue #3245 exercise — that is indistinguishable from correct. Add a shallow sibling ahead of the nested group and it fans out: include = "c_o_r_e_comments,classifications(tag)" FROM posts LEFT OUTER JOIN (comments INNER JOIN tags ON classifications.tagid = tags.id) ON ... LEFT OUTER JOIN (classifications INNER JOIN tags ON classifications.tagid = tags.id) ON ... The first group references `classifications` before the query introduces it. Reported against Oracle as ORA-00904, but it is engine-independent: on SQLite the same include errors with `no such column: c_o_r_e_classifications.tagid`. The information needed to place each join correctly already existed and was being thrown away. `$expandedAssociations` walks the include with a `levels` stack, so it knows exactly which association nests under which — and then returned a flat array, leaving `$fromClause` to re-derive parentage by regex over the generated SQL text. Both prior patches to this code fought that same information loss. Each entry now carries `parentPosition`, and an INNER join is grouped with one OUTER join: the association it is actually nested under. A root-level INNER join has no enclosing group and stays flat, which keeps the root FROM table in scope for its ON condition — the #3245 failure mode, now prevented structurally rather than by a gate. That also fixes a root-level INNER join being dropped from the output entirely. The old loop only ever emitted the outer-join array, so `include="author,classifications(tag)"` never emitted `INNER JOIN authors` at top level at all — it appeared only inside the classifications group, where its ON references the out-of-scope root. The gate is now redundant, so it is gone. It tested the include STRING against `^([^(]+)\(([^)]+)\)$`, which only matches when the nested group comes last, so `a(b),c` and `c,a(b)` generated different SQL for the same query. Whether a join is correctly scoped is a property of the association tree, not of where the user typed the parentheses. Removing it also collapses the duplicated flat-join branch: 200 lines changed, 42 fewer. Behaviour change, called out in the changelog: in the nested-first form the nested INNER JOIN used to be emitted at the root, demoting the sibling LEFT OUTER JOIN to an inner join and silently dropping parent rows with no associated record. On the fixtures that is 3 rows where the nested-last form returns 15. Both orderings now return 15. A query written nested-first can therefore return more rows than before — the rows a hasMany/hasOne include exists to preserve. Apps that relied on the filtering should set joinType="inner". Red-first, with sql.cfc reverted to develop: 945 pass / 3 fail / 1 error. The three string assertions fail on the generated SQL and the executable one errors with the SQLite message above — the reported symptom, reproduced. Verification, lucee7 + sqlite, full core suite, back to back on one machine: develop ab901cf 4732 pass / 0 fail / 0 error this branch 4736 pass / 0 fail / 0 error Exactly +4, the new specs. The existing #449 and #3245 regression specs pass unchanged and byte-identically — neither expected string needed editing. Reported by Mike Grogan, who also submitted a working patch. His fix localises the same loop but decides ownership by substring-searching the outer join's table name inside each inner join string, which misfires when one table name contains another (c_o_r_e_photos inside c_o_r_e_photogalleryphotos, already present in these fixtures) and leaves the ordering inconsistency in place. Closes #3334 Signed-off-by: Peter Amiri <peter@alurium.com>
1 parent ab901cf commit 0222f58

3 files changed

Lines changed: 160 additions & 113 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- `findAll(include="...")` no longer copies a nested association's `INNER JOIN` into unrelated sibling joins. When a nested group followed one or more shallow associations (e.g. `include="comments,classifications(tag)"`), the issue #449 parenthesized grouping spliced the nested `INNER JOIN` into every preceding `LEFT OUTER JOIN`, so those joins referenced a table the query had not introduced yet — Oracle rejected it with `ORA-00904: invalid identifier`, MySQL with `Unknown column '<table>.<column>' in 'on clause'`. Each `INNER JOIN` is now scoped to the single association it is nested under, taken from the include's association tree rather than re-derived from the generated SQL text. Reported with a working patch by Mike Grogan (#3334)
2+
- **Behaviour change:** `include` order no longer changes the SQL a query generates. Grouping used to be gated on an anchored pattern over the include string that only matched when the nested group came last, so `include="a(b),c"` and `include="c,a(b)"` produced structurally different joins for the same query. In the nested-first form the nested `INNER JOIN` was emitted at the root, which demoted the sibling `LEFT OUTER JOIN` to an inner join and silently dropped parent rows that had no associated record. Both orderings now emit the same joins, so a query written in the nested-first form can return **more** rows than before — the rows a `hasMany`/`hasOne` include is meant to preserve. Pass `joinType="inner"` on the association if the filtering was intentional (#3334)

‎vendor/wheels/model/sql.cfc‎

Lines changed: 89 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -92,134 +92,97 @@ component {
9292
includeSoftDeletes = arguments.includeSoftDeletes
9393
);
9494

95-
// Check if we need to nest inner joins (when both inner and outer joins are present)
96-
// Only apply nesting for HABTM patterns, not for all mixed join scenarios
97-
local.hasInnerJoins = false;
98-
local.hasOuterJoins = false;
99-
local.hasThroughAssociation = false;
10095
local.iEnd = ArrayLen(local.associations);
101-
102-
// Check if this is specifically a HABTM / through bridge pattern. The
103-
// parenthesized-INNER-join grouping below was added for issue #449 so a
104-
// many-to-many bridge (e.g. `memberTeams(member)`) keeps its nested inner
105-
// join scoped to the OUTER-joined bridge table. It must NOT fire for a plain
106-
// `belongsTo`-chain nested include (e.g. `SecondaryContact(User)`): there the
107-
// inner join's ON clause references the root FROM table, and wrapping it
108-
// inside the OUTER group scopes the root out — the MySQL "Unknown column ...
109-
// in 'on clause'" regression reported in issue #3245. So consult the actual
110-
// association metadata for the parenthesized intermediate instead of trusting
111-
// the include string alone: only a `hasMany` / `hasOne` intermediate (the
112-
// OUTER-joined bridge the grouping was designed for) qualifies; a `belongsTo`
113-
// intermediate falls through to the flat-join branch Wheels 2 emitted.
114-
local.originalInclude = Replace(arguments.include, " ", "", "all");
115-
if (Find("(", local.originalInclude)) {
116-
// Parse the include to see if it matches the pattern: intermediate(target)
117-
local.includePattern = ReFindNoCase("^([^(]+)\(([^)]+)\)$", local.originalInclude, 1, true);
118-
if (ArrayLen(local.includePattern.pos) >= 3) {
119-
// The association that parents the parenthesized target is the last
120-
// entry in the comma-list before the "(" (the only level this single-
121-
// paren pattern can match), so it is always a root-model association.
122-
local.intermediateName = ListLast(Mid(local.originalInclude, local.includePattern.pos[2], local.includePattern.len[2]));
123-
if (
124-
StructKeyExists(variables.wheels.class.associations, local.intermediateName)
125-
&& ListFindNoCase(
126-
"hasMany,hasOne",
127-
variables.wheels.class.associations[local.intermediateName].type
128-
)
129-
) {
130-
local.hasThroughAssociation = true;
131-
}
96+
97+
// Build the join statements. Every association carries the position of the
98+
// association it is nested under (`parentPosition`, 0 at the root), so the
99+
// grouping decision below reads the include structure instead of re-deriving
100+
// it from the generated SQL text.
101+
//
102+
// This replaces a gate that only grouped when the include string matched
103+
// `^([^(]+)\(([^)]+)\)$` — i.e. only when the nested group came LAST. Whether
104+
// a join is scoped correctly is a property of the association tree, not of
105+
// where the user happened to type the parentheses, and the old anchored
106+
// pattern made `a(b),c` and `c,a(b)` generate different SQL for the same query
107+
// (issue #3334).
108+
local.joins = [];
109+
110+
for (local.i = 1; local.i <= local.iEnd; local.i++) {
111+
local.indexHint = this.$indexHint(
112+
useIndex = arguments.useIndex,
113+
modelName = local.associations[local.i].modelName,
114+
adapterName = arguments.adapterName
115+
);
116+
local.join = local.associations[local.i].join;
117+
if (Len(local.indexHint)) {
118+
// replace the quoted table name with the quoted table name & index hint
119+
// TODO: factor in table aliases.. the index hint is placed after the table alias
120+
local.quotedAssocTable = variables.wheels.class.adapter.$quoteIdentifier(local.associations[local.i].tableName);
121+
local.join = Replace(
122+
local.join,
123+
" #local.quotedAssocTable# ",
124+
" #local.quotedAssocTable# #local.indexHint# ",
125+
"one"
126+
);
132127
}
128+
local.joins[local.i] = local.join;
133129
}
134-
130+
131+
// Decide which INNER joins get pulled inside a parenthesized group. An INNER join
132+
// belongs to exactly one OUTER join — the association it is nested under in the
133+
// include string — and must never be copied into a sibling, which would reference
134+
// a table the query has not introduced yet (issue #3334: ORA-00904 / MySQL
135+
// "unknown column in on clause"). Prior to this the loop appended every INNER join
136+
// to every OUTER join, which only looked correct because issues #449 and #3245 both
137+
// exercise a single OUTER join. A root-level INNER join (`parentPosition` 0) has no
138+
// enclosing group and stays flat, keeping the root FROM table in scope for its ON.
139+
local.nestedJoins = {};
140+
local.isNested = {};
135141
for (local.i = 1; local.i <= local.iEnd; local.i++) {
136-
if (FindNoCase("INNER", local.associations[local.i].join)) {
137-
local.hasInnerJoins = true;
138-
}
139-
if (FindNoCase("OUTER", local.associations[local.i].join) || FindNoCase("LEFT", local.associations[local.i].join)) {
140-
local.hasOuterJoins = true;
142+
local.parentPosition = StructKeyExists(local.associations[local.i], "parentPosition")
143+
? local.associations[local.i].parentPosition
144+
: 0;
145+
if (
146+
FindNoCase("INNER", local.joins[local.i])
147+
&& local.parentPosition > 0
148+
&& !FindNoCase("INNER", local.joins[local.parentPosition])
149+
) {
150+
if (!StructKeyExists(local.nestedJoins, local.parentPosition)) {
151+
local.nestedJoins[local.parentPosition] = [];
152+
}
153+
ArrayAppend(local.nestedJoins[local.parentPosition], local.joins[local.i]);
154+
local.isNested[local.i] = true;
141155
}
142156
}
143-
144-
// Only apply nesting for through associations with mixed join types
145-
local.needsNesting = local.hasInnerJoins && local.hasOuterJoins && local.hasThroughAssociation;
146157

147-
// build the join statements
148-
if (local.needsNesting) {
149-
// group inner joins with parentheses and outer joins separately
150-
local.innerJoins = [];
151-
local.outerJoins = [];
158+
for (local.i = 1; local.i <= local.iEnd; local.i++) {
159+
if (!StructKeyExists(local.isNested, local.i)) {
160+
local.join = local.joins[local.i];
152161

153-
for (local.i = 1; local.i <= local.iEnd; local.i++) {
154-
local.indexHint = this.$indexHint(
155-
useIndex = arguments.useIndex,
156-
modelName = local.associations[local.i].modelName,
157-
adapterName = arguments.adapterName
158-
);
159-
local.join = local.associations[local.i].join;
160-
if (Len(local.indexHint)) {
161-
// replace the quoted table name with the quoted table name & index hint
162-
// TODO: factor in table aliases.. the index hint is placed after the table alias
163-
local.quotedAssocTable = variables.wheels.class.adapter.$quoteIdentifier(local.associations[local.i].tableName);
164-
local.join = Replace(
165-
local.join,
166-
" #local.quotedAssocTable# ",
167-
" #local.quotedAssocTable# #local.indexHint# ",
168-
"one"
169-
);
170-
}
171-
172-
if (FindNoCase("INNER", local.join)) {
173-
ArrayAppend(local.innerJoins, local.join);
174-
} else {
175-
ArrayAppend(local.outerJoins, local.join);
176-
}
177-
}
178-
179-
for (local.i = 1; local.i <= ArrayLen(local.outerJoins); local.i++) {
180-
local.outerJoin = local.outerJoins[local.i];
181-
182-
// If we have inner joins, we need to group them in the outer join
183-
if (ArrayLen(local.innerJoins) > 0) {
162+
if (StructKeyExists(local.nestedJoins, local.i)) {
184163
// Find the table being joined in the outer join
185-
local.joinTableMatch = ReFindNoCase("LEFT OUTER JOIN ([^\s]+)", local.outerJoin, 1, true);
164+
local.joinTableMatch = ReFindNoCase("LEFT OUTER JOIN ([^\s]+)", local.join, 1, true);
186165
if (ArrayLen(local.joinTableMatch.pos) >= 2 && local.joinTableMatch.pos[2] > 0) {
187-
local.joinTable = Mid(local.outerJoin, local.joinTableMatch.pos[2], local.joinTableMatch.len[2]);
188-
166+
local.joinTable = Mid(local.join, local.joinTableMatch.pos[2], local.joinTableMatch.len[2]);
167+
189168
// Build grouped inner joins: (subscriptions INNER JOIN magazines ON ...)
190169
local.groupedInner = "(" & local.joinTable;
191-
for (local.j = 1; local.j <= ArrayLen(local.innerJoins); local.j++) {
192-
local.groupedInner &= " " & local.innerJoins[local.j];
170+
local.jEnd = ArrayLen(local.nestedJoins[local.i]);
171+
for (local.j = 1; local.j <= local.jEnd; local.j++) {
172+
local.groupedInner &= " " & local.nestedJoins[local.i][local.j];
193173
}
194174
local.groupedInner &= ")";
195-
175+
196176
// Replace in the outer join
197-
local.outerJoin = Replace(local.outerJoin, "LEFT OUTER JOIN " & local.joinTable, "LEFT OUTER JOIN " & local.groupedInner);
177+
local.join = Replace(
178+
local.join,
179+
"LEFT OUTER JOIN " & local.joinTable,
180+
"LEFT OUTER JOIN " & local.groupedInner,
181+
"one"
182+
);
198183
}
199184
}
200-
201-
local.rv = ListAppend(local.rv, local.outerJoin, " ");
202-
}
203-
} else {
204-
// original logic for when nesting is not needed
205-
for (local.i = 1; local.i <= local.iEnd; local.i++) {
206-
local.indexHint = this.$indexHint(
207-
useIndex = arguments.useIndex,
208-
modelName = local.associations[local.i].modelName,
209-
adapterName = arguments.adapterName
210-
);
211-
local.join = local.associations[local.i].join;
212-
if (Len(local.indexHint)) {
213-
// replace the quoted table name with the quoted table name & index hint
214-
// TODO: factor in table aliases.. the index hint is placed after the table alias
215-
local.quotedAssocTable = variables.wheels.class.adapter.$quoteIdentifier(local.associations[local.i].tableName);
216-
local.join = Replace(
217-
local.join,
218-
" #local.quotedAssocTable# ",
219-
" #local.quotedAssocTable# #local.indexHint# ",
220-
"one"
221-
);
222-
}
185+
223186
local.rv = ListAppend(local.rv, local.join, " ");
224187
}
225188
}
@@ -1327,6 +1290,12 @@ component {
13271290
// add the current class name so that the levels list start at the lowest level
13281291
local.levels = variables.wheels.class.modelName;
13291292

1293+
// mirrors `local.levels` with the position in `local.rv` of the association that
1294+
// opened each level, so every entry can record the association it nests under.
1295+
// Callers that group joins (see `$fromClause`) would otherwise have to re-derive
1296+
// parentage from the generated SQL text — the regex guesswork behind issue #3334.
1297+
local.parentPositions = [];
1298+
13301299
// expand through associations before processing
13311300
local.include = $expandThroughAssociations(arguments.include);
13321301

@@ -1342,6 +1311,9 @@ component {
13421311
local.pos = 1;
13431312

13441313
for (local.i = 1; local.i <= local.iEnd; local.i++) {
1314+
// the association that opened the level we are currently inside, or 0 at the root
1315+
local.parentPosition = ArrayLen(local.parentPositions) ? local.parentPositions[ArrayLen(local.parentPositions)] : 0;
1316+
13451317
// look for the next delimiter sequence in the string and set it (can be single delims or a chain, e.g ',' or ')),'
13461318
local.delimFind = ReFind("[(\(|\)|,)]+", local.include, local.pos, true);
13471319
local.delimSequence = Mid(local.include, local.delimFind.pos[1], local.delimFind.len[1]);
@@ -1553,8 +1525,12 @@ component {
15531525
local.delimChar = Mid(local.delimSequence, local.j, 1);
15541526
if (local.delimChar == "(") {
15551527
local.levels = ListAppend(local.levels, local.classAssociations[local.name].modelName);
1528+
// this association parents everything inside the parentheses it just opened;
1529+
// `local.i` is its position in `local.rv` because we append exactly once per pass
1530+
ArrayAppend(local.parentPositions, local.i);
15561531
} else if (local.delimChar == ")") {
15571532
local.levels = ListDeleteAt(local.levels, ListLen(local.levels));
1533+
ArrayDeleteAt(local.parentPositions, ArrayLen(local.parentPositions));
15581534
}
15591535
}
15601536

@@ -1572,6 +1548,8 @@ component {
15721548
// identifiers contain the ON substring (e.g. uppercase H2 schemas)
15731549
local.onPos = Find(" ON ", local.entry.join);
15741550
local.entry.joinOnConditions = local.onPos GT 0 ? Mid(local.entry.join, local.onPos + 4, Len(local.entry.join)) : "";
1551+
// position in this array of the association this one is nested under (0 = root level)
1552+
local.entry.parentPosition = local.parentPosition;
15751553
ArrayAppend(local.rv, local.entry);
15761554
}
15771555
return local.rv;

‎vendor/wheels/tests/specs/model/crudSpec.cfc‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,8 +1273,10 @@ component extends="wheels.WheelsTest" {
12731273
it("emits flat joins for a belongsTo-chain nested include (issue ##3245)", () => {
12741274
actual = g.model("author").$fromClause(include = "posts,user(galleries)")
12751275

1276-
// the parenthesized intermediate (`user`) is a belongsTo, so NO grouping:
1277-
// every join sits at the top level and the root `authors` stays in scope.
1276+
// No join here qualifies for grouping: `user` is INNER but sits at the root
1277+
// (nothing encloses it, and its ON references the root `authors`), and
1278+
// `galleries` is OUTER. Every join stays at the top level, so the root table
1279+
// remains in scope for every ON condition.
12781280
expect(actual).notToInclude("LEFT OUTER JOIN (")
12791281
expect(actual).toBe(
12801282
"FROM #qi('c_o_r_e_authors')#"
@@ -1297,6 +1299,71 @@ component extends="wheels.WheelsTest" {
12971299
& " LEFT OUTER JOIN (#qi('c_o_r_e_memberteams')# INNER JOIN #qi('c_o_r_e_members')# ON #qi('c_o_r_e_memberteams')#.#qi('memberid')# = #qi('c_o_r_e_members')#.#qi('id')#) ON #qi('c_o_r_e_teams')#.#qi('id')# = #qi('c_o_r_e_memberteams')#.#qi('teamid')#"
12981300
)
12991301
})
1302+
1303+
// Regression for issue #3334: the issue #449 grouping copied EVERY inner join
1304+
// into EVERY outer join, so a shallow sibling listed before the nested group
1305+
// got the nested group's INNER join spliced into it — referencing a table the
1306+
// query has not introduced yet (ORA-00904 / "unknown column in on clause").
1307+
// `Post.c_o_r_e_comments` and `Post.classifications` are both hasMany (outer);
1308+
// `Classification.tag` is a belongsTo (inner) whose ON clause references
1309+
// `classifications`, so it belongs to the classifications group and nowhere else.
1310+
it("scopes a nested inner join to its own parent, not to every outer join (issue ##3334)", () => {
1311+
actual = g.model("post").$fromClause(include = "c_o_r_e_comments,classifications(tag)")
1312+
1313+
// the comments join must stay flat — nothing from the classifications
1314+
// subtree may appear inside it
1315+
expect(actual).toBe(
1316+
"FROM #qi('c_o_r_e_posts')#"
1317+
& " LEFT OUTER JOIN #qi('c_o_r_e_comments')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_comments')#.#qi('postid')#"
1318+
& " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#"
1319+
)
1320+
})
1321+
1322+
// Second shape of issue #3334, and a residual case of issue #3245 that the
1323+
// #3245 gate does not cover: a ROOT-level inner join (`Post.author` is a
1324+
// belongsTo) alongside a nested group. Its ON clause references the root
1325+
// `posts` table, so pulling it inside the classifications parentheses scopes
1326+
// the root out — the same "unknown column in on clause" failure #3245 fixed
1327+
// for the flat branch. A root-level join has no enclosing group; it stays flat.
1328+
it("keeps a root-level inner join out of the nested group (issue ##3334)", () => {
1329+
actual = g.model("post").$fromClause(include = "author,classifications(tag)")
1330+
1331+
expect(actual).toBe(
1332+
"FROM #qi('c_o_r_e_posts')#"
1333+
& " INNER JOIN #qi('c_o_r_e_authors')# ON #qi('c_o_r_e_posts')#.#qi('authorid')# = #qi('c_o_r_e_authors')#.#qi('id')#"
1334+
& " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#"
1335+
)
1336+
})
1337+
1338+
// Issue #3334, the reporter's actual complaint: the grouping used to be gated on
1339+
// an anchored regex over the include STRING, so it only fired when the nested
1340+
// group came last. `a(b),c` and `c,a(b)` therefore generated structurally
1341+
// different SQL for the same query — one of them invalid. Grouping is now decided
1342+
// from the association tree, so include order only reorders the emitted joins.
1343+
it("emits the same joins wherever the nested group sits in the include (issue ##3334)", () => {
1344+
comments = " LEFT OUTER JOIN #qi('c_o_r_e_comments')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_comments')#.#qi('postid')#"
1345+
classifications = " LEFT OUTER JOIN (#qi('c_o_r_e_classifications')# INNER JOIN #qi('c_o_r_e_tags')# ON #qi('c_o_r_e_classifications')#.#qi('tagid')# = #qi('c_o_r_e_tags')#.#qi('id')#) ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_classifications')#.#qi('postid')#"
1346+
1347+
expect(g.model("post").$fromClause(include = "c_o_r_e_comments,classifications(tag)")).toBe(
1348+
"FROM #qi('c_o_r_e_posts')#" & comments & classifications
1349+
)
1350+
expect(g.model("post").$fromClause(include = "classifications(tag),c_o_r_e_comments")).toBe(
1351+
"FROM #qi('c_o_r_e_posts')#" & classifications & comments
1352+
)
1353+
})
1354+
1355+
// Executable form of the above — the string assertions pin the SQL, this proves
1356+
// the database accepts it and that both orderings agree on the result set. The
1357+
// nested-first ordering used to emit `tags` as a ROOT-level inner join, which
1358+
// silently demoted the outer join to an inner one and dropped every post with no
1359+
// classification; it now keeps them, matching the nested-last ordering.
1360+
it("returns the same rows wherever the nested group sits in the include (issue ##3334)", () => {
1361+
nestedLast = g.model("post").findAll(include = "c_o_r_e_comments,classifications(tag)")
1362+
nestedFirst = g.model("post").findAll(include = "classifications(tag),c_o_r_e_comments")
1363+
1364+
expect(nestedLast.recordCount).toBeGT(0)
1365+
expect(nestedLast.recordCount).toBe(nestedFirst.recordCount)
1366+
})
13001367
})
13011368

13021369
describe("Tests that group", () => {

0 commit comments

Comments
 (0)