Skip to content

Commit 55d347f

Browse files
finished unit tests and cursor ordering fixes
1 parent 9f89f10 commit 55d347f

File tree

6 files changed

+819
-543
lines changed

6 files changed

+819
-543
lines changed

Magic.IndexedDb/Testing/Helpers/AllPaths.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace Magic.IndexedDb.Testing.Helpers
99
{
1010
public class QueryTestBlueprint<T>
1111
{
12+
public List<Func<T, object>>? IndexOrderingProperties { get; set; }
1213
public List<Expression<Func<T, bool>>> WherePredicates { get; set; } = new();
1314
public List<Expression<Func<T, object>>> OrderBys { get; set; } = new();
1415
public List<Expression<Func<T, object>>> OrderByDescendings { get; set; } = new();

Magic.IndexedDb/Testing/Helpers/MagicQueryPathWalker.cs

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,22 +58,36 @@ QueryTestBlueprint<T> bp
5858
// STEP 4: If no explicit order, apply default ordering if Take/Skip is used
5959
if (!hasExplicitOrder && (trail.Contains("Take") || trail.Contains("Skip") || trail.Contains("TakeLast")))
6060
{
61+
IOrderedEnumerable<T>? stableOrdered = null;
62+
63+
// Step 1: Apply IndexOrderingProperties first (e.g., from .Index("TestInt"))
64+
if (bp.IndexOrderingProperties?.Count > 0)
65+
{
66+
foreach (var selector in bp.IndexOrderingProperties)
67+
{
68+
stableOrdered = stableOrdered == null
69+
? result.OrderBy(selector)
70+
: stableOrdered.ThenBy(selector);
71+
}
72+
}
73+
74+
// Step 2: ALWAYS apply compound key fallback to break ties
6175
var compoundKey = new T().GetKeys();
6276
if (compoundKey?.PropertyInfos?.Length > 0)
6377
{
64-
IOrderedEnumerable<T>? stableOrdered = null;
6578
foreach (var prop in compoundKey.PropertyInfos)
6679
{
6780
stableOrdered = stableOrdered == null
6881
? result.OrderBy(x => prop.GetValue(x) ?? "")
6982
: stableOrdered.ThenBy(x => prop.GetValue(x) ?? "");
7083
}
71-
72-
if (stableOrdered != null)
73-
result = stableOrdered;
7484
}
85+
86+
if (stableOrdered != null)
87+
result = stableOrdered;
7588
}
7689

90+
7791
// STEP 5: Detect if Skip should come before Take
7892
bool hasTake = trail.Contains("Take");
7993
bool hasSkip = trail.Contains("Skip");
@@ -113,19 +127,20 @@ public static List<ExecutionPath<T>> GenerateAllPaths<T>(
113127
QueryTestBlueprint<T> blueprint,
114128
int maxDepth = 6,
115129
Dictionary<string, int>? repetitionOverrides = null,
116-
Func<QueryTestBlueprint<T>, IMagicCursor<T>>? cursorProvider = null
130+
Func<QueryTestBlueprint<T>, IMagicCursor<T>>? cursorProvider = null,
131+
int? overrideMaxRepetitions = null
117132
) where T : class, IMagicTableBase, new()
118133
{
119134
var map = AllPaths.BuildTransitionMap<T>(repetitionOverrides);
120135
var results = new List<ExecutionPath<T>>();
121136
var seenPaths = new HashSet<string>();
122137

123-
Explore(map, baseQuery, typeof(IMagicQuery<T>), allPeople, blueprint, results, seenPaths, maxDepth);
138+
Explore(map, baseQuery, typeof(IMagicQuery<T>), allPeople, blueprint, results, seenPaths, maxDepth, null, null, null, 0, overrideMaxRepetitions);
124139

125140
if (cursorProvider != null)
126141
{
127142
var cursorQuery = cursorProvider(blueprint);
128-
Explore(map, cursorQuery, typeof(IMagicCursor<T>), allPeople, blueprint, results, seenPaths, maxDepth);
143+
Explore(map, cursorQuery, typeof(IMagicCursor<T>), allPeople, blueprint, results, seenPaths, maxDepth, null, null, null, 0, overrideMaxRepetitions);
129144
}
130145

131146
return results;
@@ -143,7 +158,8 @@ private static void Explore<T>(
143158
List<string>? trail = null,
144159
Func<IQueryable<T>, IQueryable<T>>? linq = null,
145160
Dictionary<string, int>? used = null,
146-
int depth = 0
161+
int depth = 0,
162+
int? overrideMaxRepetitions = null
147163
) where T : class, IMagicTableBase, new()
148164
{
149165
if (depth > maxDepth || !map.TryGetValue(currentType, out var transitions))
@@ -155,10 +171,14 @@ private static void Explore<T>(
155171

156172
foreach (var transition in transitions)
157173
{
174+
int reps = transition.MaxRepetitions;
175+
if (overrideMaxRepetitions != null)
176+
reps = overrideMaxRepetitions ?? transition.MaxRepetitions;
177+
158178
var nextUsed = new Dictionary<string, int>(used);
159179
if (!nextUsed.TryGetValue(transition.Name, out var count))
160180
count = 0;
161-
if (count >= transition.MaxRepetitions) continue;
181+
if (count >= reps) continue;
162182
nextUsed[transition.Name] = count + 1;
163183

164184
List<string> newTrail = trail.Append(transition.Name).ToList();
@@ -180,12 +200,29 @@ private static void Explore<T>(
180200

181201
if (nextQuery is IMagicExecute<T>)
182202
{
203+
// Create a blueprint clone with filtered IndexOrderingProperties
204+
var localBlueprint = new QueryTestBlueprint<T>
205+
{
206+
WherePredicates = blueprint.WherePredicates,
207+
OrderBys = blueprint.OrderBys,
208+
OrderByDescendings = blueprint.OrderByDescendings,
209+
SkipValues = blueprint.SkipValues,
210+
TakeValues = blueprint.TakeValues,
211+
TakeLastValues = blueprint.TakeLastValues,
212+
213+
// Only apply index ordering if a relevant Where is in the trail
214+
IndexOrderingProperties = newTrail.Contains("Where") || newTrail.Contains("Cursor")
215+
? blueprint.IndexOrderingProperties
216+
: null
217+
};
218+
183219
results.Add(new ExecutionPath<T>
184220
{
185221
Name = pathKey,
186222
ExecuteDb = async () => await ((IMagicExecute<T>)nextQuery).ToListAsync(),
187-
ExecuteInMemory = () => MagicInMemoryExecutor.Execute(allPeople, newTrail, blueprint)
223+
ExecuteInMemory = () => MagicInMemoryExecutor.Execute(allPeople, newTrail, localBlueprint)
188224
});
225+
189226
}
190227

191228
Explore(
@@ -200,7 +237,8 @@ private static void Explore<T>(
200237
newTrail,
201238
newLinq,
202239
nextUsed,
203-
depth + 1
240+
depth + 1,
241+
overrideMaxRepetitions
204242
);
205243
}
206244
catch

Magic.IndexedDb/wwwroot/utilities/cursorEngine.js

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ export async function runCursorQuery(db, table, conditions, queryAdditions, yiel
2727
// **Metadata Path: Extract primary keys and sorting properties**
2828
let primaryKeyList = await runMetaDataCursorQuery(db, table, structuredPredicateTree, queryAdditions, yieldedPrimaryKeys, compoundKeys);
2929

30+
const indexOrderProps = detectIndexOrderProperties(structuredPredicateTree, table);
31+
3032
// **Apply sorting, take, and skip operations**
31-
let finalPrimaryKeys = applyCursorQueryAdditions(primaryKeyList, queryAdditions, compoundKeys);
33+
let finalPrimaryKeys = applyCursorQueryAdditions(primaryKeyList, queryAdditions, compoundKeys, true, indexOrderProps);
3234

3335
// **Fetch only the required records from IndexedDB**
3436
let finalRecords = await fetchRecordsByPrimaryKeys(db, table, finalPrimaryKeys, compoundKeys);
@@ -41,6 +43,55 @@ export async function runCursorQuery(db, table, conditions, queryAdditions, yiel
4143
}
4244
}
4345

46+
function detectIndexOrderProperties(predicateTree, table) {
47+
const indexedProps = new Set();
48+
49+
// Step 1: Get actual indexed fields from Dexie table schema
50+
const dexieIndexKeys = Object.keys(table.schema.idxByName || {});
51+
52+
// This gives you:
53+
// - For single indexes: ["Email", "Age"]
54+
// - For compound indexes: ["[FirstName+LastName]", "[LastName+Age]"], etc.
55+
56+
// Step 2: Expand compound indexes
57+
const normalizedIndexProps = new Set();
58+
59+
for (const idx of dexieIndexKeys) {
60+
if (idx.startsWith('[')) {
61+
const parts = idx.replace(/[\[\]]/g, "").split("+").map(x => x.trim());
62+
for (const p of parts) normalizedIndexProps.add(p);
63+
} else {
64+
normalizedIndexProps.add(idx);
65+
}
66+
}
67+
68+
// Step 3: Walk the predicate tree
69+
walkPredicateTree(predicateTree, node => {
70+
if (node.nodeType === "condition") {
71+
const prop = node.condition?.property;
72+
if (normalizedIndexProps.has(prop)) {
73+
indexedProps.add(prop);
74+
}
75+
}
76+
});
77+
78+
return [...indexedProps];
79+
}
80+
81+
function walkPredicateTree(node, visitFn) {
82+
if (!node)
83+
return;
84+
85+
if (node.nodeType === "condition") {
86+
visitFn(node); // Visit condition nodes directly
87+
} else if (node.nodeType === "logical" && Array.isArray(node.children)) {
88+
for (const child of node.children) {
89+
walkPredicateTree(child, visitFn);
90+
}
91+
}
92+
}
93+
94+
4495
let lastCursorWarningTime = null;
4596

4697
/**
@@ -679,32 +730,43 @@ async function fetchRecordsByPrimaryKeys(db, table, primaryKeys, compoundKeys, b
679730
}
680731

681732

682-
function applyCursorQueryAdditions(primaryKeyList, queryAdditions, compoundKeys, flipSkipTakeOrder = true) {
733+
function applyCursorQueryAdditions(
734+
primaryKeyList,
735+
queryAdditions,
736+
compoundKeys,
737+
flipSkipTakeOrder = true,
738+
detectedIndexOrderProperties = []
739+
) {
683740
if (!queryAdditions || queryAdditions.length === 0) {
684741
return primaryKeyList.map(item =>
685742
compoundKeys.map(key => item.sortingProperties[key])
686743
);
687744
}
745+
// waca
746+
debugLog("Applying cursor query additions in strict given order", {
747+
queryAdditions,
748+
detectedIndexOrderProperties
749+
});
688750

689-
debugLog("Applying cursor query additions in strict given order", { queryAdditions });
690-
691-
let additions = [...queryAdditions]; // Copy to avoid modifying original
751+
let additions = [...queryAdditions]; // Avoid modifying original
692752
let needsReverse = false;
693753

694-
// **Avoid unnecessary sort if `_MagicOrderId` is already ordered**
695-
let isAlreadyOrdered = additions.every(a =>
696-
a.additionFunction !== QUERY_ADDITIONS.ORDER_BY &&
697-
a.additionFunction !== QUERY_ADDITIONS.ORDER_BY_DESCENDING
698-
);
699-
700-
if (!isAlreadyOrdered) {
701-
primaryKeyList.sort((a, b) => a.sortingProperties["_MagicOrderId"] - b.sortingProperties["_MagicOrderId"]);
754+
// Step 0: Always apply detectedIndexOrderProperties first
755+
if (detectedIndexOrderProperties?.length > 0) {
756+
primaryKeyList.sort((a, b) => {
757+
for (let prop of detectedIndexOrderProperties) {
758+
const aVal = a.sortingProperties[prop];
759+
const bVal = b.sortingProperties[prop];
760+
if (aVal !== bVal) return aVal > bVal ? 1 : -1;
761+
}
762+
// Always fallback to internal row ordering
763+
return a.sortingProperties["_MagicOrderId"] - b.sortingProperties["_MagicOrderId"];
764+
});
702765
}
703766

704-
// **Optimized order-flipping logic**
767+
// Flip TAKE + SKIP if needed (for consistent cursor behavior)
705768
if (flipSkipTakeOrder) {
706769
let takeIndex = -1, skipIndex = -1;
707-
708770
for (let i = 0; i < additions.length; i++) {
709771
if (additions[i].additionFunction === QUERY_ADDITIONS.TAKE) takeIndex = i;
710772
if (additions[i].additionFunction === QUERY_ADDITIONS.SKIP) skipIndex = i;
@@ -716,7 +778,7 @@ function applyCursorQueryAdditions(primaryKeyList, queryAdditions, compoundKeys,
716778
}
717779
}
718780

719-
// **Step 2: Apply Query Additions in Exact Order**
781+
// Step 1: Apply all query additions in declared order
720782
for (const addition of additions) {
721783
switch (addition.additionFunction) {
722784
case QUERY_ADDITIONS.ORDER_BY:
@@ -731,6 +793,8 @@ function applyCursorQueryAdditions(primaryKeyList, queryAdditions, compoundKeys,
731793
? (valueB > valueA ? 1 : -1)
732794
: (valueA > valueB ? 1 : -1);
733795
}
796+
797+
// Fallback to row order
734798
return a.sortingProperties["_MagicOrderId"] - b.sortingProperties["_MagicOrderId"];
735799
});
736800
break;
@@ -740,7 +804,7 @@ function applyCursorQueryAdditions(primaryKeyList, queryAdditions, compoundKeys,
740804
break;
741805

742806
case QUERY_ADDITIONS.TAKE:
743-
primaryKeyList.length = Math.min(primaryKeyList.length, addition.intValue); // **Avoid new array allocation**
807+
primaryKeyList.length = Math.min(primaryKeyList.length, addition.intValue);
744808
break;
745809

746810
case QUERY_ADDITIONS.TAKE_LAST:
@@ -749,7 +813,7 @@ function applyCursorQueryAdditions(primaryKeyList, queryAdditions, compoundKeys,
749813
break;
750814

751815
case QUERY_ADDITIONS.FIRST:
752-
primaryKeyList.length = primaryKeyList.length > 0 ? 1 : 0; // **No new array allocation**
816+
primaryKeyList.length = primaryKeyList.length > 0 ? 1 : 0;
753817
break;
754818

755819
case QUERY_ADDITIONS.LAST:
@@ -761,14 +825,12 @@ function applyCursorQueryAdditions(primaryKeyList, queryAdditions, compoundKeys,
761825
}
762826
}
763827

764-
// **Step 3: Reverse if TAKE_LAST was used**
765828
if (needsReverse) {
766829
primaryKeyList.reverse();
767830
}
768831

769832
debugLog("Final Ordered Primary Key List", primaryKeyList);
770833

771-
// **Final Map: Extract only needed keys in the correct order**
772834
return primaryKeyList.map(item =>
773835
compoundKeys.map(key => item.sortingProperties[key])
774836
);

0 commit comments

Comments
 (0)