@tldraw/store 4.2.0-next.e824a30c434e → 4.2.0-next.f100cedfc45b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,44 +37,69 @@ export type QueryValueMatcher<T> = { eq: T } | { neq: T } | { gt: number }
37
37
  * const notByAuthor: QueryExpression<Book> = {
38
38
  * authorId: { neq: 'author:tolkien' }
39
39
  * }
40
+ *
41
+ * // Query with nested properties
42
+ * const nestedQuery: QueryExpression<Book> = {
43
+ * metadata: { sessionId: { eq: 'session:alpha' } }
44
+ * }
40
45
  * ```
41
46
  *
42
47
  * @public
43
48
  */
49
+ /** @public */
44
50
  export type QueryExpression<R extends object> = {
45
- [k in keyof R & string]?: QueryValueMatcher<R[k]>
46
- // todo: handle nesting
47
- // | (R[k] extends object ? { match: QueryExpression<R[k]> } : never)
51
+ [k in keyof R & string]?: R[k] extends string | number | boolean | null | undefined
52
+ ? QueryValueMatcher<R[k]>
53
+ : R[k] extends object
54
+ ? QueryExpression<R[k]>
55
+ : QueryValueMatcher<R[k]>
56
+ }
57
+
58
+ function isQueryValueMatcher(value: unknown): value is QueryValueMatcher<unknown> {
59
+ if (typeof value !== 'object' || value === null) return false
60
+ return 'eq' in value || 'neq' in value || 'gt' in value
61
+ }
62
+
63
+ function extractMatcherPaths(
64
+ query: QueryExpression<any>,
65
+ prefix: string = ''
66
+ ): Array<{ path: string; matcher: QueryValueMatcher<any> }> {
67
+ const paths: Array<{ path: string; matcher: QueryValueMatcher<any> }> = []
68
+
69
+ for (const [key, value] of Object.entries(query)) {
70
+ const currentPath = prefix ? `${prefix}\\${key}` : key
71
+
72
+ if (isQueryValueMatcher(value)) {
73
+ // It's a direct matcher
74
+ paths.push({ path: currentPath, matcher: value })
75
+ } else if (typeof value === 'object' && value !== null) {
76
+ // It's a nested query - recurse into it
77
+ paths.push(...extractMatcherPaths(value as QueryExpression<any>, currentPath))
78
+ }
79
+ }
80
+
81
+ return paths
48
82
  }
49
83
 
50
- /**
51
- * Tests whether an object matches the given query expression by checking each property
52
- * against its corresponding matcher criteria.
53
- *
54
- * @param query - The query expression containing matching criteria for object properties
55
- * @param object - The object to test against the query
56
- * @returns True if the object matches all criteria in the query, false otherwise
57
- *
58
- * @example
59
- * ```ts
60
- * const book = { title: '1984', publishedYear: 1949, inStock: true }
61
- * const query = { publishedYear: { gt: 1945 }, inStock: { eq: true } }
62
- *
63
- * const matches = objectMatchesQuery(query, book) // true
64
- * ```
65
- *
66
- * @public
67
- */
68
84
  export function objectMatchesQuery<T extends object>(query: QueryExpression<T>, object: T) {
69
- for (const [key, _matcher] of Object.entries(query)) {
70
- const matcher = _matcher as QueryValueMatcher<T>
85
+ for (const [key, matcher] of Object.entries(query)) {
71
86
  const value = object[key as keyof T]
87
+
72
88
  // if you add matching logic here, make sure you also update executeQuery,
73
89
  // where initial data is pulled out of the indexes, since that requires different
74
90
  // matching logic
75
- if ('eq' in matcher && value !== matcher.eq) return false
76
- if ('neq' in matcher && value === matcher.neq) return false
77
- if ('gt' in matcher && (typeof value !== 'number' || value <= matcher.gt)) return false
91
+ if (isQueryValueMatcher(matcher)) {
92
+ if ('eq' in matcher && value !== matcher.eq) return false
93
+ if ('neq' in matcher && value === matcher.neq) return false
94
+ if ('gt' in matcher && (typeof value !== 'number' || value <= matcher.gt)) return false
95
+ continue
96
+ }
97
+
98
+ // It's a nested query
99
+ if (typeof value !== 'object' || value === null) return false
100
+ if (!objectMatchesQuery(matcher as QueryExpression<any>, value as any)) {
101
+ return false
102
+ }
78
103
  }
79
104
  return true
80
105
  }
@@ -100,6 +125,11 @@ export function objectMatchesQuery<T extends object>(query: QueryExpression<T>,
100
125
  * const otherBookIds = executeQuery(store, 'book', {
101
126
  * authorId: { neq: 'author:tolkien' }
102
127
  * })
128
+ *
129
+ * // Query with nested properties
130
+ * const nestedQueryIds = executeQuery(store, 'book', {
131
+ * metadata: { sessionId: { eq: 'session:alpha' } }
132
+ * })
103
133
  * ```
104
134
  *
105
135
  * @public
@@ -109,37 +139,49 @@ export function executeQuery<R extends UnknownRecord, TypeName extends R['typeNa
109
139
  typeName: TypeName,
110
140
  query: QueryExpression<Extract<R, { typeName: TypeName }>>
111
141
  ): Set<IdOf<Extract<R, { typeName: TypeName }>>> {
112
- const matchIds = Object.fromEntries(Object.keys(query).map((key) => [key, new Set()]))
142
+ type S = Extract<R, { typeName: TypeName }>
143
+
144
+ // Extract all paths with matchers (flattens nested queries)
145
+ const matcherPaths = extractMatcherPaths(query)
146
+
147
+ // Build a set of matching IDs for each path
148
+ const matchIds = Object.fromEntries(matcherPaths.map(({ path }) => [path, new Set<IdOf<S>>()]))
149
+
150
+ // For each path, use the index to find matching IDs
151
+ for (const { path, matcher } of matcherPaths) {
152
+ const index = store.index(typeName, path as any)
113
153
 
114
- for (const [k, matcher] of Object.entries(query)) {
115
154
  if ('eq' in matcher) {
116
- const index = store.index(typeName, k as any)
117
155
  const ids = index.get().get(matcher.eq)
118
156
  if (ids) {
119
157
  for (const id of ids) {
120
- matchIds[k].add(id)
158
+ matchIds[path].add(id)
121
159
  }
122
160
  }
123
161
  } else if ('neq' in matcher) {
124
- const index = store.index(typeName, k as any)
125
162
  for (const [value, ids] of index.get()) {
126
163
  if (value !== matcher.neq) {
127
164
  for (const id of ids) {
128
- matchIds[k].add(id)
165
+ matchIds[path].add(id)
129
166
  }
130
167
  }
131
168
  }
132
169
  } else if ('gt' in matcher) {
133
- const index = store.index(typeName, k as any)
134
170
  for (const [value, ids] of index.get()) {
135
- if (value > matcher.gt) {
171
+ if (typeof value === 'number' && value > matcher.gt) {
136
172
  for (const id of ids) {
137
- matchIds[k].add(id)
173
+ matchIds[path].add(id)
138
174
  }
139
175
  }
140
176
  }
141
177
  }
178
+
179
+ // Short-circuit if this set is empty - intersection will be empty
180
+ if (matchIds[path].size === 0) {
181
+ return new Set()
182
+ }
142
183
  }
143
184
 
144
- return intersectSets(Object.values(matchIds)) as Set<IdOf<Extract<R, { typeName: TypeName }>>>
185
+ // Intersect all the match sets
186
+ return intersectSets(Object.values(matchIds)) as Set<IdOf<S>>
145
187
  }