@opensaas/stack-core 0.6.1 → 0.7.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +53 -0
- package/CLAUDE.md +37 -0
- package/README.md +2 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +27 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/fields/index.d.ts +27 -10
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +78 -11
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/config/index.ts +1 -0
- package/src/config/types.ts +31 -0
- package/src/fields/index.ts +89 -12
- package/src/index.ts +1 -0
- package/tests/virtual-fields.test.ts +421 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/fields/index.ts
CHANGED
|
@@ -539,6 +539,58 @@ export function json<
|
|
|
539
539
|
}
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Convert a TypeDescriptor to a TypeScript type string
|
|
544
|
+
* Handles three formats:
|
|
545
|
+
* 1. Primitive string: 'string', 'number', 'boolean' -> returned as-is
|
|
546
|
+
* 2. Import string: "import('decimal.js').Decimal" -> returned as-is
|
|
547
|
+
* 3. Type object: { value: Decimal, from: 'decimal.js' } -> "import('decimal.js').Decimal"
|
|
548
|
+
*/
|
|
549
|
+
function typeDescriptorToString(descriptor: import('../config/types.js').TypeDescriptor): string {
|
|
550
|
+
if (typeof descriptor === 'string') {
|
|
551
|
+
return descriptor
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Extract type name from constructor or use provided name
|
|
555
|
+
const typeName = descriptor.name || descriptor.value.name
|
|
556
|
+
|
|
557
|
+
// Generate import string
|
|
558
|
+
return `import('${descriptor.from}').${typeName}`
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Extract TypeScript imports from a TypeDescriptor
|
|
563
|
+
* Returns array of import statements needed for type generation
|
|
564
|
+
*/
|
|
565
|
+
function typeDescriptorToImports(
|
|
566
|
+
descriptor: import('../config/types.js').TypeDescriptor,
|
|
567
|
+
): Array<{ names: string[]; from: string; typeOnly?: boolean }> {
|
|
568
|
+
// If it's a string, check if it's an import string
|
|
569
|
+
if (typeof descriptor === 'string') {
|
|
570
|
+
const importMatch = descriptor.match(/import\('([^']+)'\)\.(\w+)/)
|
|
571
|
+
if (importMatch) {
|
|
572
|
+
return [
|
|
573
|
+
{
|
|
574
|
+
names: [importMatch[2]],
|
|
575
|
+
from: importMatch[1],
|
|
576
|
+
typeOnly: true,
|
|
577
|
+
},
|
|
578
|
+
]
|
|
579
|
+
}
|
|
580
|
+
return []
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Type object descriptor
|
|
584
|
+
const typeName = descriptor.name || descriptor.value.name
|
|
585
|
+
return [
|
|
586
|
+
{
|
|
587
|
+
names: [typeName],
|
|
588
|
+
from: descriptor.from,
|
|
589
|
+
typeOnly: true,
|
|
590
|
+
},
|
|
591
|
+
]
|
|
592
|
+
}
|
|
593
|
+
|
|
542
594
|
/**
|
|
543
595
|
* Virtual field - not stored in database, computed via hooks
|
|
544
596
|
*
|
|
@@ -548,10 +600,11 @@ export function json<
|
|
|
548
600
|
* - Optionally uses resolveInput hook for write side effects (e.g., sync to external API)
|
|
549
601
|
* - Only computed when explicitly selected/included in queries
|
|
550
602
|
* - Supports both read and write operations via hooks
|
|
603
|
+
* - Supports custom scalar types (e.g., Decimal) for financial precision
|
|
551
604
|
*
|
|
552
|
-
* **Usage
|
|
605
|
+
* **Usage Examples:**
|
|
553
606
|
* ```typescript
|
|
554
|
-
* // Read-only computed field
|
|
607
|
+
* // Read-only computed field with primitive type
|
|
555
608
|
* fields: {
|
|
556
609
|
* firstName: text(),
|
|
557
610
|
* lastName: text(),
|
|
@@ -563,6 +616,28 @@ export function json<
|
|
|
563
616
|
* })
|
|
564
617
|
* }
|
|
565
618
|
*
|
|
619
|
+
* // Custom scalar type using import string
|
|
620
|
+
* fields: {
|
|
621
|
+
* totalPrice: virtual({
|
|
622
|
+
* type: "import('decimal.js').Decimal",
|
|
623
|
+
* hooks: {
|
|
624
|
+
* resolveOutput: ({ item }) => new Decimal(item.price).times(item.quantity)
|
|
625
|
+
* }
|
|
626
|
+
* })
|
|
627
|
+
* }
|
|
628
|
+
*
|
|
629
|
+
* // Custom scalar type using type descriptor (recommended)
|
|
630
|
+
* import Decimal from 'decimal.js'
|
|
631
|
+
*
|
|
632
|
+
* fields: {
|
|
633
|
+
* totalPrice: virtual({
|
|
634
|
+
* type: { value: Decimal, from: 'decimal.js' },
|
|
635
|
+
* hooks: {
|
|
636
|
+
* resolveOutput: ({ item }) => new Decimal(item.price).times(item.quantity)
|
|
637
|
+
* }
|
|
638
|
+
* })
|
|
639
|
+
* }
|
|
640
|
+
*
|
|
566
641
|
* // Write side effects (e.g., sync to external API)
|
|
567
642
|
* fields: {
|
|
568
643
|
* externalSync: virtual({
|
|
@@ -576,16 +651,10 @@ export function json<
|
|
|
576
651
|
* }
|
|
577
652
|
* })
|
|
578
653
|
* }
|
|
579
|
-
*
|
|
580
|
-
* // Query with select
|
|
581
|
-
* const user = await context.db.user.findUnique({
|
|
582
|
-
* where: { id },
|
|
583
|
-
* select: { firstName: true, lastName: true, fullName: true } // fullName computed
|
|
584
|
-
* })
|
|
585
654
|
* ```
|
|
586
655
|
*
|
|
587
656
|
* **Requirements:**
|
|
588
|
-
* - Must provide `type` (TypeScript type string)
|
|
657
|
+
* - Must provide `type` (TypeScript type string, import string, or type descriptor)
|
|
589
658
|
* - Must provide `resolveOutput` hook (for reads)
|
|
590
659
|
* - Optional `resolveInput` hook (for write side effects)
|
|
591
660
|
*
|
|
@@ -593,7 +662,9 @@ export function json<
|
|
|
593
662
|
* @returns Virtual field configuration
|
|
594
663
|
*/
|
|
595
664
|
export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>(
|
|
596
|
-
options: Omit<VirtualField<TTypeInfo>, 'virtual' | 'outputType' | 'type'> & {
|
|
665
|
+
options: Omit<VirtualField<TTypeInfo>, 'virtual' | 'outputType' | 'type'> & {
|
|
666
|
+
type: import('../config/types.js').TypeDescriptor
|
|
667
|
+
},
|
|
597
668
|
): VirtualField<TTypeInfo> {
|
|
598
669
|
// Validate that resolveOutput is provided
|
|
599
670
|
if (!options.hooks?.resolveOutput) {
|
|
@@ -603,7 +674,11 @@ export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>
|
|
|
603
674
|
)
|
|
604
675
|
}
|
|
605
676
|
|
|
606
|
-
|
|
677
|
+
// Convert type descriptor to string
|
|
678
|
+
const outputType = typeDescriptorToString(options.type)
|
|
679
|
+
const imports = typeDescriptorToImports(options.type)
|
|
680
|
+
|
|
681
|
+
const { type: _, ...rest } = options
|
|
607
682
|
|
|
608
683
|
return {
|
|
609
684
|
type: 'virtual',
|
|
@@ -616,10 +691,12 @@ export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>
|
|
|
616
691
|
// Virtual fields appear in output types with their specified type
|
|
617
692
|
getTypeScriptType: () => {
|
|
618
693
|
return {
|
|
619
|
-
type:
|
|
694
|
+
type: outputType,
|
|
620
695
|
optional: false, // Virtual fields always compute a value
|
|
621
696
|
}
|
|
622
697
|
},
|
|
698
|
+
// Add import statements if needed
|
|
699
|
+
getTypeScriptImports: imports.length > 0 ? () => imports : undefined,
|
|
623
700
|
// Virtual fields never validate input (they don't accept database input)
|
|
624
701
|
getZodSchema: () => {
|
|
625
702
|
return z.never()
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
import { virtual } from '../src/fields/index.js'
|
|
3
|
+
|
|
4
|
+
// Mock Decimal class for testing
|
|
5
|
+
class Decimal {
|
|
6
|
+
value: number
|
|
7
|
+
|
|
8
|
+
constructor(value: number | string) {
|
|
9
|
+
this.value = typeof value === 'string' ? parseFloat(value) : value
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
times(other: Decimal | number): Decimal {
|
|
13
|
+
const otherValue = other instanceof Decimal ? other.value : other
|
|
14
|
+
return new Decimal(this.value * otherValue)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
plus(other: Decimal | number): Decimal {
|
|
18
|
+
const otherValue = other instanceof Decimal ? other.value : other
|
|
19
|
+
return new Decimal(this.value + otherValue)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
toString(): string {
|
|
23
|
+
return this.value.toString()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static get name() {
|
|
27
|
+
return 'Decimal'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Mock custom class for testing
|
|
32
|
+
class CustomType {
|
|
33
|
+
data: string
|
|
34
|
+
|
|
35
|
+
constructor(data: string) {
|
|
36
|
+
this.data = data
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static get name() {
|
|
40
|
+
return 'CustomType'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('Virtual Fields with TypeDescriptor', () => {
|
|
45
|
+
describe('primitive type strings', () => {
|
|
46
|
+
test('accepts string type', () => {
|
|
47
|
+
const field = virtual({
|
|
48
|
+
type: 'string',
|
|
49
|
+
hooks: {
|
|
50
|
+
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(field.type).toBe('virtual')
|
|
55
|
+
expect(field.virtual).toBe(true)
|
|
56
|
+
expect(field.outputType).toBe('string')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('accepts number type', () => {
|
|
60
|
+
const field = virtual({
|
|
61
|
+
type: 'number',
|
|
62
|
+
hooks: {
|
|
63
|
+
resolveOutput: ({ item }) => item.count * 2,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(field.outputType).toBe('number')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('accepts boolean type', () => {
|
|
71
|
+
const field = virtual({
|
|
72
|
+
type: 'boolean',
|
|
73
|
+
hooks: {
|
|
74
|
+
resolveOutput: ({ item }) => item.isActive,
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(field.outputType).toBe('boolean')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('accepts Date type', () => {
|
|
82
|
+
const field = virtual({
|
|
83
|
+
type: 'Date',
|
|
84
|
+
hooks: {
|
|
85
|
+
resolveOutput: ({ item }) => new Date(item.createdAt),
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
expect(field.outputType).toBe('Date')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('accepts array types', () => {
|
|
93
|
+
const field = virtual({
|
|
94
|
+
type: 'string[]',
|
|
95
|
+
hooks: {
|
|
96
|
+
resolveOutput: ({ item }) => item.tags.split(','),
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(field.outputType).toBe('string[]')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('import string types', () => {
|
|
105
|
+
test('accepts import string format', () => {
|
|
106
|
+
const field = virtual({
|
|
107
|
+
type: "import('decimal.js').Decimal",
|
|
108
|
+
hooks: {
|
|
109
|
+
resolveOutput: ({ item }) => new Decimal(item.price),
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(field.outputType).toBe("import('decimal.js').Decimal")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('generates TypeScript imports from import string', () => {
|
|
117
|
+
const field = virtual({
|
|
118
|
+
type: "import('decimal.js').Decimal",
|
|
119
|
+
hooks: {
|
|
120
|
+
resolveOutput: ({ item }) => new Decimal(item.price),
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(field.getTypeScriptImports).toBeDefined()
|
|
125
|
+
const imports = field.getTypeScriptImports!()
|
|
126
|
+
expect(imports).toHaveLength(1)
|
|
127
|
+
expect(imports[0]).toEqual({
|
|
128
|
+
names: ['Decimal'],
|
|
129
|
+
from: 'decimal.js',
|
|
130
|
+
typeOnly: true,
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('handles complex import paths', () => {
|
|
135
|
+
const field = virtual({
|
|
136
|
+
type: "import('@myorg/custom-types').MyCustomType",
|
|
137
|
+
hooks: {
|
|
138
|
+
resolveOutput: ({ item }) => item.customData,
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(field.outputType).toBe("import('@myorg/custom-types').MyCustomType")
|
|
143
|
+
const imports = field.getTypeScriptImports!()
|
|
144
|
+
expect(imports[0]).toEqual({
|
|
145
|
+
names: ['MyCustomType'],
|
|
146
|
+
from: '@myorg/custom-types',
|
|
147
|
+
typeOnly: true,
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('type descriptor objects', () => {
|
|
153
|
+
test('accepts type descriptor with class constructor', () => {
|
|
154
|
+
const field = virtual({
|
|
155
|
+
type: { value: Decimal, from: 'decimal.js' },
|
|
156
|
+
hooks: {
|
|
157
|
+
resolveOutput: ({ item }) => new Decimal(item.price).times(item.quantity),
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(field.outputType).toBe("import('decimal.js').Decimal")
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('generates TypeScript imports from type descriptor', () => {
|
|
165
|
+
const field = virtual({
|
|
166
|
+
type: { value: Decimal, from: 'decimal.js' },
|
|
167
|
+
hooks: {
|
|
168
|
+
resolveOutput: ({ item }) => new Decimal(item.price),
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expect(field.getTypeScriptImports).toBeDefined()
|
|
173
|
+
const imports = field.getTypeScriptImports!()
|
|
174
|
+
expect(imports).toHaveLength(1)
|
|
175
|
+
expect(imports[0]).toEqual({
|
|
176
|
+
names: ['Decimal'],
|
|
177
|
+
from: 'decimal.js',
|
|
178
|
+
typeOnly: true,
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('uses custom name when provided', () => {
|
|
183
|
+
const field = virtual({
|
|
184
|
+
type: { value: Decimal, from: 'decimal.js', name: 'CustomDecimal' },
|
|
185
|
+
hooks: {
|
|
186
|
+
resolveOutput: ({ item }) => new Decimal(item.value),
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
expect(field.outputType).toBe("import('decimal.js').CustomDecimal")
|
|
191
|
+
const imports = field.getTypeScriptImports!()
|
|
192
|
+
expect(imports[0]).toEqual({
|
|
193
|
+
names: ['CustomDecimal'],
|
|
194
|
+
from: 'decimal.js',
|
|
195
|
+
typeOnly: true,
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('works with custom classes', () => {
|
|
200
|
+
const field = virtual({
|
|
201
|
+
type: { value: CustomType, from: './types' },
|
|
202
|
+
hooks: {
|
|
203
|
+
resolveOutput: ({ item }) => new CustomType(item.data),
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
expect(field.outputType).toBe("import('./types').CustomType")
|
|
208
|
+
const imports = field.getTypeScriptImports!()
|
|
209
|
+
expect(imports[0]).toEqual({
|
|
210
|
+
names: ['CustomType'],
|
|
211
|
+
from: './types',
|
|
212
|
+
typeOnly: true,
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('getTypeScriptType', () => {
|
|
218
|
+
test('returns correct type for primitive string', () => {
|
|
219
|
+
const field = virtual({
|
|
220
|
+
type: 'string',
|
|
221
|
+
hooks: {
|
|
222
|
+
resolveOutput: ({ item }) => item.name,
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const tsType = field.getTypeScriptType!()
|
|
227
|
+
expect(tsType.type).toBe('string')
|
|
228
|
+
expect(tsType.optional).toBe(false)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('returns correct type for import string', () => {
|
|
232
|
+
const field = virtual({
|
|
233
|
+
type: "import('decimal.js').Decimal",
|
|
234
|
+
hooks: {
|
|
235
|
+
resolveOutput: ({ item }) => new Decimal(item.value),
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const tsType = field.getTypeScriptType!()
|
|
240
|
+
expect(tsType.type).toBe("import('decimal.js').Decimal")
|
|
241
|
+
expect(tsType.optional).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('returns correct type for type descriptor', () => {
|
|
245
|
+
const field = virtual({
|
|
246
|
+
type: { value: Decimal, from: 'decimal.js' },
|
|
247
|
+
hooks: {
|
|
248
|
+
resolveOutput: ({ item }) => new Decimal(item.value),
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const tsType = field.getTypeScriptType!()
|
|
253
|
+
expect(tsType.type).toBe("import('decimal.js').Decimal")
|
|
254
|
+
expect(tsType.optional).toBe(false)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('virtual fields are never optional', () => {
|
|
258
|
+
const field = virtual({
|
|
259
|
+
type: 'string',
|
|
260
|
+
hooks: {
|
|
261
|
+
resolveOutput: ({ item }) => item.value || 'default',
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const tsType = field.getTypeScriptType!()
|
|
266
|
+
expect(tsType.optional).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('validation', () => {
|
|
271
|
+
test('throws error when resolveOutput hook is missing', () => {
|
|
272
|
+
expect(() => {
|
|
273
|
+
virtual({
|
|
274
|
+
type: 'string',
|
|
275
|
+
// @ts-expect-error - Testing missing hook
|
|
276
|
+
hooks: {},
|
|
277
|
+
})
|
|
278
|
+
}).toThrow('Virtual fields must provide a resolveOutput hook')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('throws error when hooks are completely missing', () => {
|
|
282
|
+
expect(() => {
|
|
283
|
+
// @ts-expect-error - Testing missing hooks
|
|
284
|
+
virtual({
|
|
285
|
+
type: 'string',
|
|
286
|
+
})
|
|
287
|
+
}).toThrow('Virtual fields must provide a resolveOutput hook')
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe('getPrismaType', () => {
|
|
292
|
+
test('returns undefined to skip database column creation', () => {
|
|
293
|
+
const field = virtual({
|
|
294
|
+
type: 'string',
|
|
295
|
+
hooks: {
|
|
296
|
+
resolveOutput: ({ item }) => item.computed,
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
expect(field.getPrismaType).toBeUndefined()
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe('getZodSchema', () => {
|
|
305
|
+
test('returns z.never() to prevent input validation', () => {
|
|
306
|
+
const field = virtual({
|
|
307
|
+
type: 'string',
|
|
308
|
+
hooks: {
|
|
309
|
+
resolveOutput: ({ item }) => item.computed,
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const schema = field.getZodSchema!()
|
|
314
|
+
expect(() => schema.parse('anything')).toThrow()
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
describe('real-world use cases', () => {
|
|
319
|
+
test('Decimal type for financial calculations', () => {
|
|
320
|
+
const field = virtual({
|
|
321
|
+
type: { value: Decimal, from: 'decimal.js' },
|
|
322
|
+
hooks: {
|
|
323
|
+
resolveOutput: ({ item }) => {
|
|
324
|
+
// Calculate total from price and quantity with decimal precision
|
|
325
|
+
return new Decimal(item.price).times(item.quantity)
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
expect(field.type).toBe('virtual')
|
|
331
|
+
expect(field.outputType).toBe("import('decimal.js').Decimal")
|
|
332
|
+
|
|
333
|
+
const imports = field.getTypeScriptImports!()
|
|
334
|
+
expect(imports).toEqual([
|
|
335
|
+
{
|
|
336
|
+
names: ['Decimal'],
|
|
337
|
+
from: 'decimal.js',
|
|
338
|
+
typeOnly: true,
|
|
339
|
+
},
|
|
340
|
+
])
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('Complex computed field with multiple operations', () => {
|
|
344
|
+
const field = virtual({
|
|
345
|
+
type: { value: Decimal, from: 'decimal.js' },
|
|
346
|
+
hooks: {
|
|
347
|
+
resolveOutput: ({ item }) => {
|
|
348
|
+
// Calculate tax amount
|
|
349
|
+
const subtotal = new Decimal(item.subtotal)
|
|
350
|
+
const taxRate = new Decimal(item.taxRate)
|
|
351
|
+
return subtotal.times(taxRate)
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
expect(field.outputType).toBe("import('decimal.js').Decimal")
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('Full name concatenation (primitive type)', () => {
|
|
360
|
+
const field = virtual({
|
|
361
|
+
type: 'string',
|
|
362
|
+
hooks: {
|
|
363
|
+
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
|
|
364
|
+
},
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
expect(field.outputType).toBe('string')
|
|
368
|
+
expect(field.getTypeScriptImports).toBeUndefined()
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('Array transformation', () => {
|
|
372
|
+
const field = virtual({
|
|
373
|
+
type: 'string[]',
|
|
374
|
+
hooks: {
|
|
375
|
+
resolveOutput: ({ item }) => {
|
|
376
|
+
// Split comma-separated tags into array
|
|
377
|
+
return item.tags ? item.tags.split(',').map((t: string) => t.trim()) : []
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
expect(field.outputType).toBe('string[]')
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
describe('backwards compatibility', () => {
|
|
387
|
+
test('existing primitive type strings still work', () => {
|
|
388
|
+
const field = virtual({
|
|
389
|
+
type: 'string',
|
|
390
|
+
hooks: {
|
|
391
|
+
resolveOutput: ({ item }) => item.value,
|
|
392
|
+
},
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
expect(field.type).toBe('virtual')
|
|
396
|
+
expect(field.outputType).toBe('string')
|
|
397
|
+
expect(field.getTypeScriptImports).toBeUndefined()
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test('existing field configuration is preserved', () => {
|
|
401
|
+
const field = virtual({
|
|
402
|
+
type: 'number',
|
|
403
|
+
hooks: {
|
|
404
|
+
resolveOutput: ({ item }) => item.count,
|
|
405
|
+
resolveInput: async ({ inputValue }) => inputValue,
|
|
406
|
+
},
|
|
407
|
+
access: {
|
|
408
|
+
read: () => true,
|
|
409
|
+
},
|
|
410
|
+
ui: {
|
|
411
|
+
displayMode: 'readonly',
|
|
412
|
+
},
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
expect(field.hooks?.resolveOutput).toBeDefined()
|
|
416
|
+
expect(field.hooks?.resolveInput).toBeDefined()
|
|
417
|
+
expect(field.access).toBeDefined()
|
|
418
|
+
expect(field.ui).toBeDefined()
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
})
|