@uniweb/core 0.6.2 → 0.7.1
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/entity-store.js +49 -1
- package/src/index.js +2 -0
- package/src/request-styles/flat-query.js +146 -0
- package/src/request-styles/index.js +61 -0
- package/src/request-styles/json-body.js +117 -0
- package/src/request-styles/strapi.js +232 -0
- package/src/where.js +223 -0
package/package.json
CHANGED
package/src/entity-store.js
CHANGED
|
@@ -137,13 +137,61 @@ export default class EntityStore {
|
|
|
137
137
|
if (!cfg.schema) continue
|
|
138
138
|
if (configs.has(cfg.schema)) continue
|
|
139
139
|
if (collectAll || requested.includes(cfg.schema)) {
|
|
140
|
-
|
|
140
|
+
const localized = this._localizeConfig(cfg, website)
|
|
141
|
+
const withDetail = this._applyDeferredDetail(localized, website)
|
|
142
|
+
configs.set(cfg.schema, withDetail)
|
|
141
143
|
}
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
return configs
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Auto-inject `detail:` on collection refs whose collection has
|
|
151
|
+
* `deferred:` declared. The detail pattern points at the per-record
|
|
152
|
+
* source so the existing dynamic-route singular flow fetches a record
|
|
153
|
+
* with all fields (including the deferred ones) instead of the
|
|
154
|
+
* matched-item-from-the-cascade-collection (without).
|
|
155
|
+
*
|
|
156
|
+
* Two patterns:
|
|
157
|
+
*
|
|
158
|
+
* - Markdown-backed collections (the build emits per-record files
|
|
159
|
+
* at `/data/<name>/<slug>.json`): the auto-injected pattern is
|
|
160
|
+
* that path. `isLocalPath` resolution downstream gives the
|
|
161
|
+
* fetch a `path:` shape.
|
|
162
|
+
*
|
|
163
|
+
* - API-backed collections (the source is a remote URL; the build
|
|
164
|
+
* emits no per-record files): the author declares a `detailUrl:`
|
|
165
|
+
* on the collection — e.g., `/api/articles/{slug}` — and the
|
|
166
|
+
* auto-injected pattern uses it. `isLocalPath` resolution
|
|
167
|
+
* downstream gives the fetch a `url:` shape (because the
|
|
168
|
+
* collection itself has `url:`, not `path:`).
|
|
169
|
+
*
|
|
170
|
+
* Conventions:
|
|
171
|
+
* - Per-record sources are keyed by `item.slug`. The injected
|
|
172
|
+
* pattern uses the `{slug}` placeholder; substitution works when
|
|
173
|
+
* the dynamic route's paramName is 'slug' (the documented
|
|
174
|
+
* convention). Routes with other param names need an explicit
|
|
175
|
+
* author-written `detail:` value.
|
|
176
|
+
* - Author-supplied `cfg.detail` always wins. This helper only fills
|
|
177
|
+
* in the default for collections that have declared deferred fields.
|
|
178
|
+
* - Per-record files are not currently localized; sites needing
|
|
179
|
+
* localized deferred collections write their own `detail:` URL.
|
|
180
|
+
*/
|
|
181
|
+
_applyDeferredDetail(cfg, website) {
|
|
182
|
+
if (cfg.detail !== undefined) return cfg
|
|
183
|
+
const schema = cfg.schema
|
|
184
|
+
if (!schema) return cfg
|
|
185
|
+
const collConfig = website?.config?.collections?.[schema]
|
|
186
|
+
if (!collConfig || typeof collConfig !== 'object') return cfg
|
|
187
|
+
const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
|
|
188
|
+
if (!deferred || deferred.length === 0) return cfg
|
|
189
|
+
const pattern = typeof collConfig.detailUrl === 'string'
|
|
190
|
+
? collConfig.detailUrl
|
|
191
|
+
: `/data/${schema}/{slug}.json`
|
|
192
|
+
return { ...cfg, detail: pattern }
|
|
193
|
+
}
|
|
194
|
+
|
|
147
195
|
/**
|
|
148
196
|
* Build a detail-URL fetch config from a collection config + dynamic context.
|
|
149
197
|
*
|
package/src/index.js
CHANGED
|
@@ -21,6 +21,8 @@ export { default as ObservableState } from './observable-state.js'
|
|
|
21
21
|
// Utilities
|
|
22
22
|
export { default as singularize } from './singularize.js'
|
|
23
23
|
export { substitutePlaceholders } from './substitute-placeholders.js'
|
|
24
|
+
export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
|
|
25
|
+
export { resolveStyle as resolveRequestStyle, listStyleNames as listRequestStyleNames } from './request-styles/index.js'
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* The singleton Uniweb instance.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* flat-query — plain URL query-string style.
|
|
3
|
+
*
|
|
4
|
+
* GET-only. Operators become bare query params:
|
|
5
|
+
*
|
|
6
|
+
* ?limit=10
|
|
7
|
+
* ?sort=-date (single-key, "-" prefix for desc)
|
|
8
|
+
* ?sort=-date,title (multi-key, comma-separated)
|
|
9
|
+
* ?dept=biology&tenured=true (where, flat AND of equalities only)
|
|
10
|
+
*
|
|
11
|
+
* The `where:` operator is pushed **only** when the predicate is a flat
|
|
12
|
+
* AND of equalities on top-level fields. Nested operator objects
|
|
13
|
+
* (`{ age: { gte: 18 } }`), composition (`and` / `or` / `not`), and
|
|
14
|
+
* dotted field paths disqualify pushdown — the wire format can't
|
|
15
|
+
* express them. In those cases flat-query silently skips `where`, and
|
|
16
|
+
* the default fetcher applies it as a runtime fallback after the
|
|
17
|
+
* response arrives. This is the documented trade-off for the style's
|
|
18
|
+
* simplicity: flat-query fits simple REST APIs; richer queries either
|
|
19
|
+
* evaluate client-side or pick a different style.
|
|
20
|
+
*
|
|
21
|
+
* POST requests encode nothing — flat-query is a URL-params shape and
|
|
22
|
+
* has no meaningful POST representation.
|
|
23
|
+
*
|
|
24
|
+
* Use case: public REST APIs like `?dept=biology&limit=10&sort=-date`.
|
|
25
|
+
* For GraphQL or body-shaped backends, use json-body. For Strapi v4,
|
|
26
|
+
* use the strapi style.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const flatQuery = {
|
|
30
|
+
name: 'flat-query',
|
|
31
|
+
canPush: new Set(['where', 'limit', 'sort']),
|
|
32
|
+
defaultEnvelope: null,
|
|
33
|
+
|
|
34
|
+
encode(request, { method, pushCandidates, rename }) {
|
|
35
|
+
const pushed = new Set()
|
|
36
|
+
const queryParams = []
|
|
37
|
+
|
|
38
|
+
if (method !== 'GET') {
|
|
39
|
+
// POST has no flat-query representation; silent no-op.
|
|
40
|
+
return { queryParams, bodyMerge: null, pushed }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// where — only flat AND of equalities.
|
|
44
|
+
if (pushCandidates.has('where') && request.where !== undefined) {
|
|
45
|
+
const pairs = encodeFlatEqualities(request.where)
|
|
46
|
+
if (pairs !== null) {
|
|
47
|
+
for (const [k, v] of pairs) queryParams.push([applyRename(k, rename), v])
|
|
48
|
+
pushed.add('where')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (pushCandidates.has('limit') && request.limit !== undefined) {
|
|
53
|
+
queryParams.push([wireName('limit', 'limit', rename), String(request.limit)])
|
|
54
|
+
pushed.add('limit')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (pushCandidates.has('sort') && request.sort !== undefined) {
|
|
58
|
+
const encoded = encodeSort(request.sort)
|
|
59
|
+
if (encoded !== null) {
|
|
60
|
+
queryParams.push([wireName('sort', 'sort', rename), encoded])
|
|
61
|
+
pushed.add('sort')
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { queryParams, bodyMerge: null, pushed }
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Encode a where-object as flat [field, value] pairs. Returns null when
|
|
71
|
+
* the predicate doesn't fit the flat-AND-equalities shape — caller
|
|
72
|
+
* falls back to runtime evaluation.
|
|
73
|
+
*/
|
|
74
|
+
function encodeFlatEqualities(where) {
|
|
75
|
+
if (!where || typeof where !== 'object' || Array.isArray(where)) return null
|
|
76
|
+
const out = []
|
|
77
|
+
for (const [key, raw] of Object.entries(where)) {
|
|
78
|
+
// Composition and operator shorthand disqualify the whole predicate.
|
|
79
|
+
if (key === 'and' || key === 'or' || key === 'not') return null
|
|
80
|
+
if (key.includes('.')) return null // dotted path — flat-query can't express nesting
|
|
81
|
+
|
|
82
|
+
// Bare primitive → implicit equality.
|
|
83
|
+
if (isPrimitive(raw)) {
|
|
84
|
+
out.push([key, stringifyPrimitive(raw)])
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// { eq: primitive } → equality. Anything else disqualifies.
|
|
89
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
90
|
+
const opKeys = Object.keys(raw)
|
|
91
|
+
if (opKeys.length === 1 && opKeys[0] === 'eq' && isPrimitive(raw.eq)) {
|
|
92
|
+
out.push([key, stringifyPrimitive(raw.eq)])
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Encode `sort: 'date desc'` → `-date`; `sort: 'date desc, title asc'` →
|
|
104
|
+
* `-date,title`. Returns null on malformed input (caller falls back).
|
|
105
|
+
*/
|
|
106
|
+
function encodeSort(sortExpr) {
|
|
107
|
+
if (typeof sortExpr !== 'string' || sortExpr.length === 0) return null
|
|
108
|
+
const parts = sortExpr.split(',').map((s) => s.trim()).filter(Boolean)
|
|
109
|
+
if (parts.length === 0) return null
|
|
110
|
+
const encoded = []
|
|
111
|
+
for (const part of parts) {
|
|
112
|
+
const [field, dirRaw] = part.split(/\s+/)
|
|
113
|
+
if (!field) return null
|
|
114
|
+
const desc = (dirRaw || 'asc').toLowerCase() === 'desc'
|
|
115
|
+
encoded.push(desc ? `-${field}` : field)
|
|
116
|
+
}
|
|
117
|
+
return encoded.join(',')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isPrimitive(value) {
|
|
121
|
+
if (value === null) return true
|
|
122
|
+
const t = typeof value
|
|
123
|
+
return t === 'string' || t === 'number' || t === 'boolean'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stringifyPrimitive(value) {
|
|
127
|
+
if (value === null) return ''
|
|
128
|
+
return String(value)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function wireName(operator, defaultWire, rename) {
|
|
132
|
+
if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
|
|
133
|
+
return rename[operator]
|
|
134
|
+
}
|
|
135
|
+
return defaultWire
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// `rename` on flat-query's where pushdown doesn't apply to individual
|
|
139
|
+
// field names — authors who need `dept → department` should write
|
|
140
|
+
// their where-objects with the backend's field name directly. rename
|
|
141
|
+
// remains available for `limit` and `sort` wire names.
|
|
142
|
+
function applyRename(fieldName, _rename) {
|
|
143
|
+
return fieldName
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default flatQuery
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-style registry.
|
|
3
|
+
*
|
|
4
|
+
* A "style" describes how the default fetcher reshapes a normalized
|
|
5
|
+
* request into wire format — which operators become URL params, which
|
|
6
|
+
* go into a body, what envelope the response carries. Sites pick a
|
|
7
|
+
* style on `site.yml fetcher.request.style`; when unset, the ambient
|
|
8
|
+
* default is `json-body`.
|
|
9
|
+
*
|
|
10
|
+
* Styles are shipped by the framework. There is no `registerStyle()`
|
|
11
|
+
* API for foundations or sites — custom wire shapes are expressed via
|
|
12
|
+
* foundation-level named transports. Styles are specifically for
|
|
13
|
+
* reshapings of the framework's own fetcher behavior.
|
|
14
|
+
*
|
|
15
|
+
* Internal to @uniweb/core. Consumed by @uniweb/runtime's default-fetcher.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { jsonBody } from './json-body.js'
|
|
19
|
+
import { flatQuery } from './flat-query.js'
|
|
20
|
+
import { strapi } from './strapi.js'
|
|
21
|
+
|
|
22
|
+
const STYLES = new Map([
|
|
23
|
+
[jsonBody.name, jsonBody],
|
|
24
|
+
[flatQuery.name, flatQuery],
|
|
25
|
+
[strapi.name, strapi],
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a style by name. Returns the requested style if registered,
|
|
30
|
+
* otherwise the ambient default (`json-body`). Unknown names trigger a
|
|
31
|
+
* one-time dev warning; production silently falls back.
|
|
32
|
+
*
|
|
33
|
+
* @param {string|undefined|null} name
|
|
34
|
+
* @param {{ dev?: boolean }} [options]
|
|
35
|
+
* @returns {Object} A style module.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveStyle(name, { dev = false } = {}) {
|
|
38
|
+
if (!name) return jsonBody
|
|
39
|
+
const style = STYLES.get(name)
|
|
40
|
+
if (style) return style
|
|
41
|
+
if (dev && !warnedUnknownStyles.has(name)) {
|
|
42
|
+
warnedUnknownStyles.add(name)
|
|
43
|
+
console.warn(
|
|
44
|
+
`[default-fetcher] unknown request style "${name}"; falling back to "json-body". ` +
|
|
45
|
+
`Known styles: ${[...STYLES.keys()].join(', ')}.`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return jsonBody
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const warnedUnknownStyles = new Set()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sentinel list of names registered at the registry level. Used by tests
|
|
55
|
+
* and by dev-mode diagnostics; not part of the site-facing contract.
|
|
56
|
+
*/
|
|
57
|
+
export function listStyleNames() {
|
|
58
|
+
return [...STYLES.keys()]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { jsonBody, flatQuery, strapi }
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* json-body — the framework's general-purpose request style.
|
|
3
|
+
*
|
|
4
|
+
* Ambient default when the site doesn't pick a style. Speaks the
|
|
5
|
+
* framework's own conventions:
|
|
6
|
+
*
|
|
7
|
+
* GET — operators travel as URL params prefixed with underscore:
|
|
8
|
+
* ?_where=<JSON.stringify(predicate)>
|
|
9
|
+
* ?_limit=N
|
|
10
|
+
* ?_sort=field:dir (comma-separated for multi-key)
|
|
11
|
+
* The leading underscore avoids collision with backend-
|
|
12
|
+
* specific query params the author may have included in `url:`.
|
|
13
|
+
*
|
|
14
|
+
* POST — operators merge as top-level keys into an object body,
|
|
15
|
+
* alongside any author-supplied body. Content-Type defaults
|
|
16
|
+
* to `application/json` unless the site set a different one.
|
|
17
|
+
* String POST bodies (rare; typically GraphQL-only) don't
|
|
18
|
+
* receive operator merge — the string is sent as-is.
|
|
19
|
+
*
|
|
20
|
+
* Operator name renames are applied from the `rename:` map passed in
|
|
21
|
+
* context. Shallow substitutions only — `rename: { limit: pageSize }`
|
|
22
|
+
* swaps the wire name `_limit` → `pageSize` on GET, or the body key
|
|
23
|
+
* `limit` → `pageSize` on POST. The operator identity (what `limit`
|
|
24
|
+
* means in the query) does not change.
|
|
25
|
+
*
|
|
26
|
+
* Internal module. Accessed by `default-fetcher.js` via the registry;
|
|
27
|
+
* never imported directly from outside `@uniweb/runtime`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const jsonBody = {
|
|
31
|
+
name: 'json-body',
|
|
32
|
+
|
|
33
|
+
// Which operators this style knows how to push. The effective push set
|
|
34
|
+
// is the intersection of this and the site's `supports:` list.
|
|
35
|
+
canPush: new Set(['where', 'limit', 'sort']),
|
|
36
|
+
|
|
37
|
+
// Default response envelope. null = no wrapper (a plain JSON payload).
|
|
38
|
+
defaultEnvelope: null,
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Encode a request against the json-body conventions.
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} request - The normalized fetch request.
|
|
44
|
+
* @param {Object} ctx
|
|
45
|
+
* @param {'GET'|'POST'} ctx.method - The method the fetcher chose.
|
|
46
|
+
* @param {Set<string>} ctx.pushCandidates - Operators present on the
|
|
47
|
+
* request AND listed in the site's `supports:`. Style may choose to
|
|
48
|
+
* push all, some, or none of these based on what it knows how to
|
|
49
|
+
* express on the wire.
|
|
50
|
+
* @param {Object|null} ctx.rename - Optional { operator → wireName } map.
|
|
51
|
+
* @returns {{
|
|
52
|
+
* queryParams: Array<[string, string]>,
|
|
53
|
+
* bodyMerge: Object|null,
|
|
54
|
+
* pushed: Set<string>,
|
|
55
|
+
* }}
|
|
56
|
+
* queryParams — pairs appended to the URL's query string.
|
|
57
|
+
* bodyMerge — object merged into the POST body, or null.
|
|
58
|
+
* pushed — the operators that actually rode on the wire.
|
|
59
|
+
* Feeds the fetcher's cache-key derivation and
|
|
60
|
+
* runtime-fallback skip.
|
|
61
|
+
*/
|
|
62
|
+
encode(request, { method, pushCandidates, rename }) {
|
|
63
|
+
const pushed = new Set()
|
|
64
|
+
const queryParams = []
|
|
65
|
+
let bodyMerge = null
|
|
66
|
+
|
|
67
|
+
if (method === 'GET') {
|
|
68
|
+
if (pushCandidates.has('where') && request.where !== undefined) {
|
|
69
|
+
queryParams.push([
|
|
70
|
+
wireName('where', '_where', rename),
|
|
71
|
+
JSON.stringify(request.where),
|
|
72
|
+
])
|
|
73
|
+
pushed.add('where')
|
|
74
|
+
}
|
|
75
|
+
if (pushCandidates.has('limit') && request.limit !== undefined) {
|
|
76
|
+
queryParams.push([
|
|
77
|
+
wireName('limit', '_limit', rename),
|
|
78
|
+
String(request.limit),
|
|
79
|
+
])
|
|
80
|
+
pushed.add('limit')
|
|
81
|
+
}
|
|
82
|
+
if (pushCandidates.has('sort') && request.sort !== undefined) {
|
|
83
|
+
queryParams.push([
|
|
84
|
+
wireName('sort', '_sort', rename),
|
|
85
|
+
String(request.sort),
|
|
86
|
+
])
|
|
87
|
+
pushed.add('sort')
|
|
88
|
+
}
|
|
89
|
+
} else if (method === 'POST') {
|
|
90
|
+
const merged = {}
|
|
91
|
+
if (pushCandidates.has('where') && request.where !== undefined) {
|
|
92
|
+
merged[wireName('where', 'where', rename)] = request.where
|
|
93
|
+
pushed.add('where')
|
|
94
|
+
}
|
|
95
|
+
if (pushCandidates.has('limit') && request.limit !== undefined) {
|
|
96
|
+
merged[wireName('limit', 'limit', rename)] = request.limit
|
|
97
|
+
pushed.add('limit')
|
|
98
|
+
}
|
|
99
|
+
if (pushCandidates.has('sort') && request.sort !== undefined) {
|
|
100
|
+
merged[wireName('sort', 'sort', rename)] = request.sort
|
|
101
|
+
pushed.add('sort')
|
|
102
|
+
}
|
|
103
|
+
if (Object.keys(merged).length > 0) bodyMerge = merged
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { queryParams, bodyMerge, pushed }
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function wireName(operator, defaultWire, rename) {
|
|
111
|
+
if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
|
|
112
|
+
return rename[operator]
|
|
113
|
+
}
|
|
114
|
+
return defaultWire
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default jsonBody
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* strapi — Strapi v4 REST API query style.
|
|
3
|
+
*
|
|
4
|
+
* GET-only. Encodes a full where-object into Strapi's bracket-notation
|
|
5
|
+
* filters:
|
|
6
|
+
*
|
|
7
|
+
* where: { dept: 'biology' }
|
|
8
|
+
* → filters[dept][$eq]=biology
|
|
9
|
+
*
|
|
10
|
+
* where: { age: { gte: 18 } }
|
|
11
|
+
* → filters[age][$gte]=18
|
|
12
|
+
*
|
|
13
|
+
* where: { 'tenure.start': { gte: 2015 } }
|
|
14
|
+
* → filters[tenure][start][$gte]=2015
|
|
15
|
+
*
|
|
16
|
+
* where: { or: [{ a: 1 }, { b: 2 }] }
|
|
17
|
+
* → filters[$or][0][a][$eq]=1 & filters[$or][1][b][$eq]=2
|
|
18
|
+
*
|
|
19
|
+
* where: { not: { dept: 'emeritus' } }
|
|
20
|
+
* → filters[$not][dept][$eq]=emeritus
|
|
21
|
+
*
|
|
22
|
+
* limit: 10
|
|
23
|
+
* → pagination[limit]=10
|
|
24
|
+
*
|
|
25
|
+
* sort: 'date desc'
|
|
26
|
+
* → sort=date:desc
|
|
27
|
+
*
|
|
28
|
+
* sort: 'date desc, title asc'
|
|
29
|
+
* → sort[0]=date:desc & sort[1]=title:asc
|
|
30
|
+
*
|
|
31
|
+
* Response envelope defaults to `{ collection: 'data', item: 'data' }` —
|
|
32
|
+
* Strapi v4 wraps every response in `{ data, meta }`. Sites can override
|
|
33
|
+
* via `envelope:` at the site or per-fetch level.
|
|
34
|
+
*
|
|
35
|
+
* Operators mapped (where-object → Strapi `$op`):
|
|
36
|
+
* eq, ne, gt, gte, lt, lte, in, nin → notIn, like → containsi,
|
|
37
|
+
* exists → notNull, and → $and, or → $or, not → $not.
|
|
38
|
+
*
|
|
39
|
+
* For operators the where-object supports but Strapi doesn't have a
|
|
40
|
+
* clean equivalent for, the style leaves them untouched (the default
|
|
41
|
+
* fetcher applies them as a runtime fallback).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export const strapi = {
|
|
45
|
+
name: 'strapi',
|
|
46
|
+
canPush: new Set(['where', 'limit', 'sort']),
|
|
47
|
+
defaultEnvelope: { collection: 'data', item: 'data' },
|
|
48
|
+
|
|
49
|
+
encode(request, { method, pushCandidates, rename }) {
|
|
50
|
+
const pushed = new Set()
|
|
51
|
+
const queryParams = []
|
|
52
|
+
|
|
53
|
+
if (method !== 'GET') {
|
|
54
|
+
// Strapi v4 REST is GET-only for reads.
|
|
55
|
+
return { queryParams, bodyMerge: null, pushed }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (pushCandidates.has('where') && request.where !== undefined) {
|
|
59
|
+
const filterKey = wireName('where', 'filters', rename)
|
|
60
|
+
const pairs = encodeStrapiFilters(request.where, filterKey)
|
|
61
|
+
if (pairs !== null) {
|
|
62
|
+
for (const pair of pairs) queryParams.push(pair)
|
|
63
|
+
pushed.add('where')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (pushCandidates.has('limit') && request.limit !== undefined) {
|
|
68
|
+
const limitKey = wireName('limit', 'pagination[limit]', rename)
|
|
69
|
+
queryParams.push([limitKey, String(request.limit)])
|
|
70
|
+
pushed.add('limit')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (pushCandidates.has('sort') && request.sort !== undefined) {
|
|
74
|
+
const sortPairs = encodeStrapiSort(request.sort, wireName('sort', 'sort', rename))
|
|
75
|
+
if (sortPairs !== null) {
|
|
76
|
+
for (const pair of sortPairs) queryParams.push(pair)
|
|
77
|
+
pushed.add('sort')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { queryParams, bodyMerge: null, pushed }
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── where encoding ────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
// Values are the Strapi operator *with* bracket delimiters, so they
|
|
88
|
+
// concatenate cleanly into a bracketed path like `filters[field][$eq]`.
|
|
89
|
+
const OPERATOR_MAP = {
|
|
90
|
+
eq: '[$eq]',
|
|
91
|
+
ne: '[$ne]',
|
|
92
|
+
gt: '[$gt]',
|
|
93
|
+
gte: '[$gte]',
|
|
94
|
+
lt: '[$lt]',
|
|
95
|
+
lte: '[$lte]',
|
|
96
|
+
in: '[$in]',
|
|
97
|
+
nin: '[$notIn]',
|
|
98
|
+
like: '[$containsi]', // case-insensitive substring — closest Strapi analog
|
|
99
|
+
exists: '[$notNull]', // overridden below for exists:false → [$null]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Walk a where-object and emit [paramName, paramValue] pairs encoding
|
|
104
|
+
* Strapi's bracket syntax. Returns null if any branch is unencodable
|
|
105
|
+
* (caller falls back to runtime evaluation for the whole predicate).
|
|
106
|
+
*/
|
|
107
|
+
function encodeStrapiFilters(where, rootKey) {
|
|
108
|
+
const out = []
|
|
109
|
+
try {
|
|
110
|
+
walkPredicate(where, [rootKey], out)
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err && err.unencodable) return null
|
|
113
|
+
throw err
|
|
114
|
+
}
|
|
115
|
+
return out.length > 0 ? out : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function walkPredicate(node, path, out) {
|
|
119
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
|
120
|
+
throwUnencodable()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Top-level each key is either composition ($and/$or/$not) or a field.
|
|
124
|
+
for (const [key, value] of Object.entries(node)) {
|
|
125
|
+
if (key === 'and' || key === 'or') {
|
|
126
|
+
if (!Array.isArray(value)) throwUnencodable()
|
|
127
|
+
value.forEach((sub, i) => {
|
|
128
|
+
walkPredicate(sub, [...path, `[$${key}]`, `[${i}]`], out)
|
|
129
|
+
})
|
|
130
|
+
} else if (key === 'not') {
|
|
131
|
+
walkPredicate(value, [...path, '[$not]'], out)
|
|
132
|
+
} else {
|
|
133
|
+
emitFieldPredicate(key, value, path, out)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function emitFieldPredicate(field, value, path, out) {
|
|
139
|
+
// Dotted paths become nested bracket segments: `tenure.start` →
|
|
140
|
+
// `[tenure][start]`.
|
|
141
|
+
const fieldSegments = field.split('.').map((seg) => `[${seg}]`)
|
|
142
|
+
const fieldPath = [...path, ...fieldSegments]
|
|
143
|
+
|
|
144
|
+
// Bare primitive → implicit eq.
|
|
145
|
+
if (isPrimitive(value)) {
|
|
146
|
+
out.push([joinPath(fieldPath) + '[$eq]', stringifyPrimitive(value)])
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
151
|
+
for (const [opName, opValue] of Object.entries(value)) {
|
|
152
|
+
const strapiOp = OPERATOR_MAP[opName]
|
|
153
|
+
if (!strapiOp) throwUnencodable()
|
|
154
|
+
|
|
155
|
+
if (opName === 'in' || opName === 'nin') {
|
|
156
|
+
if (!Array.isArray(opValue)) throwUnencodable()
|
|
157
|
+
opValue.forEach((item, i) => {
|
|
158
|
+
if (!isPrimitive(item)) throwUnencodable()
|
|
159
|
+
out.push([joinPath(fieldPath) + strapiOp + `[${i}]`, stringifyPrimitive(item)])
|
|
160
|
+
})
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (opName === 'exists') {
|
|
165
|
+
// { exists: true } → field IS NOT NULL → [field][$notNull]=true
|
|
166
|
+
// { exists: false } → field IS NULL → [field][$null]=true
|
|
167
|
+
const wantsPresent = !!opValue
|
|
168
|
+
out.push([joinPath(fieldPath) + (wantsPresent ? '[$notNull]' : '[$null]'), 'true'])
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!isPrimitive(opValue)) throwUnencodable()
|
|
173
|
+
out.push([joinPath(fieldPath) + strapiOp, stringifyPrimitive(opValue)])
|
|
174
|
+
}
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
throwUnencodable()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function joinPath(segments) {
|
|
182
|
+
return segments.join('')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function throwUnencodable() {
|
|
186
|
+
const err = new Error('unencodable predicate')
|
|
187
|
+
err.unencodable = true
|
|
188
|
+
throw err
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── sort encoding ─────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function encodeStrapiSort(sortExpr, baseKey) {
|
|
194
|
+
if (typeof sortExpr !== 'string' || sortExpr.length === 0) return null
|
|
195
|
+
const parts = sortExpr.split(',').map((s) => s.trim()).filter(Boolean)
|
|
196
|
+
if (parts.length === 0) return null
|
|
197
|
+
|
|
198
|
+
const encoded = []
|
|
199
|
+
for (const part of parts) {
|
|
200
|
+
const [field, dirRaw] = part.split(/\s+/)
|
|
201
|
+
if (!field) return null
|
|
202
|
+
const dir = (dirRaw || 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc'
|
|
203
|
+
encoded.push(`${field}:${dir}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (encoded.length === 1) {
|
|
207
|
+
return [[baseKey, encoded[0]]]
|
|
208
|
+
}
|
|
209
|
+
return encoded.map((v, i) => [`${baseKey}[${i}]`, v])
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
function isPrimitive(value) {
|
|
215
|
+
if (value === null) return true
|
|
216
|
+
const t = typeof value
|
|
217
|
+
return t === 'string' || t === 'number' || t === 'boolean'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stringifyPrimitive(value) {
|
|
221
|
+
if (value === null) return ''
|
|
222
|
+
return String(value)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function wireName(operator, defaultWire, rename) {
|
|
226
|
+
if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
|
|
227
|
+
return rename[operator]
|
|
228
|
+
}
|
|
229
|
+
return defaultWire
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export default strapi
|
package/src/where.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where-object evaluator.
|
|
3
|
+
*
|
|
4
|
+
* A where-object is a structured JSON predicate. The format is small,
|
|
5
|
+
* additive, and YAML/JSON-native — there is no DSL or parser. The same
|
|
6
|
+
* predicate travels from author YAML, through transports, to backends
|
|
7
|
+
* (which translate to their native query language) or to this evaluator
|
|
8
|
+
* (which walks the object against a record).
|
|
9
|
+
*
|
|
10
|
+
* Architecture: see kb/framework/architecture/data-fetching.md.
|
|
11
|
+
*
|
|
12
|
+
* Shape:
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* // Top-level keys are field names; values are the values to match.
|
|
16
|
+
* // Implicit AND across keys.
|
|
17
|
+
* department: 'biology',
|
|
18
|
+
* tenured: true,
|
|
19
|
+
*
|
|
20
|
+
* // For non-equality, the value is an operator object.
|
|
21
|
+
* start_year: { gte: 2010 },
|
|
22
|
+
* rank: { in: ['associate', 'full'] },
|
|
23
|
+
* title: { like: 'Origin*' },
|
|
24
|
+
*
|
|
25
|
+
* // Explicit composition keys at any nesting level.
|
|
26
|
+
* and: [{ tenured: true }, { rank: 'full' }],
|
|
27
|
+
* or: [{ rank: 'full' }, { years_in_role: { gte: 10 } }],
|
|
28
|
+
* not: { department: 'emeritus' },
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* Operators (in operator-object form):
|
|
32
|
+
*
|
|
33
|
+
* eq Equal (also implicit when the value is bare, non-object, non-null).
|
|
34
|
+
* ne Not equal.
|
|
35
|
+
* gt/gte Greater than / greater than or equal.
|
|
36
|
+
* lt/lte Less than / less than or equal.
|
|
37
|
+
* in Value is in the listed array.
|
|
38
|
+
* nin Value is not in the listed array.
|
|
39
|
+
* like Glob match (`*` any run, `?` one char). String fields only.
|
|
40
|
+
* exists Field is truthy (boolean toggle).
|
|
41
|
+
*
|
|
42
|
+
* Composition keys:
|
|
43
|
+
*
|
|
44
|
+
* and Array of sub-predicates; all must match.
|
|
45
|
+
* or Array of sub-predicates; at least one must match.
|
|
46
|
+
* not Single sub-predicate; must not match.
|
|
47
|
+
*
|
|
48
|
+
* Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }`.
|
|
49
|
+
*
|
|
50
|
+
* Type safety: type mismatches return `false` rather than throwing
|
|
51
|
+
* (e.g., comparing a string to a number with `gt`). Missing fields
|
|
52
|
+
* return `false` for equality and most operators; `exists: false` matches
|
|
53
|
+
* missing/falsy fields.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
const COMPOSITION_KEYS = new Set(['and', 'or', 'not'])
|
|
57
|
+
const OPERATORS = new Set([
|
|
58
|
+
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'exists',
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Evaluate a where-object against a single record.
|
|
63
|
+
*
|
|
64
|
+
* @param {Object} where - The where-object predicate.
|
|
65
|
+
* @param {Object} record - The record to test.
|
|
66
|
+
* @returns {boolean} true if the record matches.
|
|
67
|
+
*/
|
|
68
|
+
export function evaluate(where, record) {
|
|
69
|
+
if (where == null) return true
|
|
70
|
+
if (typeof where !== 'object' || Array.isArray(where)) return false
|
|
71
|
+
if (record == null || typeof record !== 'object') return false
|
|
72
|
+
|
|
73
|
+
// Implicit AND across all top-level keys.
|
|
74
|
+
for (const key of Object.keys(where)) {
|
|
75
|
+
if (!evaluateClause(key, where[key], record)) return false
|
|
76
|
+
}
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Filter an array of records by a where-object predicate.
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} where - The where-object predicate.
|
|
84
|
+
* @param {Array<Object>} records - The records to filter.
|
|
85
|
+
* @returns {Array<Object>} Records in source order for which the predicate is true.
|
|
86
|
+
*/
|
|
87
|
+
export function match(where, records) {
|
|
88
|
+
if (!Array.isArray(records)) return []
|
|
89
|
+
if (where == null) return records.slice()
|
|
90
|
+
return records.filter((r) => evaluate(where, r))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Internals ────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function evaluateClause(key, value, record) {
|
|
96
|
+
// Composition keys.
|
|
97
|
+
if (key === 'and') {
|
|
98
|
+
if (!Array.isArray(value)) return false
|
|
99
|
+
return value.every((sub) => evaluate(sub, record))
|
|
100
|
+
}
|
|
101
|
+
if (key === 'or') {
|
|
102
|
+
if (!Array.isArray(value)) return false
|
|
103
|
+
return value.some((sub) => evaluate(sub, record))
|
|
104
|
+
}
|
|
105
|
+
if (key === 'not') {
|
|
106
|
+
return !evaluate(value, record)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Field clause: key is a (possibly dotted) field name; value is either
|
|
110
|
+
// a bare value (implicit eq) or an operator-object.
|
|
111
|
+
const fieldValue = getPath(record, key)
|
|
112
|
+
|
|
113
|
+
if (value === null) {
|
|
114
|
+
return fieldValue === null || fieldValue === undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (typeof value === 'object' && !Array.isArray(value) && isOperatorObject(value)) {
|
|
118
|
+
return evaluateOperatorObject(value, fieldValue)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Bare value (string, number, boolean, array): implicit equality.
|
|
122
|
+
return matchEqual(fieldValue, value)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isOperatorObject(value) {
|
|
126
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) return false
|
|
127
|
+
// An operator-object's keys are all in OPERATORS. If even one key isn't
|
|
128
|
+
// an operator, it's not an operator-object — it might be a nested
|
|
129
|
+
// sub-predicate or a structured equality target. The latter is rare;
|
|
130
|
+
// we treat any object whose keys are all known operators as an
|
|
131
|
+
// operator-object, otherwise fall back to deep-equality matching.
|
|
132
|
+
const keys = Object.keys(value)
|
|
133
|
+
if (keys.length === 0) return false
|
|
134
|
+
return keys.every((k) => OPERATORS.has(k))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function evaluateOperatorObject(opObject, fieldValue) {
|
|
138
|
+
for (const op of Object.keys(opObject)) {
|
|
139
|
+
if (!evaluateOperator(op, opObject[op], fieldValue)) return false
|
|
140
|
+
}
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function evaluateOperator(op, opValue, fieldValue) {
|
|
145
|
+
switch (op) {
|
|
146
|
+
case 'eq':
|
|
147
|
+
return matchEqual(fieldValue, opValue)
|
|
148
|
+
case 'ne':
|
|
149
|
+
return !matchEqual(fieldValue, opValue)
|
|
150
|
+
case 'gt':
|
|
151
|
+
return compareCanRun(fieldValue, opValue) && fieldValue > opValue
|
|
152
|
+
case 'gte':
|
|
153
|
+
return compareCanRun(fieldValue, opValue) && fieldValue >= opValue
|
|
154
|
+
case 'lt':
|
|
155
|
+
return compareCanRun(fieldValue, opValue) && fieldValue < opValue
|
|
156
|
+
case 'lte':
|
|
157
|
+
return compareCanRun(fieldValue, opValue) && fieldValue <= opValue
|
|
158
|
+
case 'in':
|
|
159
|
+
if (!Array.isArray(opValue)) return false
|
|
160
|
+
return opValue.some((v) => matchEqual(fieldValue, v))
|
|
161
|
+
case 'nin':
|
|
162
|
+
if (!Array.isArray(opValue)) return false
|
|
163
|
+
return !opValue.some((v) => matchEqual(fieldValue, v))
|
|
164
|
+
case 'like':
|
|
165
|
+
if (typeof fieldValue !== 'string' || typeof opValue !== 'string') return false
|
|
166
|
+
return globMatch(opValue, fieldValue)
|
|
167
|
+
case 'exists':
|
|
168
|
+
return Boolean(fieldValue) === Boolean(opValue)
|
|
169
|
+
default:
|
|
170
|
+
// Unknown operator → fail closed.
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function matchEqual(a, b) {
|
|
176
|
+
if (a === b) return true
|
|
177
|
+
if (a == null || b == null) return false
|
|
178
|
+
// Array-on-either-side: if `a` is an array (record's field), match if
|
|
179
|
+
// any element equals b. This makes `tags: 'featured'` match a record
|
|
180
|
+
// with `tags: ['featured', 'sale']`.
|
|
181
|
+
if (Array.isArray(a) && !Array.isArray(b)) {
|
|
182
|
+
return a.some((v) => v === b)
|
|
183
|
+
}
|
|
184
|
+
if (typeof a === 'object' || typeof b === 'object') {
|
|
185
|
+
// No deep equality for objects in v1 — keep the surface narrow.
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function compareCanRun(a, b) {
|
|
192
|
+
if (a == null || b == null) return false
|
|
193
|
+
// Numbers and ISO-date strings (which compare correctly with </>=) are fine.
|
|
194
|
+
// Mixed types (string vs number) are a mismatch — return false rather
|
|
195
|
+
// than coerce.
|
|
196
|
+
return typeof a === typeof b
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getPath(record, path) {
|
|
200
|
+
if (typeof path !== 'string') return undefined
|
|
201
|
+
if (path.indexOf('.') === -1) return record[path]
|
|
202
|
+
let cursor = record
|
|
203
|
+
for (const segment of path.split('.')) {
|
|
204
|
+
if (cursor == null || typeof cursor !== 'object') return undefined
|
|
205
|
+
cursor = cursor[segment]
|
|
206
|
+
}
|
|
207
|
+
return cursor
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Shell-glob match: `*` matches any run of characters, `?` matches one char.
|
|
212
|
+
* Anchored — the pattern must match the whole string.
|
|
213
|
+
*/
|
|
214
|
+
function globMatch(pattern, value) {
|
|
215
|
+
// Translate to a RegExp with anchors. Escape regex metacharacters
|
|
216
|
+
// except for our wildcards.
|
|
217
|
+
const re = '^' + pattern
|
|
218
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
219
|
+
.replace(/\*/g, '.*')
|
|
220
|
+
.replace(/\?/g, '.')
|
|
221
|
+
+ '$'
|
|
222
|
+
return new RegExp(re).test(value)
|
|
223
|
+
}
|