@quazardous/qdadm 2.4.5 → 2.6.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/README.md +2 -0
- package/package.json +1 -1
- package/src/components/editors/VanillaJsonEditor.vue +11 -5
- package/src/composables/useListPage.ts +79 -5
- package/src/composables/useListPage.types.ts +7 -0
- package/src/editors/index.ts +5 -0
- package/src/entity/EntityManager.interface.ts +30 -5
- package/src/entity/EntityManager.ts +2 -0
- package/src/gen/connectors/OpenAPIConnector.test.js +253 -1
- package/src/gen/connectors/OpenAPIConnector.ts +110 -4
- package/src/gen/generateManagers.test.js +78 -0
- package/src/gen/generateManagers.ts +33 -7
- package/src/gen/index.ts +1 -0
- package/src/index.ts +37 -0
- package/src/orchestrator/useOrchestrator.ts +20 -5
- package/src/utils/humanize.ts +26 -0
- package/src/utils/index.ts +3 -0
package/README.md
CHANGED
|
@@ -137,6 +137,8 @@ This is the canonical Vite/Webpack pattern for Vue plugin singletons. The `file:
|
|
|
137
137
|
|
|
138
138
|
Installing qdadm from npm (`@quazardous/qdadm@^1.19.3`) doesn't have this issue: the published tarball ships no `node_modules/`, so peers always resolve from the host. The trap is specific to `file:` / `workspace:` links onto a package that lives inside another monorepo.
|
|
139
139
|
|
|
140
|
+
One more caveat: qdadm ships TypeScript sources, so with a `file:` link your `vue-tsc` / lint / test counters measure qdadm's **working tree**, not a released version. Any local qdadm edit moves your numbers with zero change on your side — don't calibrate quality ratchets or CI thresholds against a linked checkout; pin them to the published package your CI actually installs.
|
|
141
|
+
|
|
140
142
|
## License
|
|
141
143
|
|
|
142
144
|
MIT
|
package/package.json
CHANGED
|
@@ -22,7 +22,13 @@ type JsonValue = Record<string, unknown> | unknown[] | string | null
|
|
|
22
22
|
|
|
23
23
|
interface Props {
|
|
24
24
|
modelValue?: JsonValue
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Editor mode. String literals are accepted (#1253) so consumers never
|
|
27
|
+
* need to import the `Mode` enum from vanilla-jsoneditor (which is not
|
|
28
|
+
* a direct dependency of theirs); `Mode` itself is re-exported from
|
|
29
|
+
* '@quazardous/qdadm/editors' for those who want it.
|
|
30
|
+
*/
|
|
31
|
+
mode?: Mode | 'tree' | 'text' | 'table'
|
|
26
32
|
height?: string
|
|
27
33
|
readOnly?: boolean
|
|
28
34
|
mainMenuBar?: boolean
|
|
@@ -40,7 +46,7 @@ interface Props {
|
|
|
40
46
|
|
|
41
47
|
const props = withDefaults(defineProps<Props>(), {
|
|
42
48
|
modelValue: () => ({}),
|
|
43
|
-
mode: 'tree'
|
|
49
|
+
mode: 'tree',
|
|
44
50
|
height: '400px',
|
|
45
51
|
readOnly: false,
|
|
46
52
|
mainMenuBar: true,
|
|
@@ -100,7 +106,7 @@ onMounted(() => {
|
|
|
100
106
|
target: containerRef.value,
|
|
101
107
|
props: {
|
|
102
108
|
content,
|
|
103
|
-
mode: props.mode,
|
|
109
|
+
mode: props.mode as Mode,
|
|
104
110
|
readOnly: props.readOnly,
|
|
105
111
|
mainMenuBar: props.mainMenuBar,
|
|
106
112
|
navigationBar: props.navigationBar,
|
|
@@ -161,9 +167,9 @@ watch(() => props.modelValue, (newVal: JsonValue | undefined) => {
|
|
|
161
167
|
}, { deep: true })
|
|
162
168
|
|
|
163
169
|
// Watch mode changes
|
|
164
|
-
watch(() => props.mode, (newMode
|
|
170
|
+
watch(() => props.mode, (newMode) => {
|
|
165
171
|
if (editor && newMode) {
|
|
166
|
-
editor.updateProps({ mode: newMode })
|
|
172
|
+
editor.updateProps({ mode: newMode as Mode })
|
|
167
173
|
}
|
|
168
174
|
})
|
|
169
175
|
|
|
@@ -35,6 +35,7 @@ import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } fr
|
|
|
35
35
|
import { useActiveStack } from '../chain/useActiveStack.js'
|
|
36
36
|
import { useI18n } from '../i18n/useI18n'
|
|
37
37
|
import { formatDateOnly } from '../utils/formatters'
|
|
38
|
+
import { humanizeFieldName } from '../utils/humanize'
|
|
38
39
|
|
|
39
40
|
// Import types from dedicated types file
|
|
40
41
|
import type {
|
|
@@ -260,26 +261,47 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
260
261
|
// when the i18n bundle has no translation for the field.
|
|
261
262
|
const columnInlineHeaders = new Map<string, string | undefined>()
|
|
262
263
|
|
|
264
|
+
// One-shot per entity+field (#1255): the catalog winning over an explicit
|
|
265
|
+
// header is by design, but it must never happen silently — the day a key
|
|
266
|
+
// appears, the page author learns which inline header it shadows.
|
|
267
|
+
const warnedHeaderShadows = new Set<string>()
|
|
268
|
+
function warnHeaderShadow(field: string, i18nValue: string, explicit: string | undefined): void {
|
|
269
|
+
if (explicit === undefined || explicit === i18nValue) return
|
|
270
|
+
const key = `${entity}.${field}`
|
|
271
|
+
if (warnedHeaderShadows.has(key)) return
|
|
272
|
+
warnedHeaderShadows.add(key)
|
|
273
|
+
console.warn(
|
|
274
|
+
`[qdadm] column '${field}' (${entity}): i18n key entities.${entity}.fields.${field} ` +
|
|
275
|
+
`("${i18nValue}") shadows explicit header ("${explicit}") — the catalog wins by design; ` +
|
|
276
|
+
`drop the inline header or remove the key.`
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
263
280
|
/**
|
|
264
|
-
* Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins
|
|
281
|
+
* Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins
|
|
282
|
+
* (the inline `header` is the no-key fallback, not an absolute override),
|
|
265
283
|
* otherwise the inline `header` option, otherwise a capitalized field name.
|
|
266
284
|
*/
|
|
267
285
|
function resolveColumnHeader(field: string, inline?: string): string {
|
|
268
286
|
if (entity && kernelI18n) {
|
|
269
287
|
const trace = kernelI18n.resolve(`entities.${entity}.fields.${field}`)
|
|
270
|
-
if (trace.hit)
|
|
288
|
+
if (trace.hit) {
|
|
289
|
+
warnHeaderShadow(field, trace.result, inline)
|
|
290
|
+
return trace.result
|
|
291
|
+
}
|
|
271
292
|
}
|
|
272
293
|
return inline || field.charAt(0).toUpperCase() + field.slice(1)
|
|
273
294
|
}
|
|
274
295
|
|
|
275
296
|
function addColumn(field: string, columnConfig: Partial<ColumnConfig> = {}): void {
|
|
276
297
|
columnInlineHeaders.set(field, columnConfig.header)
|
|
298
|
+
// Resolved header LAST so the catalog wins at registration too (#1255) —
|
|
299
|
+
// previously the inline header survived until the first locale change
|
|
300
|
+
// flipped it to the i18n value, silently and inconsistently.
|
|
277
301
|
columnsMap.value.set(field, {
|
|
278
302
|
field,
|
|
279
|
-
header: resolveColumnHeader(field, columnConfig.header),
|
|
280
303
|
...columnConfig,
|
|
281
|
-
|
|
282
|
-
...(columnConfig.header ? {} : { header: resolveColumnHeader(field) }),
|
|
304
|
+
header: resolveColumnHeader(field, columnConfig.header),
|
|
283
305
|
})
|
|
284
306
|
}
|
|
285
307
|
|
|
@@ -294,6 +316,57 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
294
316
|
}
|
|
295
317
|
})
|
|
296
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Template-side column binding helper (#1255): spread onto a PrimeVue
|
|
321
|
+
* `<Column>` so `field` + `header` come from one source instead of being
|
|
322
|
+
* retyped in every list template:
|
|
323
|
+
*
|
|
324
|
+
* ```html
|
|
325
|
+
* <Column v-bind="list.column('botUuid')" sortable>
|
|
326
|
+
* <template #body="{ data }">...</template>
|
|
327
|
+
* </Column>
|
|
328
|
+
* ```
|
|
329
|
+
*
|
|
330
|
+
* Header resolution: i18n key (`entities.{entity}.fields.{field}`) >
|
|
331
|
+
* `overrides.header` > inline header given to `addColumn` >
|
|
332
|
+
* `manager.fields[name].label` > humanized field name.
|
|
333
|
+
*
|
|
334
|
+
* NOTE the precedence truth (#1255): an inline `header` is the fallback
|
|
335
|
+
* used when no i18n key exists — NOT an absolute override. The catalog
|
|
336
|
+
* wins by design (or a hardcoded header would pin the column to one
|
|
337
|
+
* language); to pin a header, don't create the key. When a key shadows
|
|
338
|
+
* an explicit header with a different value, a one-shot dev warning
|
|
339
|
+
* announces it instead of letting the header change silently.
|
|
340
|
+
*
|
|
341
|
+
* Pure read — it never registers into columnsMap, so calling it during
|
|
342
|
+
* render is safe.
|
|
343
|
+
*/
|
|
344
|
+
function column(name: string, overrides: Partial<ColumnConfig> = {}): ColumnConfig {
|
|
345
|
+
// Depend on the locale so headers re-render on locale change.
|
|
346
|
+
void i18nLocale.value
|
|
347
|
+
|
|
348
|
+
let header: string | undefined
|
|
349
|
+
if (entity && kernelI18n) {
|
|
350
|
+
const trace = kernelI18n.resolve(`entities.${entity}.fields.${name}`)
|
|
351
|
+
if (trace.hit) {
|
|
352
|
+
header = trace.result
|
|
353
|
+
// Both explicit sources are checked: per-call override AND the
|
|
354
|
+
// inline header registered via addColumn (values compared — a key
|
|
355
|
+
// that merely copies the header stays silent).
|
|
356
|
+
warnHeaderShadow(name, trace.result, overrides.header ?? columnInlineHeaders.get(name))
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const fieldConfig = manager.getFieldConfig(name) as { label?: string } | null | undefined
|
|
360
|
+
header ??=
|
|
361
|
+
overrides.header ??
|
|
362
|
+
columnInlineHeaders.get(name) ??
|
|
363
|
+
fieldConfig?.label ??
|
|
364
|
+
humanizeFieldName(name)
|
|
365
|
+
|
|
366
|
+
const registered = columnsMap.value.get(name)
|
|
367
|
+
return { ...registered, ...overrides, field: name, header }
|
|
368
|
+
}
|
|
369
|
+
|
|
297
370
|
function removeColumn(field: string): void {
|
|
298
371
|
columnsMap.value.delete(field)
|
|
299
372
|
}
|
|
@@ -1111,6 +1184,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1111
1184
|
|
|
1112
1185
|
// Columns
|
|
1113
1186
|
columns,
|
|
1187
|
+
column,
|
|
1114
1188
|
addColumn,
|
|
1115
1189
|
removeColumn,
|
|
1116
1190
|
updateColumn,
|
|
@@ -61,6 +61,11 @@ export interface ConfirmService {
|
|
|
61
61
|
*/
|
|
62
62
|
export interface ColumnConfig {
|
|
63
63
|
field: string
|
|
64
|
+
/**
|
|
65
|
+
* Column header. When an i18n key `entities.{entity}.fields.{field}`
|
|
66
|
+
* exists, the catalog WINS — an inline header is the no-key fallback,
|
|
67
|
+
* not an absolute override (#1255). To pin a header, don't create the key.
|
|
68
|
+
*/
|
|
64
69
|
header?: string
|
|
65
70
|
sortable?: boolean
|
|
66
71
|
style?: string
|
|
@@ -347,6 +352,8 @@ export interface UseListPageReturn<T = unknown> {
|
|
|
347
352
|
|
|
348
353
|
// Columns
|
|
349
354
|
columns: ComputedRef<ColumnConfig[]>
|
|
355
|
+
/** v-bind column binding helper (#1255): field + header from one source */
|
|
356
|
+
column: (name: string, overrides?: Partial<ColumnConfig>) => ColumnConfig
|
|
350
357
|
addColumn: (field: string, config?: Partial<ColumnConfig>) => void
|
|
351
358
|
removeColumn: (field: string) => void
|
|
352
359
|
updateColumn: (field: string, updates: Partial<ColumnConfig>) => void
|
package/src/editors/index.ts
CHANGED
|
@@ -10,3 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
export { default as VanillaJsonEditor } from '../components/editors/VanillaJsonEditor.vue'
|
|
12
12
|
export { default as JsonStructuredField } from '../components/editors/JsonStructuredField.vue'
|
|
13
|
+
|
|
14
|
+
// Re-export the mode enum so consumers who want it don't need to depend on
|
|
15
|
+
// vanilla-jsoneditor directly (#1253). String literals ('tree' | 'text' |
|
|
16
|
+
// 'table') are accepted by VanillaJsonEditor's `mode` prop anyway.
|
|
17
|
+
export { Mode } from 'vanilla-jsoneditor'
|
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
*
|
|
14
14
|
* All interfaces accept an optional generic parameter `T` (default: `unknown`)
|
|
15
15
|
* that flows through to method signatures for typed entity data.
|
|
16
|
+
*
|
|
17
|
+
* ## Optionality contract (#1253)
|
|
18
|
+
*
|
|
19
|
+
* These views describe the IMPLEMENTER'S MINIMUM, not a mirror of the
|
|
20
|
+
* EntityManager class. A member is REQUIRED iff qdadm's own composables
|
|
21
|
+
* call it unguarded (or capability coherence demands it — e.g. the four
|
|
22
|
+
* can* permission checks travel together). A member stays OPTIONAL only
|
|
23
|
+
* when it is a genuine capability a minimal manager-like need not carry;
|
|
24
|
+
* the reason is documented inline. `?` means "conditional", never "lazy".
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
27
|
import type { EntityBadge } from './EntityManager.types'
|
|
@@ -32,6 +41,9 @@ export interface EntityManagerBase<T = unknown> {
|
|
|
32
41
|
labelField?: string | ((entity: T) => string)
|
|
33
42
|
readOnly?: boolean
|
|
34
43
|
getEntityLabel: (data: T) => string
|
|
44
|
+
// Optional BY DESIGN (#1253): part of the badge/severity presentation
|
|
45
|
+
// capability; call sites guard (e.g. useEntityItemShowPage checks
|
|
46
|
+
// `if (!manager.getEntityBadges)`).
|
|
35
47
|
getEntityBadges?: (data: T) => Array<{ label: string; severity?: string }>
|
|
36
48
|
}
|
|
37
49
|
|
|
@@ -41,7 +53,10 @@ export interface EntityManagerBase<T = unknown> {
|
|
|
41
53
|
* Permission checks — used by guards, list pages, forms.
|
|
42
54
|
*/
|
|
43
55
|
export interface EntityManagerPermissions<T = unknown> {
|
|
44
|
-
|
|
56
|
+
// The four checks are ONE capability — a manager-like answering canUpdate
|
|
57
|
+
// but not canRead is not a smaller contract, it's an incoherent one. The
|
|
58
|
+
// `?` on canRead was a #1191 leftover (#1253).
|
|
59
|
+
canRead: (entity?: T) => boolean
|
|
45
60
|
canCreate: () => boolean
|
|
46
61
|
canUpdate: (entity?: T) => boolean
|
|
47
62
|
canDelete: (entity?: T) => boolean
|
|
@@ -55,11 +70,18 @@ export interface EntityManagerPermissions<T = unknown> {
|
|
|
55
70
|
export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, EntityManagerPermissions<T> {
|
|
56
71
|
get: (id: string | number, context?: unknown) => Promise<T>
|
|
57
72
|
list: (params?: unknown, context?: unknown) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
|
|
58
|
-
query
|
|
73
|
+
// query/invalidateCache/getFieldConfig are required since #1253: they are
|
|
74
|
+
// unconditionally implemented on the class (prototype-applied) and called
|
|
75
|
+
// unguarded by list pages — the `?` was a #1191 leftover that forced
|
|
76
|
+
// consumer-side casts on base methods.
|
|
77
|
+
query: (params?: unknown, options?: { routingContext?: unknown }) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
|
|
59
78
|
delete: (id: string | number, context?: unknown) => Promise<void>
|
|
60
79
|
request: (method: string, path: string, options?: { data?: unknown }) => Promise<unknown>
|
|
61
|
-
invalidateCache
|
|
62
|
-
|
|
80
|
+
invalidateCache: () => void
|
|
81
|
+
/** Field config lookup — read-side too since list column binding (#1255) */
|
|
82
|
+
getFieldConfig: (name: string) => unknown | null
|
|
83
|
+
/** Config-carrying property: always present on the class, VALUE nullable */
|
|
84
|
+
localFilterThreshold?: number | null
|
|
63
85
|
}
|
|
64
86
|
|
|
65
87
|
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
|
@@ -71,11 +93,14 @@ export interface EntityManagerCrud<T = unknown> extends EntityManagerRead<T> {
|
|
|
71
93
|
fields?: Record<string, unknown> | unknown[]
|
|
72
94
|
getInitialData: () => Record<string, unknown>
|
|
73
95
|
getFormFields: () => Array<{ name: string; [key: string]: unknown }>
|
|
74
|
-
getFieldConfig
|
|
96
|
+
// getFieldConfig inherited (required) from EntityManagerRead since #1253
|
|
75
97
|
resolveReferenceOptions: (fieldName: string) => Promise<unknown[]>
|
|
76
98
|
create: (data: unknown, context?: unknown) => Promise<T>
|
|
77
99
|
update: (id: string | number, data: unknown, context?: unknown) => Promise<T>
|
|
78
100
|
patch: (id: string | number, data: unknown, context?: unknown) => Promise<T>
|
|
101
|
+
// Optional BY DESIGN (#1253): the severity/badge presentation capability —
|
|
102
|
+
// a minimal manager-like need not carry it; every qdadm call site guards
|
|
103
|
+
// (`?.` or truthiness). The canonical class always provides it.
|
|
79
104
|
hasSeverityMap?: (field: string) => boolean
|
|
80
105
|
getSeverity?: (field: string, value: string | number, defaultSeverity?: string) => string
|
|
81
106
|
getSeverityDescriptor?: (field: string, value: string | number, defaultSeverity?: string) => { severity: string; icon?: string; label?: string }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { OpenAPIConnector } from './OpenAPIConnector'
|
|
3
3
|
import sampleSpec from './__fixtures__/sample-openapi.json'
|
|
4
4
|
|
|
@@ -584,6 +584,258 @@ describe('OpenAPIConnector', () => {
|
|
|
584
584
|
|
|
585
585
|
expect(result.warnings.some(w => w.code === 'UNRESOLVED_REF')).toBe(true)
|
|
586
586
|
})
|
|
587
|
+
|
|
588
|
+
describe('EMPTY_OBJECT_SCHEMA detection (#1240)', () => {
|
|
589
|
+
// Build a spec with a single tasks entity whose `payload` field uses
|
|
590
|
+
// the given schema node.
|
|
591
|
+
function specWithPayload(payloadSchema) {
|
|
592
|
+
return {
|
|
593
|
+
openapi: '3.0.0',
|
|
594
|
+
paths: {
|
|
595
|
+
'/api/tasks/{id}': {
|
|
596
|
+
get: {
|
|
597
|
+
responses: {
|
|
598
|
+
'200': {
|
|
599
|
+
content: {
|
|
600
|
+
'application/json': {
|
|
601
|
+
schema: {
|
|
602
|
+
type: 'object',
|
|
603
|
+
properties: {
|
|
604
|
+
data: {
|
|
605
|
+
type: 'object',
|
|
606
|
+
properties: {
|
|
607
|
+
id: { type: 'string' },
|
|
608
|
+
payload: payloadSchema
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function warningsFor(payloadSchema) {
|
|
624
|
+
const connector = new OpenAPIConnector()
|
|
625
|
+
const result = connector.parseWithWarnings(specWithPayload(payloadSchema))
|
|
626
|
+
return result.warnings.filter(w => w.code === 'EMPTY_OBJECT_SCHEMA')
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
it('warns on a bare object schema (no properties, no additionalProperties)', () => {
|
|
630
|
+
const warnings = warningsFor({ type: 'object' })
|
|
631
|
+
expect(warnings).toHaveLength(1)
|
|
632
|
+
expect(warnings[0].path).toBe('/api/tasks/{id}#payload')
|
|
633
|
+
expect(warnings[0].message).toContain('additionalProperties: true')
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it('warns when properties is present but empty', () => {
|
|
637
|
+
expect(warningsFor({ type: 'object', properties: {} })).toHaveLength(1)
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('does not warn when additionalProperties is true', () => {
|
|
641
|
+
expect(warningsFor({ type: 'object', additionalProperties: true })).toHaveLength(0)
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it('does not warn when additionalProperties is a sub-schema', () => {
|
|
645
|
+
expect(
|
|
646
|
+
warningsFor({ type: 'object', additionalProperties: { type: 'string' } })
|
|
647
|
+
).toHaveLength(0)
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it('does not warn when the object declares properties', () => {
|
|
651
|
+
expect(
|
|
652
|
+
warningsFor({ type: 'object', properties: { targets: { type: 'array' } } })
|
|
653
|
+
).toHaveLength(0)
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('does not warn on composition schemas (oneOf/anyOf/allOf)', () => {
|
|
657
|
+
expect(
|
|
658
|
+
warningsFor({ type: 'object', oneOf: [{ type: 'object', properties: { a: { type: 'string' } } }] })
|
|
659
|
+
).toHaveLength(0)
|
|
660
|
+
expect(
|
|
661
|
+
warningsFor({ type: 'object', allOf: [{ type: 'object', properties: { a: { type: 'string' } } }] })
|
|
662
|
+
).toHaveLength(0)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('does not warn on non-object types', () => {
|
|
666
|
+
expect(warningsFor({ type: 'string' })).toHaveLength(0)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('still generates the field as a usable object type', () => {
|
|
670
|
+
const connector = new OpenAPIConnector()
|
|
671
|
+
const { schemas } = connector.parseWithWarnings(specWithPayload({ type: 'object' }))
|
|
672
|
+
const tasks = schemas.find(e => e.name === 'tasks')
|
|
673
|
+
expect(tasks.fields.payload).toBeDefined()
|
|
674
|
+
expect(tasks.fields.payload.type).toBe('object')
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('parse() logs collected warnings to console.warn', () => {
|
|
678
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
679
|
+
try {
|
|
680
|
+
const connector = new OpenAPIConnector()
|
|
681
|
+
connector.parse(specWithPayload({ type: 'object' }))
|
|
682
|
+
const calls = warnSpy.mock.calls.map(args => args.join(' '))
|
|
683
|
+
expect(calls.some(line => line.includes('EMPTY') || line.includes('additionalProperties'))).toBe(true)
|
|
684
|
+
expect(calls.some(line => line.includes('/api/tasks/{id}#payload'))).toBe(true)
|
|
685
|
+
} finally {
|
|
686
|
+
warnSpy.mockRestore()
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
describe('inference options (#1255)', () => {
|
|
693
|
+
// /api/bots: GET item + POST with a partial body; lastSeen/uuid are
|
|
694
|
+
// response-only, name is writable, status declares readOnly in-schema.
|
|
695
|
+
const botsSpec = {
|
|
696
|
+
openapi: '3.0.0',
|
|
697
|
+
paths: {
|
|
698
|
+
'/api/bots/{id}': {
|
|
699
|
+
get: {
|
|
700
|
+
responses: {
|
|
701
|
+
'200': {
|
|
702
|
+
content: {
|
|
703
|
+
'application/json': {
|
|
704
|
+
schema: {
|
|
705
|
+
type: 'object',
|
|
706
|
+
properties: {
|
|
707
|
+
data: {
|
|
708
|
+
type: 'object',
|
|
709
|
+
properties: {
|
|
710
|
+
uuid: { type: 'string', format: 'uuid' },
|
|
711
|
+
name: { type: 'string' },
|
|
712
|
+
lastSeen: { type: 'string', format: 'date-time' },
|
|
713
|
+
status: { type: 'string', readOnly: true },
|
|
714
|
+
notes: { type: 'string', description: 'Operator notes' }
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
'/api/bots': {
|
|
726
|
+
post: {
|
|
727
|
+
requestBody: {
|
|
728
|
+
content: {
|
|
729
|
+
'application/json': {
|
|
730
|
+
schema: {
|
|
731
|
+
type: 'object',
|
|
732
|
+
properties: {
|
|
733
|
+
name: { type: 'string' },
|
|
734
|
+
notes: { type: 'string', description: 'Operator notes' }
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
responses: {
|
|
741
|
+
'200': {
|
|
742
|
+
content: {
|
|
743
|
+
'application/json': {
|
|
744
|
+
schema: { type: 'object', properties: { data: { type: 'object', properties: { uuid: { type: 'string' } } } } }
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Read-only entity: no write operation at all
|
|
755
|
+
const logsSpec = {
|
|
756
|
+
openapi: '3.0.0',
|
|
757
|
+
paths: {
|
|
758
|
+
'/api/logs': {
|
|
759
|
+
get: {
|
|
760
|
+
responses: {
|
|
761
|
+
'200': {
|
|
762
|
+
content: {
|
|
763
|
+
'application/json': {
|
|
764
|
+
schema: {
|
|
765
|
+
type: 'object',
|
|
766
|
+
properties: {
|
|
767
|
+
data: {
|
|
768
|
+
type: 'array',
|
|
769
|
+
items: {
|
|
770
|
+
type: 'object',
|
|
771
|
+
properties: {
|
|
772
|
+
id: { type: 'string' },
|
|
773
|
+
message: { type: 'string' }
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
describe('inferLabels', () => {
|
|
789
|
+
it('is off by default (label only from description)', () => {
|
|
790
|
+
const connector = new OpenAPIConnector()
|
|
791
|
+
const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
|
|
792
|
+
|
|
793
|
+
expect(bots.fields.lastSeen.label).toBeUndefined()
|
|
794
|
+
expect(bots.fields.notes.label).toBe('Operator notes')
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('humanizes field names when enabled and description is absent', () => {
|
|
798
|
+
const connector = new OpenAPIConnector({ inferLabels: 'humanize' })
|
|
799
|
+
const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
|
|
800
|
+
|
|
801
|
+
expect(bots.fields.lastSeen.label).toBe('Last Seen')
|
|
802
|
+
expect(bots.fields.uuid.label).toBe('Uuid')
|
|
803
|
+
// description still wins
|
|
804
|
+
expect(bots.fields.notes.label).toBe('Operator notes')
|
|
805
|
+
})
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
describe('inferReadOnly', () => {
|
|
809
|
+
it('is off by default (readOnly only from schema)', () => {
|
|
810
|
+
const connector = new OpenAPIConnector()
|
|
811
|
+
const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
|
|
812
|
+
|
|
813
|
+
expect(bots.fields.lastSeen.readOnly).toBe(false)
|
|
814
|
+
expect(bots.fields.status.readOnly).toBe(true)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('marks response-only fields readOnly when enabled', () => {
|
|
818
|
+
const connector = new OpenAPIConnector({ inferReadOnly: true })
|
|
819
|
+
const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
|
|
820
|
+
|
|
821
|
+
// never in a request body → inferred readOnly
|
|
822
|
+
expect(bots.fields.uuid.readOnly).toBe(true)
|
|
823
|
+
expect(bots.fields.lastSeen.readOnly).toBe(true)
|
|
824
|
+
// present in the POST body → stays writable
|
|
825
|
+
expect(bots.fields.name.readOnly).toBe(false)
|
|
826
|
+
expect(bots.fields.notes.readOnly).toBe(false)
|
|
827
|
+
// schema-declared readOnly preserved
|
|
828
|
+
expect(bots.fields.status.readOnly).toBe(true)
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('marks every field readOnly for entities without write operations', () => {
|
|
832
|
+
const connector = new OpenAPIConnector({ inferReadOnly: true })
|
|
833
|
+
const logs = connector.parse(logsSpec).find(e => e.name === 'logs')
|
|
834
|
+
|
|
835
|
+
expect(logs.fields.id.readOnly).toBe(true)
|
|
836
|
+
expect(logs.fields.message.readOnly).toBe(true)
|
|
837
|
+
})
|
|
838
|
+
})
|
|
587
839
|
})
|
|
588
840
|
|
|
589
841
|
describe('extensions option', () => {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { BaseConnector, type ConnectorOptions, type ParseResult, type ParseWarning } from './BaseConnector'
|
|
11
11
|
import { getDefaultType, type CustomMappings } from '../FieldMapper'
|
|
12
|
+
import { humanizeFieldName } from '../../utils/humanize'
|
|
12
13
|
import type { UnifiedEntitySchema, UnifiedFieldSchema, UnifiedFieldType } from '../schema'
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -48,6 +49,19 @@ export interface OpenAPIConnectorOptions extends ConnectorOptions {
|
|
|
48
49
|
operationFilter?: OperationFilter | null
|
|
49
50
|
/** Custom type mappings for FieldMapper */
|
|
50
51
|
customMappings?: CustomMappings
|
|
52
|
+
/**
|
|
53
|
+
* Infer a label from the field name when the schema has no `description`
|
|
54
|
+
* (#1255): 'humanize' turns `botUuid` into "Bot Uuid". Opt-in — it changes
|
|
55
|
+
* generated output. Default: off (label only from `description`).
|
|
56
|
+
*/
|
|
57
|
+
inferLabels?: 'humanize' | false
|
|
58
|
+
/**
|
|
59
|
+
* Infer `readOnly` from operation shape (#1255): a field that appears in
|
|
60
|
+
* responses but in no request-body schema is marked `readOnly: true`
|
|
61
|
+
* (an entity with no write operations gets all fields readOnly). Opt-in —
|
|
62
|
+
* it changes generated output. Schema-declared `readOnly` always wins.
|
|
63
|
+
*/
|
|
64
|
+
inferReadOnly?: boolean
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
/**
|
|
@@ -64,6 +78,8 @@ interface EntityWorkingData {
|
|
|
64
78
|
rawSchema: OpenAPISchema | null
|
|
65
79
|
/** Extracted fields */
|
|
66
80
|
fields: Map<string, UnifiedFieldSchema>
|
|
81
|
+
/** Field names seen in request-body schemas (for readOnly inference, #1255) */
|
|
82
|
+
requestFields: Set<string>
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
/**
|
|
@@ -173,6 +189,12 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
173
189
|
/** Custom type mappings */
|
|
174
190
|
customMappings: CustomMappings
|
|
175
191
|
|
|
192
|
+
/** Label inference mode (#1255) */
|
|
193
|
+
inferLabels: 'humanize' | false
|
|
194
|
+
|
|
195
|
+
/** Whether to infer readOnly from operation shape (#1255) */
|
|
196
|
+
inferReadOnly: boolean
|
|
197
|
+
|
|
176
198
|
/**
|
|
177
199
|
* Create a new OpenAPI connector
|
|
178
200
|
*
|
|
@@ -185,6 +207,8 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
185
207
|
this.dataWrapper = options.dataWrapper ?? 'data'
|
|
186
208
|
this.operationFilter = options.operationFilter || null
|
|
187
209
|
this.customMappings = options.customMappings || {}
|
|
210
|
+
this.inferLabels = options.inferLabels ?? false
|
|
211
|
+
this.inferReadOnly = options.inferReadOnly ?? false
|
|
188
212
|
}
|
|
189
213
|
|
|
190
214
|
/**
|
|
@@ -201,10 +225,11 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
201
225
|
* })
|
|
202
226
|
*/
|
|
203
227
|
parse(source: unknown): UnifiedEntitySchema[] {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
228
|
+
const { schemas, warnings } = this.parseWithWarnings(source)
|
|
229
|
+
for (const warning of warnings) {
|
|
230
|
+
console.warn(`⚠ [${this.name}] ${warning.path}: ${warning.message}`)
|
|
231
|
+
}
|
|
232
|
+
return schemas
|
|
208
233
|
}
|
|
209
234
|
|
|
210
235
|
/**
|
|
@@ -267,6 +292,7 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
267
292
|
idField: null,
|
|
268
293
|
rawSchema: null,
|
|
269
294
|
fields: new Map(),
|
|
295
|
+
requestFields: new Set(),
|
|
270
296
|
})
|
|
271
297
|
}
|
|
272
298
|
|
|
@@ -371,6 +397,8 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
371
397
|
const requestSchema = this._extractRequestSchema(op, spec)
|
|
372
398
|
if (requestSchema) {
|
|
373
399
|
this._extractFields(requestSchema, entity.fields, spec, warnings, path)
|
|
400
|
+
// Track writable field names for readOnly inference (#1255)
|
|
401
|
+
this._collectFieldNames(requestSchema, spec, entity.requestFields)
|
|
374
402
|
}
|
|
375
403
|
}
|
|
376
404
|
}
|
|
@@ -565,6 +593,20 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
565
593
|
continue
|
|
566
594
|
}
|
|
567
595
|
|
|
596
|
+
// Bare object schema: no contract at all. fast-json-stringify (Fastify)
|
|
597
|
+
// serializes such a field to `{}` at runtime, stripping every key (#1240).
|
|
598
|
+
if (this._isBareObjectSchema(resolvedSchema)) {
|
|
599
|
+
warnings.push({
|
|
600
|
+
path: `${sourcePath}#${fieldName}`,
|
|
601
|
+
message:
|
|
602
|
+
`object schema has no \`properties\` and \`additionalProperties\` is not set. ` +
|
|
603
|
+
`Under Fastify (fast-json-stringify) this serializes to \`{}\` at runtime ` +
|
|
604
|
+
`and the generated field type is unusable. ` +
|
|
605
|
+
`For a free-form JSON blob, declare \`additionalProperties: true\`.`,
|
|
606
|
+
code: 'EMPTY_OBJECT_SCHEMA',
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
|
|
568
610
|
// Use FieldMapper for type conversion
|
|
569
611
|
const type = getDefaultType(resolvedSchema, this.customMappings)
|
|
570
612
|
|
|
@@ -578,6 +620,8 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
578
620
|
// Add optional properties
|
|
579
621
|
if (resolvedSchema.description) {
|
|
580
622
|
field.label = resolvedSchema.description
|
|
623
|
+
} else if (this.inferLabels === 'humanize') {
|
|
624
|
+
field.label = humanizeFieldName(fieldName)
|
|
581
625
|
}
|
|
582
626
|
if (resolvedSchema.format) {
|
|
583
627
|
field.format = resolvedSchema.format
|
|
@@ -604,6 +648,57 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
604
648
|
}
|
|
605
649
|
}
|
|
606
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Collect field names from a schema the same way _extractFields names them
|
|
653
|
+
* (top-level + one-level-nested dotted keys). Used to track which fields
|
|
654
|
+
* appear in request bodies, for readOnly inference (#1255).
|
|
655
|
+
*
|
|
656
|
+
* @private
|
|
657
|
+
* @param schema - JSON Schema object
|
|
658
|
+
* @param spec - Full spec for ref resolution
|
|
659
|
+
* @param into - Set collecting the names
|
|
660
|
+
* @param prefix - Prefix for nested field names
|
|
661
|
+
*/
|
|
662
|
+
private _collectFieldNames(
|
|
663
|
+
schema: OpenAPISchema,
|
|
664
|
+
spec: OpenAPISpec,
|
|
665
|
+
into: Set<string>,
|
|
666
|
+
prefix: string = ''
|
|
667
|
+
): void {
|
|
668
|
+
if (!schema || !schema.properties) return
|
|
669
|
+
|
|
670
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
671
|
+
const fieldName = prefix ? `${prefix}.${name}` : name
|
|
672
|
+
into.add(fieldName)
|
|
673
|
+
|
|
674
|
+
const resolvedSchema = propSchema.$ref ? this._resolveRef(propSchema.$ref, spec) : propSchema
|
|
675
|
+
if (resolvedSchema?.type === 'object' && resolvedSchema.properties && !prefix) {
|
|
676
|
+
this._collectFieldNames(resolvedSchema, spec, into, fieldName)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Detect a "bare" object schema: type object with no properties, no
|
|
683
|
+
* additionalProperties contract, and no composition/reference that could
|
|
684
|
+
* supply one. Such a schema is the quasi-always-footgun case (#1240) —
|
|
685
|
+
* objects with `properties`, a sub-schema `additionalProperties`, `$ref`
|
|
686
|
+
* or oneOf/anyOf/allOf are deliberately excluded.
|
|
687
|
+
*
|
|
688
|
+
* @private
|
|
689
|
+
* @param schema - Resolved property schema
|
|
690
|
+
* @returns True if the schema is a contract-less object
|
|
691
|
+
*/
|
|
692
|
+
private _isBareObjectSchema(schema: OpenAPISchema): boolean {
|
|
693
|
+
if (schema.type !== 'object') return false
|
|
694
|
+
if (schema.properties && Object.keys(schema.properties).length > 0) return false
|
|
695
|
+
const additionalProperties = schema.additionalProperties
|
|
696
|
+
if (additionalProperties === true) return false
|
|
697
|
+
if (additionalProperties && typeof additionalProperties === 'object') return false
|
|
698
|
+
if (schema.$ref || schema.oneOf || schema.anyOf || schema.allOf) return false
|
|
699
|
+
return true
|
|
700
|
+
}
|
|
701
|
+
|
|
607
702
|
/**
|
|
608
703
|
* Merge field info from secondary source into primary
|
|
609
704
|
*
|
|
@@ -667,6 +762,17 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
667
762
|
const schemas: Map<string, UnifiedEntitySchema> = new Map()
|
|
668
763
|
|
|
669
764
|
for (const [name, data] of entityData) {
|
|
765
|
+
// readOnly inference (#1255): a field never seen in a request body is
|
|
766
|
+
// not writable through this API. Entities without write operations end
|
|
767
|
+
// up with every field readOnly. Schema-declared readOnly is preserved.
|
|
768
|
+
if (this.inferReadOnly) {
|
|
769
|
+
for (const field of data.fields.values()) {
|
|
770
|
+
if (!field.readOnly && !data.requestFields.has(field.name)) {
|
|
771
|
+
field.readOnly = true
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
670
776
|
const schema: UnifiedEntitySchema = {
|
|
671
777
|
name: data.name,
|
|
672
778
|
endpoint: data.endpoint,
|
|
@@ -436,6 +436,84 @@ describe('generateManagers', () => {
|
|
|
436
436
|
})
|
|
437
437
|
})
|
|
438
438
|
|
|
439
|
+
describe('baseClass option (#1253)', () => {
|
|
440
|
+
function entityConfig(extra = {}) {
|
|
441
|
+
return {
|
|
442
|
+
schema: {
|
|
443
|
+
name: 'users',
|
|
444
|
+
endpoint: '/users',
|
|
445
|
+
fields: {
|
|
446
|
+
id: { name: 'id', type: 'number' },
|
|
447
|
+
name: { name: 'name', type: 'text', required: true }
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
endpoint: '/users',
|
|
451
|
+
storageImport: '@quazardous/qdadm',
|
|
452
|
+
storageClass: 'ApiStorage',
|
|
453
|
+
...extra
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
it('class mode extends the global baseClass and imports it', async () => {
|
|
458
|
+
await generateManagers({
|
|
459
|
+
output: testOutputDir,
|
|
460
|
+
classMode: true,
|
|
461
|
+
baseClass: { import: '@/managers/SkyEntityManager', name: 'SkyEntityManager' },
|
|
462
|
+
entities: { users: entityConfig() }
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
const content = await readFile(join(testOutputDir, 'users.ts'), 'utf-8')
|
|
466
|
+
expect(content).toContain("import { SkyEntityManager } from '@/managers/SkyEntityManager'")
|
|
467
|
+
expect(content).toContain('extends SkyEntityManager<UsersEntity>')
|
|
468
|
+
expect(content).not.toContain("import { EntityManager } from '@quazardous/qdadm'")
|
|
469
|
+
// Options/record types still come from qdadm
|
|
470
|
+
expect(content).toContain("import type { EntityRecord, EntityManagerOptions } from '@quazardous/qdadm'")
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('instance mode instantiates the baseClass', async () => {
|
|
474
|
+
await generateManagers({
|
|
475
|
+
output: testOutputDir,
|
|
476
|
+
baseClass: { import: './base', name: 'MyBase' },
|
|
477
|
+
entities: { users: entityConfig() }
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
const content = await readFile(join(testOutputDir, 'usersManager.ts'), 'utf-8')
|
|
481
|
+
expect(content).toContain("import { MyBase } from './base'")
|
|
482
|
+
expect(content).toContain('new MyBase<UsersEntity>(')
|
|
483
|
+
expect(content).not.toContain('new EntityManager<UsersEntity>(')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('per-entity baseClass overrides the global one', async () => {
|
|
487
|
+
await generateManagers({
|
|
488
|
+
output: testOutputDir,
|
|
489
|
+
classMode: true,
|
|
490
|
+
baseClass: { import: './globalBase', name: 'GlobalBase' },
|
|
491
|
+
entities: {
|
|
492
|
+
users: entityConfig({
|
|
493
|
+
baseClass: { import: './localBase', name: 'LocalBase' }
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
const content = await readFile(join(testOutputDir, 'users.ts'), 'utf-8')
|
|
499
|
+
expect(content).toContain("import { LocalBase } from './localBase'")
|
|
500
|
+
expect(content).toContain('extends LocalBase<UsersEntity>')
|
|
501
|
+
expect(content).not.toContain('GlobalBase')
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('defaults to EntityManager when no baseClass is given', async () => {
|
|
505
|
+
await generateManagers({
|
|
506
|
+
output: testOutputDir,
|
|
507
|
+
classMode: true,
|
|
508
|
+
entities: { users: entityConfig() }
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
const content = await readFile(join(testOutputDir, 'users.ts'), 'utf-8')
|
|
512
|
+
expect(content).toContain("import { EntityManager } from '@quazardous/qdadm'")
|
|
513
|
+
expect(content).toContain('extends EntityManager<UsersEntity>')
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
439
517
|
describe('default output directory', () => {
|
|
440
518
|
it('uses default output when not specified', async () => {
|
|
441
519
|
// This test would write to actual src/generated/managers/
|
|
@@ -22,6 +22,21 @@ import type { EntityDecorator } from './decorators'
|
|
|
22
22
|
*/
|
|
23
23
|
const DEFAULT_OUTPUT_DIR = 'src/generated/managers/'
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Custom base class for generated managers (#1253).
|
|
27
|
+
*
|
|
28
|
+
* The class MUST extend EntityManager, stay generic over the entity type
|
|
29
|
+
* and pass constructor options through — the generated code does
|
|
30
|
+
* `class Generated<X>Manager extends <name><XEntity>` and calls
|
|
31
|
+
* `super({ ...schema options, storage, ...options })`.
|
|
32
|
+
*/
|
|
33
|
+
export interface GenerateManagersBaseClass {
|
|
34
|
+
/** Import specifier (e.g. '@/managers/SkyEntityManager') */
|
|
35
|
+
import: string
|
|
36
|
+
/** Exported class name (e.g. 'SkyEntityManager') */
|
|
37
|
+
name: string
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Entity configuration for generation
|
|
27
42
|
*/
|
|
@@ -40,6 +55,8 @@ export interface GenerateManagersEntityConfig {
|
|
|
40
55
|
decorators?: EntityDecorator
|
|
41
56
|
/** Generate class instead of instance (for extension) */
|
|
42
57
|
classMode?: boolean
|
|
58
|
+
/** Base class for this entity's generated manager (overrides global) */
|
|
59
|
+
baseClass?: GenerateManagersBaseClass
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
/**
|
|
@@ -50,6 +67,8 @@ export interface GenerateManagersConfig {
|
|
|
50
67
|
output?: string
|
|
51
68
|
/** Generate classes instead of instances (for extension pattern) */
|
|
52
69
|
classMode?: boolean
|
|
70
|
+
/** Base class for all generated managers (default: EntityManager) */
|
|
71
|
+
baseClass?: GenerateManagersBaseClass
|
|
53
72
|
/** Entity configurations keyed by entity name */
|
|
54
73
|
entities: Record<string, GenerateManagersEntityConfig>
|
|
55
74
|
}
|
|
@@ -175,12 +194,19 @@ function serializeValue(value: unknown, indent: number = 0): string {
|
|
|
175
194
|
function generateManagerSource(
|
|
176
195
|
entityName: string,
|
|
177
196
|
entityConfig: GenerateManagersEntityConfig,
|
|
178
|
-
globalOptions: { classMode?: boolean } = {}
|
|
197
|
+
globalOptions: { classMode?: boolean; baseClass?: GenerateManagersBaseClass } = {}
|
|
179
198
|
): string {
|
|
180
199
|
const { schema, endpoint, storageImport, storageClass, storageOptions = {}, decorators } = entityConfig
|
|
181
200
|
const classMode = entityConfig.classMode ?? globalOptions.classMode ?? false
|
|
182
201
|
const className = toPascalCase(entityName)
|
|
183
202
|
|
|
203
|
+
// Custom base class (#1253): per-entity wins over global, default EntityManager.
|
|
204
|
+
const baseClass = entityConfig.baseClass ?? globalOptions.baseClass
|
|
205
|
+
const baseName = baseClass?.name ?? 'EntityManager'
|
|
206
|
+
const baseImport = baseClass
|
|
207
|
+
? `import { ${baseClass.name} } from '${baseClass.import}'`
|
|
208
|
+
: `import { EntityManager } from '@quazardous/qdadm'`
|
|
209
|
+
|
|
184
210
|
// Build storage options object
|
|
185
211
|
const fullStorageOptions: Record<string, unknown> = {
|
|
186
212
|
endpoint,
|
|
@@ -240,7 +266,7 @@ function generateManagerSource(
|
|
|
240
266
|
* Endpoint: ${endpoint}
|
|
241
267
|
*/
|
|
242
268
|
|
|
243
|
-
|
|
269
|
+
${baseImport}
|
|
244
270
|
import type { EntityRecord, EntityManagerOptions } from '@quazardous/qdadm'
|
|
245
271
|
import { ${storageClass} } from '${storageImport}'
|
|
246
272
|
|
|
@@ -249,9 +275,9 @@ ${entityInterface}
|
|
|
249
275
|
/**
|
|
250
276
|
* Generated base manager for ${entityName}
|
|
251
277
|
*
|
|
252
|
-
* @extends
|
|
278
|
+
* @extends ${baseName}
|
|
253
279
|
*/
|
|
254
|
-
export class Generated${className}Manager extends
|
|
280
|
+
export class Generated${className}Manager extends ${baseName}<${className}Entity> {
|
|
255
281
|
constructor(options: Partial<EntityManagerOptions<${className}Entity>> = {}) {
|
|
256
282
|
super({
|
|
257
283
|
...${serializeValue(managerOptions, 2)},
|
|
@@ -274,7 +300,7 @@ export class Generated${className}Manager extends EntityManager<${className}Enti
|
|
|
274
300
|
* Endpoint: ${endpoint}
|
|
275
301
|
*/
|
|
276
302
|
|
|
277
|
-
|
|
303
|
+
${baseImport}
|
|
278
304
|
import type { EntityRecord } from '@quazardous/qdadm'
|
|
279
305
|
import { ${storageClass} } from '${storageImport}'
|
|
280
306
|
|
|
@@ -295,7 +321,7 @@ const storageOptions = ${serializeValue(fullStorageOptions, 0)}
|
|
|
295
321
|
*
|
|
296
322
|
* Provides CRUD operations for ${entityName} entity.
|
|
297
323
|
*/
|
|
298
|
-
export const ${entityName}Manager = new
|
|
324
|
+
export const ${entityName}Manager = new ${baseName}<${className}Entity>({
|
|
299
325
|
...${serializeValue(managerOptions, 0)},
|
|
300
326
|
storage: new ${storageClass}<${className}Entity>(storageOptions)
|
|
301
327
|
})
|
|
@@ -349,7 +375,7 @@ export async function generateManagers(config: GenerateManagersConfig): Promise<
|
|
|
349
375
|
}
|
|
350
376
|
|
|
351
377
|
const outputDir = config.output || DEFAULT_OUTPUT_DIR
|
|
352
|
-
const globalOptions = { classMode: config.classMode ?? false }
|
|
378
|
+
const globalOptions = { classMode: config.classMode ?? false, baseClass: config.baseClass }
|
|
353
379
|
const generatedFiles: string[] = []
|
|
354
380
|
|
|
355
381
|
// Validate each entity config
|
package/src/gen/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -96,6 +96,8 @@ export {
|
|
|
96
96
|
type SeverityMapValue,
|
|
97
97
|
type SeverityMap,
|
|
98
98
|
type RoutingContext,
|
|
99
|
+
type StorageResolution,
|
|
100
|
+
type ResolvedStorage,
|
|
99
101
|
type PresaveContext,
|
|
100
102
|
type PostsaveContext,
|
|
101
103
|
type PredeleteContext,
|
|
@@ -104,6 +106,17 @@ export {
|
|
|
104
106
|
type CacheInfo,
|
|
105
107
|
} from './entity/EntityManager'
|
|
106
108
|
|
|
109
|
+
// Structural manager/orchestrator views (#1253) — the implementer's minimum
|
|
110
|
+
// that composables consume; import these to type manager-likes and test doubles.
|
|
111
|
+
export type {
|
|
112
|
+
EntityManagerBase,
|
|
113
|
+
EntityManagerPermissions,
|
|
114
|
+
EntityManagerRead,
|
|
115
|
+
EntityManagerCrud,
|
|
116
|
+
EntityManagerLike,
|
|
117
|
+
OrchestratorLike,
|
|
118
|
+
} from './entity/EntityManager.interface'
|
|
119
|
+
|
|
107
120
|
// Re-export core entity types
|
|
108
121
|
export type {
|
|
109
122
|
EntityRecord,
|
|
@@ -150,6 +163,30 @@ export type {
|
|
|
150
163
|
// ════════════════════════════════════════════════════════════════════════════
|
|
151
164
|
export * from './orchestrator/index'
|
|
152
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Typed manager registry (#1253) — augment it from your app to make
|
|
168
|
+
* `getManager()` / `useEntity()` return your concrete EntityManager
|
|
169
|
+
* subclasses instead of `EntityManager<EntityRecord>`:
|
|
170
|
+
*
|
|
171
|
+
* ```ts
|
|
172
|
+
* declare module '@quazardous/qdadm' {
|
|
173
|
+
* interface QdadmManagerRegistry {
|
|
174
|
+
* bots: BotsManager
|
|
175
|
+
* botPools: BotPoolsManager
|
|
176
|
+
* }
|
|
177
|
+
* }
|
|
178
|
+
* ```
|
|
179
|
+
*
|
|
180
|
+
* Entity names not declared here keep the current fallback behavior.
|
|
181
|
+
*
|
|
182
|
+
* NOTE: deliberately declared in this file (not re-exported from a
|
|
183
|
+
* sub-module) — TypeScript module augmentation does not merge through
|
|
184
|
+
* re-export aliases, and consumers augment '@quazardous/qdadm', which
|
|
185
|
+
* resolves here.
|
|
186
|
+
*/
|
|
187
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
188
|
+
export interface QdadmManagerRegistry {}
|
|
189
|
+
|
|
153
190
|
// ════════════════════════════════════════════════════════════════════════════
|
|
154
191
|
// COMPOSABLES (excluding types that conflict with other modules)
|
|
155
192
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -16,13 +16,24 @@ import { inject } from 'vue'
|
|
|
16
16
|
import type { Orchestrator } from './Orchestrator'
|
|
17
17
|
import type { EntityManager } from '../entity/EntityManager'
|
|
18
18
|
import type { EntityRecord } from '../types'
|
|
19
|
+
// Type-only circular import: QdadmManagerRegistry must live in the package
|
|
20
|
+
// entry (module augmentation does not merge through re-export aliases).
|
|
21
|
+
import type { QdadmManagerRegistry } from '../index'
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Return type for useOrchestrator
|
|
25
|
+
*
|
|
26
|
+
* `getManager` resolves through the consumer-augmentable
|
|
27
|
+
* QdadmManagerRegistry first (#1253): a declared entity name returns its
|
|
28
|
+
* concrete manager subclass; anything else falls back to the historical
|
|
29
|
+
* `EntityManager<T>` signature.
|
|
22
30
|
*/
|
|
23
31
|
export interface UseOrchestratorReturn {
|
|
24
32
|
orchestrator: Orchestrator
|
|
25
|
-
getManager:
|
|
33
|
+
getManager: {
|
|
34
|
+
<K extends keyof QdadmManagerRegistry>(name: K): QdadmManagerRegistry[K]
|
|
35
|
+
<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T>
|
|
36
|
+
}
|
|
26
37
|
hasManager: (name: string) => boolean
|
|
27
38
|
}
|
|
28
39
|
|
|
@@ -46,8 +57,10 @@ export function useOrchestrator(): UseOrchestratorReturn {
|
|
|
46
57
|
* Get an EntityManager by name
|
|
47
58
|
* @param name - Entity name
|
|
48
59
|
*/
|
|
49
|
-
function getManager<
|
|
50
|
-
|
|
60
|
+
function getManager<K extends keyof QdadmManagerRegistry>(name: K): QdadmManagerRegistry[K]
|
|
61
|
+
function getManager<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T>
|
|
62
|
+
function getManager(name: string): unknown {
|
|
63
|
+
return orchestrator.get(name)
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
/**
|
|
@@ -74,7 +87,9 @@ export function useOrchestrator(): UseOrchestratorReturn {
|
|
|
74
87
|
* const { items } = await users.list()
|
|
75
88
|
* ```
|
|
76
89
|
*/
|
|
77
|
-
export function useEntity<
|
|
90
|
+
export function useEntity<K extends keyof QdadmManagerRegistry>(name: K): QdadmManagerRegistry[K]
|
|
91
|
+
export function useEntity<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T>
|
|
92
|
+
export function useEntity(name: string): unknown {
|
|
78
93
|
const { getManager } = useOrchestrator()
|
|
79
|
-
return getManager
|
|
94
|
+
return getManager(name)
|
|
80
95
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-name humanization (#1255)
|
|
3
|
+
*
|
|
4
|
+
* Turns machine field names into presentable labels:
|
|
5
|
+
* 'botUuid' → 'Bot Uuid'
|
|
6
|
+
* 'created_at' → 'Created At'
|
|
7
|
+
* 'filter.botId' → 'Filter Bot Id'
|
|
8
|
+
*
|
|
9
|
+
* Shared by the list column binding helper (useListPage.column()) and the
|
|
10
|
+
* OpenAPI connector's opt-in label inference — dependency-free so the gen
|
|
11
|
+
* side (node) can import it too.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Humanize a field name: split camelCase, snake_case, kebab-case and dotted
|
|
16
|
+
* segments into words, then capitalize each word.
|
|
17
|
+
*/
|
|
18
|
+
export function humanizeFieldName(field: string): string {
|
|
19
|
+
return field
|
|
20
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
21
|
+
.replace(/[_\-.]+/g, ' ')
|
|
22
|
+
.trim()
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
25
|
+
.join(' ')
|
|
26
|
+
}
|
package/src/utils/index.ts
CHANGED