qdadm 0.18.0 → 0.26.0
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.
- package/package.json +1 -1
- package/src/composables/useListPageBuilder.js +301 -49
- package/src/entity/EntityManager.js +293 -24
- package/src/entity/storage/ApiStorage.js +19 -2
- package/src/entity/storage/LocalStorage.js +25 -2
- package/src/entity/storage/MemoryStorage.js +28 -0
- package/src/entity/storage/MockApiStorage.js +25 -2
- package/src/entity/storage/SdkStorage.js +17 -2
- package/src/entity/storage/index.js +105 -0
- package/src/index.js +3 -0
- package/src/query/FilterQuery.js +277 -0
- package/src/query/QueryExecutor.js +332 -0
- package/src/query/index.js +8 -0
|
@@ -5,6 +5,111 @@
|
|
|
5
5
|
* EntityManagers can use these or implement their own storage.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Storage Capabilities Interface
|
|
10
|
+
*
|
|
11
|
+
* Static capabilities declaration that storage adapters expose via `static capabilities = {...}`.
|
|
12
|
+
* EntityManager reads these via `storage.constructor.capabilities` to determine feature support.
|
|
13
|
+
*
|
|
14
|
+
* All properties default to `false` if not declared (conservative assumption).
|
|
15
|
+
* Custom storage implementations that don't declare capabilities will degrade gracefully
|
|
16
|
+
* via fallback to empty `{}`.
|
|
17
|
+
*
|
|
18
|
+
* @typedef {object} StorageCapabilities
|
|
19
|
+
* @property {boolean} [supportsTotal=false] - Storage `list()` returns `{ items, total }` with accurate total count
|
|
20
|
+
* @property {boolean} [supportsFilters=false] - Storage `list()` accepts `filters` param and handles filtering
|
|
21
|
+
* @property {boolean} [supportsPagination=false] - Storage `list()` accepts `page`/`page_size` params
|
|
22
|
+
* @property {boolean} [supportsCaching=false] - Storage benefits from EntityManager cache layer (network-based storages)
|
|
23
|
+
* @property {string[]} [searchFields] - Fields to search when filtering locally. Supports own fields ('title')
|
|
24
|
+
* and parent entity fields ('book.title') via EntityManager.parents config. When undefined, all string
|
|
25
|
+
* fields are searched (default behavior). When defined, only listed fields are searched.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Migration Guide for Custom Storage Adapters
|
|
30
|
+
*
|
|
31
|
+
* If you have a custom storage adapter, you can add capabilities support in two ways:
|
|
32
|
+
*
|
|
33
|
+
* **Option 1: Static capabilities (recommended)**
|
|
34
|
+
* Add a static `capabilities` property to your class. EntityManager reads this via
|
|
35
|
+
* `storage.constructor.capabilities`. This is the preferred pattern for new code.
|
|
36
|
+
*
|
|
37
|
+
* ```js
|
|
38
|
+
* export class MyCustomStorage {
|
|
39
|
+
* static capabilities = {
|
|
40
|
+
* supportsTotal: true,
|
|
41
|
+
* supportsFilters: true,
|
|
42
|
+
* supportsPagination: true,
|
|
43
|
+
* supportsCaching: true // set to false for in-memory storages
|
|
44
|
+
* }
|
|
45
|
+
*
|
|
46
|
+
* // Backward-compat instance getter (optional, for smooth migration)
|
|
47
|
+
* get supportsCaching() {
|
|
48
|
+
* return MyCustomStorage.capabilities.supportsCaching
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* async list(params) { ... }
|
|
52
|
+
* async get(id) { ... }
|
|
53
|
+
* // ... other methods
|
|
54
|
+
* }
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* **Option 2: Instance property only (legacy)**
|
|
58
|
+
* Keep using instance properties. EntityManager's `isCacheEnabled` check uses:
|
|
59
|
+
* `if (storage?.supportsCaching === false) return false`
|
|
60
|
+
* This works with instance properties directly.
|
|
61
|
+
*
|
|
62
|
+
* ```js
|
|
63
|
+
* export class MyLegacyStorage {
|
|
64
|
+
* supportsCaching = true // or false for in-memory
|
|
65
|
+
*
|
|
66
|
+
* async list(params) { ... }
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* **No capabilities declared**
|
|
71
|
+
* If your storage doesn't declare capabilities, EntityManager will:
|
|
72
|
+
* - Assume `supportsTotal: false` (disables auto-caching threshold check)
|
|
73
|
+
* - Assume `supportsCaching: undefined` (caching allowed, but threshold check fails)
|
|
74
|
+
* - Gracefully degrade without errors
|
|
75
|
+
*
|
|
76
|
+
* Use `getStorageCapabilities(storage)` helper to read merged capabilities with defaults.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Default capabilities for storages that don't declare their own.
|
|
81
|
+
* All capabilities default to false for safe degradation.
|
|
82
|
+
*
|
|
83
|
+
* @type {StorageCapabilities}
|
|
84
|
+
*/
|
|
85
|
+
export const DEFAULT_STORAGE_CAPABILITIES = {
|
|
86
|
+
supportsTotal: false,
|
|
87
|
+
supportsFilters: false,
|
|
88
|
+
supportsPagination: false,
|
|
89
|
+
supportsCaching: false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read capabilities from a storage instance.
|
|
94
|
+
* Accesses static `capabilities` property via constructor with fallback to defaults.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} storage - Storage adapter instance
|
|
97
|
+
* @returns {StorageCapabilities} Merged capabilities with defaults
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* const caps = getStorageCapabilities(myStorage)
|
|
101
|
+
* if (caps.supportsTotal) {
|
|
102
|
+
* // Storage provides accurate total count
|
|
103
|
+
* }
|
|
104
|
+
*/
|
|
105
|
+
export function getStorageCapabilities(storage) {
|
|
106
|
+
const declared = storage?.constructor?.capabilities || {}
|
|
107
|
+
return {
|
|
108
|
+
...DEFAULT_STORAGE_CAPABILITIES,
|
|
109
|
+
...declared
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
8
113
|
export { ApiStorage, createApiStorage } from './ApiStorage.js'
|
|
9
114
|
export { LocalStorage, createLocalStorage } from './LocalStorage.js'
|
|
10
115
|
export { MemoryStorage, createMemoryStorage } from './MemoryStorage.js'
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterQuery - Resolves dropdown options for filters
|
|
3
|
+
*
|
|
4
|
+
* Abstracts option resolution from two sources:
|
|
5
|
+
* - 'entity': fetch options from a related EntityManager
|
|
6
|
+
* - 'field': extract distinct values from parent manager's cached items
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```js
|
|
10
|
+
* // Entity source - fetch from genres EntityManager
|
|
11
|
+
* const query = new FilterQuery({
|
|
12
|
+
* source: 'entity',
|
|
13
|
+
* entity: 'genres',
|
|
14
|
+
* label: 'name',
|
|
15
|
+
* value: 'id'
|
|
16
|
+
* })
|
|
17
|
+
*
|
|
18
|
+
* // Field source - extract unique status values
|
|
19
|
+
* const query = new FilterQuery({
|
|
20
|
+
* source: 'field',
|
|
21
|
+
* field: 'status'
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* // Get options (async)
|
|
25
|
+
* const options = await query.getOptions(orchestrator)
|
|
26
|
+
* // → [{ label: 'Rock', value: 1 }, { label: 'Jazz', value: 2 }]
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
import { getNestedValue } from './QueryExecutor.js'
|
|
30
|
+
|
|
31
|
+
const VALID_SOURCES = ['entity', 'field']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a value from an item using a path or function
|
|
35
|
+
*
|
|
36
|
+
* @param {object} item - The item to extract value from
|
|
37
|
+
* @param {string|Function} resolver - Path string ('name', 'author.country') or function (item => value)
|
|
38
|
+
* @returns {*} The resolved value
|
|
39
|
+
*/
|
|
40
|
+
function resolveValue(item, resolver) {
|
|
41
|
+
if (typeof resolver === 'function') {
|
|
42
|
+
return resolver(item)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Use shared getNestedValue for path resolution
|
|
46
|
+
return getNestedValue(resolver, item)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class FilterQuery {
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} options
|
|
52
|
+
* @param {'entity'|'field'} options.source - Source type for options
|
|
53
|
+
* @param {string} [options.entity] - Entity name (required if source='entity')
|
|
54
|
+
* @param {string} [options.field] - Field name to extract values from (required if source='field')
|
|
55
|
+
* @param {object} [options.parentManager] - Parent EntityManager (for field source, set by builder)
|
|
56
|
+
* @param {string|Function} [options.label='name'] - Label resolver (path or function)
|
|
57
|
+
* @param {string|Function} [options.value='id'] - Value resolver (path or function)
|
|
58
|
+
* @param {Function} [options.transform] - Post-processing function for options
|
|
59
|
+
* @param {Function} [options.toQuery] - Transform filter value to query object
|
|
60
|
+
*/
|
|
61
|
+
constructor(options = {}) {
|
|
62
|
+
const {
|
|
63
|
+
source,
|
|
64
|
+
entity = null,
|
|
65
|
+
field = null,
|
|
66
|
+
parentManager = null,
|
|
67
|
+
label = 'name',
|
|
68
|
+
value = 'id',
|
|
69
|
+
transform = null,
|
|
70
|
+
toQuery = null
|
|
71
|
+
} = options
|
|
72
|
+
|
|
73
|
+
// Validate source type
|
|
74
|
+
if (!source || !VALID_SOURCES.includes(source)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`FilterQuery: source must be one of: ${VALID_SOURCES.join(', ')}. Got: ${source}`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate source-specific requirements
|
|
81
|
+
if (source === 'entity' && !entity) {
|
|
82
|
+
throw new Error('FilterQuery: entity is required when source is "entity"')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (source === 'field' && !field) {
|
|
86
|
+
throw new Error('FilterQuery: field is required when source is "field"')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.source = source
|
|
90
|
+
this.entity = entity
|
|
91
|
+
this.field = field
|
|
92
|
+
this.parentManager = parentManager
|
|
93
|
+
this.label = label
|
|
94
|
+
this.value = value
|
|
95
|
+
this.transform = transform
|
|
96
|
+
this.toQuery = toQuery
|
|
97
|
+
|
|
98
|
+
// Internal cache
|
|
99
|
+
this._options = null
|
|
100
|
+
this._signals = null
|
|
101
|
+
this._subscriptions = [] // Signal unsubscribe functions for cleanup
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Set the parent manager (called by useListPageBuilder for field source)
|
|
106
|
+
*
|
|
107
|
+
* @param {EntityManager} manager
|
|
108
|
+
* @returns {FilterQuery} this for chaining
|
|
109
|
+
*/
|
|
110
|
+
setParentManager(manager) {
|
|
111
|
+
this.parentManager = manager
|
|
112
|
+
return this
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Set the SignalBus for cache invalidation
|
|
117
|
+
*
|
|
118
|
+
* For entity sources, subscribes to CRUD signals ({entity}:created, {entity}:updated,
|
|
119
|
+
* {entity}:deleted) to automatically invalidate cached options when the source
|
|
120
|
+
* entity changes.
|
|
121
|
+
*
|
|
122
|
+
* @param {SignalBus} signals
|
|
123
|
+
* @returns {FilterQuery} this for chaining
|
|
124
|
+
*/
|
|
125
|
+
setSignals(signals) {
|
|
126
|
+
// Clean up existing subscriptions if any
|
|
127
|
+
this._cleanupSubscriptions()
|
|
128
|
+
|
|
129
|
+
this._signals = signals
|
|
130
|
+
|
|
131
|
+
// Subscribe to entity CRUD signals for cache invalidation
|
|
132
|
+
if (this.source === 'entity' && this.entity && signals) {
|
|
133
|
+
const actions = ['created', 'updated', 'deleted']
|
|
134
|
+
for (const action of actions) {
|
|
135
|
+
const signalName = `${this.entity}:${action}`
|
|
136
|
+
const unbind = signals.on(signalName, () => this.invalidate())
|
|
137
|
+
this._subscriptions.push(unbind)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return this
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Cleanup signal subscriptions
|
|
146
|
+
* @private
|
|
147
|
+
*/
|
|
148
|
+
_cleanupSubscriptions() {
|
|
149
|
+
for (const unbind of this._subscriptions) {
|
|
150
|
+
if (typeof unbind === 'function') {
|
|
151
|
+
unbind()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this._subscriptions = []
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Dispose the FilterQuery, cleaning up signal subscriptions
|
|
159
|
+
*
|
|
160
|
+
* Call this when the FilterQuery is no longer needed to prevent memory leaks.
|
|
161
|
+
*/
|
|
162
|
+
dispose() {
|
|
163
|
+
this._cleanupSubscriptions()
|
|
164
|
+
this._signals = null
|
|
165
|
+
this._options = null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Invalidate the cached options (forces refresh on next getOptions)
|
|
170
|
+
*/
|
|
171
|
+
invalidate() {
|
|
172
|
+
this._options = null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get options for dropdown
|
|
177
|
+
*
|
|
178
|
+
* @param {Orchestrator} [orchestrator] - Required for entity source
|
|
179
|
+
* @returns {Promise<Array<{label: string, value: *}>>}
|
|
180
|
+
*/
|
|
181
|
+
async getOptions(orchestrator = null) {
|
|
182
|
+
// Return cached options if available
|
|
183
|
+
if (this._options !== null) {
|
|
184
|
+
return this._options
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let items = []
|
|
188
|
+
|
|
189
|
+
if (this.source === 'entity') {
|
|
190
|
+
items = await this._fetchFromEntity(orchestrator)
|
|
191
|
+
} else if (this.source === 'field') {
|
|
192
|
+
items = this._extractFromField()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Map items to { label, value } format
|
|
196
|
+
let options = items.map(item => ({
|
|
197
|
+
label: resolveValue(item, this.label),
|
|
198
|
+
value: resolveValue(item, this.value)
|
|
199
|
+
}))
|
|
200
|
+
|
|
201
|
+
// Apply transform if provided
|
|
202
|
+
if (typeof this.transform === 'function') {
|
|
203
|
+
options = this.transform(options)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Cache the result
|
|
207
|
+
this._options = options
|
|
208
|
+
|
|
209
|
+
return options
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Fetch items from entity manager
|
|
214
|
+
*
|
|
215
|
+
* @param {Orchestrator} orchestrator
|
|
216
|
+
* @returns {Promise<Array>}
|
|
217
|
+
* @private
|
|
218
|
+
*/
|
|
219
|
+
async _fetchFromEntity(orchestrator) {
|
|
220
|
+
if (!orchestrator) {
|
|
221
|
+
throw new Error('FilterQuery: orchestrator is required for entity source')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const manager = orchestrator.get(this.entity)
|
|
225
|
+
if (!manager) {
|
|
226
|
+
throw new Error(`FilterQuery: entity "${this.entity}" not found in orchestrator`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fetch all items (large page_size to get everything)
|
|
230
|
+
const result = await manager.list({ page_size: 1000 })
|
|
231
|
+
return result.items || []
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Extract unique values from parent manager's cached items
|
|
236
|
+
*
|
|
237
|
+
* @returns {Array}
|
|
238
|
+
* @private
|
|
239
|
+
*/
|
|
240
|
+
_extractFromField() {
|
|
241
|
+
if (!this.parentManager) {
|
|
242
|
+
throw new Error('FilterQuery: parentManager is required for field source')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Get cached items from parent manager
|
|
246
|
+
const cachedItems = this.parentManager._cache || []
|
|
247
|
+
|
|
248
|
+
if (cachedItems.length === 0) {
|
|
249
|
+
return []
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Extract unique values from the field
|
|
253
|
+
const seen = new Set()
|
|
254
|
+
const items = []
|
|
255
|
+
|
|
256
|
+
for (const item of cachedItems) {
|
|
257
|
+
const fieldValue = resolveValue(item, this.field)
|
|
258
|
+
if (fieldValue == null) continue
|
|
259
|
+
|
|
260
|
+
// Create a unique key for deduplication
|
|
261
|
+
const key = typeof fieldValue === 'object' ? JSON.stringify(fieldValue) : fieldValue
|
|
262
|
+
|
|
263
|
+
if (!seen.has(key)) {
|
|
264
|
+
seen.add(key)
|
|
265
|
+
// For field source, the item IS the value (create a simple object)
|
|
266
|
+
items.push({
|
|
267
|
+
[this.field]: fieldValue,
|
|
268
|
+
// Also set 'name' and 'id' defaults for simple resolution
|
|
269
|
+
name: fieldValue,
|
|
270
|
+
id: fieldValue
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return items
|
|
276
|
+
}
|
|
277
|
+
}
|