json-rest-api 1.0.6 → 1.0.8

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.
Files changed (146) hide show
  1. package/.claude/agents/library-guide-expert.md +29 -0
  2. package/.claude/agents/rest-api-internals-expert.md +53 -0
  3. package/CLAUDE.md +139 -1
  4. package/README.md +2 -5
  5. package/docTest.js +238 -0
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
  7. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
  8. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
  9. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
  10. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
  11. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
  12. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
  13. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
  14. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
  15. package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
  16. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
  17. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
  18. package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
  19. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
  20. package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
  21. package/docs/GUIDE/index.md +58 -0
  22. package/docs/GUIDE.md +61 -2238
  23. package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
  24. package/docs/README.md +57 -2239
  25. package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
  26. package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
  27. package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
  28. package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
  29. package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
  30. package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
  31. package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
  32. package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
  33. package/docs/assets/css/style.scss +78 -5
  34. package/docs/index.md +2 -12
  35. package/helper-reorganization-plan.md +85 -0
  36. package/helper-usage-analysis.md +94 -0
  37. package/package.json +5 -4
  38. package/plugins/core/bulk-operations-plugin.js +2 -2
  39. package/plugins/core/connectors/express-plugin.js +99 -33
  40. package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
  41. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
  42. package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
  43. package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
  44. package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
  45. package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
  46. package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
  47. package/plugins/core/lib/querying/knex-window-queries.js +328 -0
  48. package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
  49. package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
  50. package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
  51. package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
  52. package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
  53. package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
  54. package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
  55. package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
  56. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
  57. package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
  58. package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
  59. package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
  60. package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
  61. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
  62. package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
  63. package/plugins/core/lib/writing/relationship-processor.js +252 -0
  64. package/plugins/core/rest-api-knex-plugin.js +183 -62
  65. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
  66. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
  67. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
  68. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
  69. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
  70. package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
  71. package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
  72. package/plugins/core/rest-api-plugin-methods/common.js +605 -0
  73. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
  74. package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
  75. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
  76. package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
  77. package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
  78. package/plugins/core/rest-api-plugin-methods/get.js +187 -0
  79. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
  80. package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
  81. package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
  82. package/plugins/core/rest-api-plugin-methods/post.js +157 -0
  83. package/plugins/core/rest-api-plugin-methods/put.js +257 -0
  84. package/plugins/core/rest-api-plugin-methods/query.js +208 -0
  85. package/plugins/core/rest-api-plugin.js +108 -2498
  86. package/plugins/core/socketio-plugin.js +28 -5
  87. package/plugins/storage/local-storage.js +2 -2
  88. package/tests/computed-fields.test.js +483 -0
  89. package/tests/cors.test.js +4 -4
  90. package/tests/curl.test.js +5 -4
  91. package/tests/custom-idproperty-relationships.test.js +891 -0
  92. package/tests/field-getters.test.js +353 -0
  93. package/tests/field-setters.test.js +310 -0
  94. package/tests/fixtures/api-configs.js +800 -28
  95. package/tests/jwt-auth.test.js +0 -2
  96. package/tests/nested-includes.test.js +1 -1
  97. package/tests/pagination-cursor-multifield.test.js +514 -0
  98. package/tests/pagination-enhanced.test.js +12 -9
  99. package/tests/polymorphic-relationships.test.js +10 -8
  100. package/tests/polymorphic-simple.test.js +6 -4
  101. package/tests/positioning-advanced.test.js +12 -12
  102. package/tests/positioning-extended.test.js +25 -25
  103. package/tests/positioning-multifilter.test.js +24 -24
  104. package/tests/positioning.test.js +3 -3
  105. package/tests/query-limits.test.js +24 -8
  106. package/tests/relationship-endpoints.test.js +4 -5
  107. package/tests/relationships.test.js +0 -1
  108. package/tests/socketio.test.js +12 -1
  109. package/tests/virtual-fields.test.js +13 -13
  110. package/PLAN_FOR_WEBSOCKETS.md +0 -398
  111. package/README_1_Initial_Setup.md +0 -393
  112. package/README_X_Hooks.md +0 -647
  113. package/TODO_rest-api-plugin-extras.todo +0 -23
  114. package/connectors_ideas/express-plugin.js +0 -120
  115. package/connectors_ideas/http-plugin.js +0 -138
  116. package/docs/examples/express-plugin.js +0 -366
  117. package/example.js +0 -301
  118. package/plugins/core/HELPER_MODULES.md +0 -285
  119. package/plugins/core/lib/compile-schemas.js +0 -245
  120. package/plugins/core/lib/database-value-normalizers.js +0 -192
  121. package/plugins/core/lib/default-data-helpers.js +0 -100
  122. package/plugins/core/lib/knex-cross-table-search.js +0 -442
  123. package/plugins/core/lib/knex-json-api-helpers.js +0 -84
  124. package/plugins/core/lib/knex-json-api-transformers.js +0 -449
  125. package/plugins/core/lib/knex-pagination-helpers.js +0 -193
  126. package/plugins/core/lib/knex-window-queries.js +0 -142
  127. package/plugins/core/lib/many-to-many-manipulations.js +0 -343
  128. package/plugins/core/lib/relationship-processor.js +0 -278
  129. package/plugins/core/lib/schema-helpers.js +0 -343
  130. package/plugins/core/lib/scope-validations.js +0 -252
  131. package/plugins/core/rest-api-relationships-plugin.js +0 -700
  132. package/plugins/core/utils/knex-query-helpers-base.js +0 -56
  133. /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
  134. /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
  135. /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
  136. /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
  137. /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
  138. /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
  139. /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
  140. /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
  141. /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
  142. /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
  143. /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
  144. /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
  145. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
  146. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
@@ -0,0 +1,1246 @@
1
+ # 2.7 Field Transformations
2
+
3
+ The JSON REST API library provides a comprehensive system for transforming data as it flows through your application. This chapter covers all the ways you can transform, compute, and control field visibility in your API.
4
+
5
+ ## Overview
6
+
7
+ Field transformations allow you to:
8
+ - Accept temporary data that isn't stored (virtual fields)
9
+ - Transform data before storing it (setters)
10
+ - Transform data when retrieving it (getters)
11
+ - Calculate values from other fields (computed fields)
12
+ - Control which fields are visible in responses (hidden fields)
13
+
14
+ ## The Data Transformation Pipeline
15
+
16
+ Understanding when each transformation occurs is crucial for building robust APIs:
17
+
18
+ ```
19
+ INPUT FLOW (POST/PUT/PATCH):
20
+ ┌─────────────┐ ┌──────────────────┐ ┌─────────┐ ┌──────────┐
21
+ │ User Input │ --> │ Validate Schema │ --> │ Setters │ --> │ Database │
22
+ │ (JSON) │ │ (including virt) │ │ │ │ (no virt)│
23
+ └─────────────┘ └──────────────────┘ └─────────┘ └──────────┘
24
+ ↓
25
+ Virtual fields validated
26
+ and preserved for response
27
+
28
+ OUTPUT FLOW (GET):
29
+ ┌──────────┐ ┌─────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────┐
30
+ │ Database │ --> │ Getters │ --> │ Computed │ --> │ Merge Virtual & │ --> │ Response │
31
+ │ │ │ │ │ Fields │ │ Apply Hidden │ │ (JSON) │
32
+ └──────────┘ └─────────┘ └──────────────┘ └─────────────────┘ └──────────┘
33
+ ```
34
+
35
+ ## Virtual Fields
36
+
37
+ Virtual fields are fields that pass through the API but are never stored in the database. They're perfect for temporary data needed during request processing.
38
+
39
+ ### Defining Virtual Fields
40
+
41
+ ```javascript
42
+ await api.addResource('users', {
43
+ schema: {
44
+ username: { type: 'string', required: true },
45
+ email: { type: 'string', required: true },
46
+ password: { type: 'string', required: true, hidden: true },
47
+
48
+ // Virtual fields - not stored in database
49
+ passwordConfirmation: { type: 'string', virtual: true },
50
+ termsAccepted: { type: 'boolean', virtual: true },
51
+ captchaToken: { type: 'string', virtual: true }
52
+ }
53
+ });
54
+ ```
55
+
56
+ ### Common Use Cases
57
+
58
+ 1. **Password Confirmation**
59
+ ```javascript
60
+ // Client sends:
61
+ {
62
+ "username": "john",
63
+ "password": "secret123",
64
+ "passwordConfirmation": "secret123" // Virtual field
65
+ }
66
+
67
+ // Use in a hook to validate:
68
+ api.on('beforeData:create:users', ({ inputRecord }) => {
69
+ const { password, passwordConfirmation } = inputRecord.data.attributes;
70
+ if (password !== passwordConfirmation) {
71
+ throw new Error('Passwords do not match');
72
+ }
73
+ });
74
+ ```
75
+
76
+ 2. **Terms Acceptance**
77
+ ```javascript
78
+ api.on('beforeData:create:users', ({ inputRecord }) => {
79
+ if (!inputRecord.data.attributes.termsAccepted) {
80
+ throw new Error('You must accept the terms of service');
81
+ }
82
+ });
83
+ ```
84
+
85
+ 3. **UI State or Metadata**
86
+ ```javascript
87
+ // Client can send UI state that's returned but not stored
88
+ {
89
+ "title": "My Article",
90
+ "content": "...",
91
+ "editorState": { ... }, // Virtual field with editor metadata
92
+ "isDraft": true // Virtual field for UI state
93
+ }
94
+ ```
95
+
96
+ ### Key Characteristics
97
+
98
+ - **Input**: Accepted in POST/PUT/PATCH requests
99
+ - **Validation**: Validated according to schema rules
100
+ - **Storage**: Never stored in the database
101
+ - **Output**: Returned in responses if provided
102
+ - **Hooks**: Available to all hooks during request processing
103
+
104
+ ## Setters and Getters
105
+
106
+ Setters and getters transform data at the database boundary. Setters run before saving, getters run after loading.
107
+
108
+ ### Setters - Transform Before Storage
109
+
110
+ ```javascript
111
+ await api.addResource('users', {
112
+ schema: {
113
+ email: {
114
+ type: 'string',
115
+ required: true,
116
+ setter: (value) => value.toLowerCase().trim()
117
+ },
118
+ phone: {
119
+ type: 'string',
120
+ setter: (value) => {
121
+ // Remove all non-digits
122
+ return value ? value.replace(/\D/g, '') : null;
123
+ }
124
+ },
125
+ metadata: {
126
+ type: 'object',
127
+ setter: (value) => JSON.stringify(value || {})
128
+ }
129
+ }
130
+ });
131
+ ```
132
+
133
+ ### Getters - Transform After Retrieval
134
+
135
+ ```javascript
136
+ await api.addResource('users', {
137
+ schema: {
138
+ email: {
139
+ type: 'string',
140
+ getter: (value) => value?.toLowerCase()
141
+ },
142
+ phone: {
143
+ type: 'string',
144
+ getter: (value) => {
145
+ // Format as (XXX) XXX-XXXX
146
+ if (!value || value.length !== 10) return value;
147
+ return `(${value.slice(0,3)}) ${value.slice(3,6)}-${value.slice(6)}`;
148
+ }
149
+ },
150
+ metadata: {
151
+ type: 'string',
152
+ getter: (value) => {
153
+ try {
154
+ return value ? JSON.parse(value) : {};
155
+ } catch {
156
+ return {};
157
+ }
158
+ }
159
+ }
160
+ }
161
+ });
162
+ ```
163
+
164
+ ### Using Virtual Fields with Setters
165
+
166
+ A common pattern is using virtual fields to provide data that setters will process:
167
+
168
+ ```javascript
169
+ await api.addResource('products', {
170
+ schema: {
171
+ price: {
172
+ type: 'number',
173
+ setter: function(value, { attributes }) {
174
+ // Use virtual priceInCents if provided
175
+ if (attributes.priceInCents !== undefined) {
176
+ return attributes.priceInCents / 100;
177
+ }
178
+ return value;
179
+ }
180
+ },
181
+ priceInCents: { type: 'number', virtual: true }
182
+ }
183
+ });
184
+
185
+ // Client can send either format:
186
+ // { "price": 19.99 }
187
+ // OR
188
+ // { "priceInCents": 1999 }
189
+ ```
190
+
191
+ ### Async Setters and Getters
192
+
193
+ Both setters and getters can be async:
194
+
195
+ ```javascript
196
+ await api.addResource('secure_data', {
197
+ schema: {
198
+ secret: {
199
+ type: 'string',
200
+ setter: async (value) => {
201
+ // Encrypt before storing
202
+ const encrypted = await encrypt(value);
203
+ return encrypted;
204
+ },
205
+ getter: async (value) => {
206
+ // Decrypt after retrieving
207
+ const decrypted = await decrypt(value);
208
+ return decrypted;
209
+ }
210
+ }
211
+ }
212
+ });
213
+ ```
214
+
215
+ ### Setter and Getter Context
216
+
217
+ Both functions receive a context object as the second parameter:
218
+
219
+ ```javascript
220
+ setter: (value, context) => {
221
+ // context contains:
222
+ // - attributes: all field values
223
+ // - record: the full record (on updates)
224
+ // - scopeName: resource name
225
+ // - method: 'post', 'put', or 'patch'
226
+
227
+ if (context.method === 'post') {
228
+ // Special handling for creation
229
+ }
230
+ return value;
231
+ }
232
+ ```
233
+
234
+ ## Computed Fields
235
+
236
+ Computed fields are output-only fields calculated from other fields. They're never stored and always calculated fresh when requested.
237
+
238
+ ### Basic Computed Fields
239
+
240
+ ```javascript
241
+ await api.addResource('products', {
242
+ schema: {
243
+ name: { type: 'string', required: true },
244
+ price: { type: 'number', required: true },
245
+ cost: { type: 'number', required: true, normallyHidden: true },
246
+
247
+ // Computed fields
248
+ profitMargin: {
249
+ type: 'number',
250
+ computed: true,
251
+ dependencies: ['price', 'cost'],
252
+ compute: ({ attributes }) => {
253
+ if (!attributes.price || !attributes.cost) return null;
254
+ return ((attributes.price - attributes.cost) / attributes.price * 100).toFixed(2);
255
+ }
256
+ },
257
+
258
+ displayName: {
259
+ type: 'string',
260
+ computed: true,
261
+ dependencies: ['name', 'price'],
262
+ compute: ({ attributes }) => {
263
+ return `${attributes.name} - $${attributes.price}`;
264
+ }
265
+ }
266
+ }
267
+ });
268
+ ```
269
+
270
+ ### Dependencies and Hidden Fields
271
+
272
+ Computed fields can depend on hidden fields:
273
+
274
+ ```javascript
275
+ await api.addResource('users', {
276
+ schema: {
277
+ firstName: { type: 'string' },
278
+ lastName: { type: 'string' },
279
+ dateOfBirth: { type: 'date', normallyHidden: true },
280
+
281
+ fullName: {
282
+ type: 'string',
283
+ computed: true,
284
+ dependencies: ['firstName', 'lastName'],
285
+ compute: ({ attributes }) => {
286
+ return [attributes.firstName, attributes.lastName]
287
+ .filter(Boolean)
288
+ .join(' ');
289
+ }
290
+ },
291
+
292
+ age: {
293
+ type: 'number',
294
+ computed: true,
295
+ dependencies: ['dateOfBirth'], // Depends on hidden field
296
+ compute: ({ attributes }) => {
297
+ if (!attributes.dateOfBirth) return null;
298
+ const birth = new Date(attributes.dateOfBirth);
299
+ const today = new Date();
300
+ let age = today.getFullYear() - birth.getFullYear();
301
+ const monthDiff = today.getMonth() - birth.getMonth();
302
+ if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
303
+ age--;
304
+ }
305
+ return age;
306
+ }
307
+ }
308
+ }
309
+ });
310
+ ```
311
+
312
+ ### Async Computed Fields
313
+
314
+ ```javascript
315
+ await api.addResource('products', {
316
+ schema: {
317
+ id: { type: 'id' },
318
+ name: { type: 'string' },
319
+
320
+ inventoryStatus: {
321
+ type: 'string',
322
+ computed: true,
323
+ dependencies: ['id'],
324
+ compute: async ({ attributes, context }) => {
325
+ // Could check external inventory system
326
+ const count = await context.knex('inventory')
327
+ .where('product_id', attributes.id)
328
+ .sum('quantity as total')
329
+ .first();
330
+
331
+ if (count.total > 100) return 'In Stock';
332
+ if (count.total > 0) return 'Low Stock';
333
+ return 'Out of Stock';
334
+ }
335
+ }
336
+ }
337
+ });
338
+ ```
339
+
340
+ ### Compute Function Context
341
+
342
+ The compute function receives a rich context:
343
+
344
+ ```javascript
345
+ compute: (context) => {
346
+ // context contains:
347
+ // - attributes: all record attributes (including dependencies)
348
+ // - record: full record with id
349
+ // - context: request context with knex, transaction, etc.
350
+ // - helpers: API helpers
351
+ // - api: full API instance
352
+ // - scopeName: current resource name
353
+ }
354
+ ```
355
+
356
+ ### Important Notes on Computed Fields
357
+
358
+ 1. **Output Only**: If a computed field is sent in input, it's ignored with a warning
359
+ 2. **Always Fresh**: Calculated on every request (no caching)
360
+ 3. **Dependencies**: The system automatically fetches dependency fields from the database
361
+ 4. **Sparse Fieldsets**: Work seamlessly with JSON:API sparse fieldsets
362
+ 5. **Performance**: Keep computations fast as they run on every request
363
+
364
+ ## Hidden Fields
365
+
366
+ Control which fields are visible in API responses:
367
+
368
+ ### Hidden Fields - Never Visible
369
+
370
+ ```javascript
371
+ await api.addResource('users', {
372
+ schema: {
373
+ email: { type: 'string', required: true },
374
+ passwordHash: { type: 'string', hidden: true },
375
+ salt: { type: 'string', hidden: true },
376
+ internalNotes: { type: 'string', hidden: true }
377
+ }
378
+ });
379
+
380
+ // These fields are NEVER returned in responses, even if explicitly requested
381
+ ```
382
+
383
+ ### Normally Hidden Fields - Available on Request
384
+
385
+ ```javascript
386
+ await api.addResource('products', {
387
+ schema: {
388
+ name: { type: 'string', required: true },
389
+ price: { type: 'number', required: true },
390
+ cost: { type: 'number', normallyHidden: true },
391
+ supplierCode: { type: 'string', normallyHidden: true }
392
+ }
393
+ });
394
+
395
+ // Hidden by default
396
+ GET /products/1
397
+ // Returns: { id: '1', name: 'Widget', price: 99.99 }
398
+
399
+ // Explicitly request hidden fields
400
+ GET /products/1?fields[products]=name,price,cost
401
+ // Returns: { id: '1', name: 'Widget', price: 99.99, cost: 45.00 }
402
+ ```
403
+
404
+ ## Advanced Transformations with Hooks
405
+
406
+ For complex transformations that depend on context (user permissions, time of day, etc.), use the `enrichAttributes` hook:
407
+
408
+ ```javascript
409
+ // Add permission-based field visibility
410
+ api.on('enrichAttributes', ({ attributes, context }) => {
411
+ if (context.user?.role !== 'admin') {
412
+ delete attributes.profitMargin;
413
+ delete attributes.cost;
414
+ }
415
+ return attributes;
416
+ });
417
+
418
+ // Add dynamic computed fields
419
+ api.on('enrichAttributes', ({ attributes, context }) => {
420
+ if (context.scopeName === 'products') {
421
+ attributes.isOnSale = attributes.price < attributes.regularPrice;
422
+ }
423
+ return attributes;
424
+ });
425
+ ```
426
+
427
+ For more details on hooks, see the [Hooks Documentation](./GUIDE_3_Hooks.md).
428
+
429
+ ## Complete Example: E-commerce Product
430
+
431
+ Here's a complete example showing all transformation types working together:
432
+
433
+ ```javascript
434
+ await api.addResource('products', {
435
+ schema: {
436
+ // Regular fields
437
+ sku: { type: 'string', required: true },
438
+ name: { type: 'string', required: true },
439
+ description: { type: 'string' },
440
+
441
+ // Price with setter for cents conversion
442
+ price: {
443
+ type: 'number',
444
+ required: true,
445
+ setter: function(value, { attributes }) {
446
+ // Accept price in cents via virtual field
447
+ if (attributes.priceInCents !== undefined) {
448
+ return attributes.priceInCents / 100;
449
+ }
450
+ return value;
451
+ },
452
+ getter: (value) => Number(value.toFixed(2))
453
+ },
454
+
455
+ // Hidden cost field
456
+ cost: {
457
+ type: 'number',
458
+ required: true,
459
+ normallyHidden: true
460
+ },
461
+
462
+ // Never visible
463
+ supplierApiKey: {
464
+ type: 'string',
465
+ hidden: true
466
+ },
467
+
468
+ // Virtual fields for input
469
+ priceInCents: { type: 'number', virtual: true },
470
+ importFromSupplier: { type: 'boolean', virtual: true },
471
+
472
+ // Computed fields for output
473
+ profitMargin: {
474
+ type: 'number',
475
+ computed: true,
476
+ dependencies: ['price', 'cost'],
477
+ compute: ({ attributes }) => {
478
+ if (!attributes.price || !attributes.cost) return null;
479
+ return ((attributes.price - attributes.cost) / attributes.price * 100).toFixed(2);
480
+ }
481
+ },
482
+
483
+ displayPrice: {
484
+ type: 'string',
485
+ computed: true,
486
+ dependencies: ['price'],
487
+ compute: ({ attributes }) => {
488
+ return `$${attributes.price.toFixed(2)}`;
489
+ }
490
+ }
491
+ }
492
+ });
493
+
494
+ // Usage example:
495
+ // POST /products
496
+ {
497
+ "data": {
498
+ "type": "products",
499
+ "attributes": {
500
+ "sku": "WIDGET-001",
501
+ "name": "Super Widget",
502
+ "description": "The best widget",
503
+ "priceInCents": 9999, // Virtual field (converted to 99.99)
504
+ "cost": 45.00,
505
+ "importFromSupplier": true, // Virtual field (triggers hook)
506
+ "supplierApiKey": "secret" // Hidden field (stored but never returned)
507
+ }
508
+ }
509
+ }
510
+
511
+ // Hook to handle virtual field
512
+ api.on('beforeData:create:products', async ({ inputRecord, context }) => {
513
+ if (inputRecord.data.attributes.importFromSupplier) {
514
+ // Use the hidden supplierApiKey to fetch data
515
+ const data = await fetchFromSupplier(inputRecord.data.attributes.supplierApiKey);
516
+ inputRecord.data.attributes.description = data.description;
517
+ }
518
+ });
519
+
520
+ // Response:
521
+ {
522
+ "data": {
523
+ "type": "products",
524
+ "id": "1",
525
+ "attributes": {
526
+ "sku": "WIDGET-001",
527
+ "name": "Super Widget",
528
+ "description": "The best widget",
529
+ "price": 99.99, // Setter converted from cents
530
+ "displayPrice": "$99.99", // Computed field
531
+ "profitMargin": "54.95", // Computed field (if user has permission)
532
+ "importFromSupplier": true // Virtual field preserved
533
+ // Note: cost, supplierApiKey not included
534
+ }
535
+ }
536
+ }
537
+ ```
538
+
539
+ ## Best Practices
540
+
541
+ ### When to Use Each Transformation Type
542
+
543
+ | Need | Use | Example |
544
+ |------|-----|---------|
545
+ | Temporary request data | Virtual field | Password confirmation |
546
+ | Clean input data | Setter | Lowercase emails, trim whitespace |
547
+ | Format output data | Getter | Format phone numbers, parse JSON |
548
+ | Calculate from other fields | Computed field | Full names, totals, percentages |
549
+ | Security-sensitive data | Hidden field | Password hashes, API keys |
550
+ | Sensitive business data | Normally hidden field | Costs, internal notes |
551
+ | Context-aware transforms | enrichAttributes hook | Permission-based visibility |
552
+
553
+ ### Performance Considerations
554
+
555
+ 1. **Setters/Getters**: Keep them fast and synchronous when possible
556
+ 2. **Computed Fields**:
557
+ - Calculated on every request (no caching)
558
+ - Dependencies are always fetched from DB
559
+ - Consider storing frequently accessed computed values
560
+ 3. **Virtual Fields**: No performance impact (not stored/retrieved)
561
+ 4. **Hidden Fields**: Filtered after retrieval (minimal impact)
562
+
563
+ ### Common Pitfalls to Avoid
564
+
565
+ 1. **Don't use computed fields for heavy calculations** - Consider background jobs instead
566
+ 2. **Don't put validation logic in setters** - Use schema validation or hooks
567
+ 3. **Remember computed fields are output-only** - They're ignored in input
568
+ 4. **Test edge cases** - Null values, missing dependencies, etc.
569
+ 5. **Document virtual fields** - They're part of your API contract
570
+
571
+ ## Migration Tips
572
+
573
+ If you're migrating from an older version:
574
+ - Computed fields now use `computed: true` in the schema (not a separate object)
575
+ - Virtual fields use `virtual: true` in the schema
576
+ - All field transformations are defined in one place: the schema
577
+
578
+ ## Summary
579
+
580
+ The JSON REST API library provides a complete transformation pipeline:
581
+ - **Virtual fields** for temporary data that flows through but isn't stored
582
+ - **Setters** for cleaning and transforming input before storage
583
+ - **Getters** for formatting and transforming output after retrieval
584
+ - **Computed fields** for deriving values from other fields
585
+ - **Hidden fields** for controlling visibility
586
+ - **Hooks** for advanced context-aware transformations
587
+
588
+ By combining these tools, you can build APIs that accept user-friendly input, store data efficiently, and return perfectly formatted responses.
589
+
590
+ ---
591
+
592
+ # Detailed Guide: Computed Fields
593
+
594
+ Computed fields are virtual fields that don't exist in your database but are calculated on-the-fly from other fields. They're computed after the database load, every time they're requested, and can depend on other fields (including hidden ones). Computed fields work seamlessly with sparse fieldsets and are calculated for both main resources and included resources.
595
+
596
+ Let's create a complete example with products and reviews:
597
+
598
+ ```javascript
599
+ // Define products resource with computed fields
600
+ await api.addResource('products', {
601
+ schema: {
602
+ name: { type: 'string', required: true, max: 255 },
603
+ price: { type: 'number', required: true, min: 0 },
604
+ cost: { type: 'number', required: true, min: 0, normallyHidden: true },
605
+ profit_margin: {
606
+ type: 'number',
607
+ computed: true, // Mark as computed field
608
+ dependencies: ['price', 'cost'],
609
+ compute: ({ attributes }) => {
610
+ if (!attributes.price || attributes.price === 0) return 0;
611
+ return ((attributes.price - attributes.cost) / attributes.price * 100).toFixed(2);
612
+ }
613
+ },
614
+ // Computed fields can also be async
615
+ availability_status: {
616
+ type: 'string',
617
+ computed: true, // Mark as computed field
618
+ dependencies: ['name'],
619
+ compute: async ({ attributes }) => {
620
+ // Simulate async operation (e.g., checking external inventory)
621
+ await new Promise(resolve => setTimeout(resolve, 10));
622
+ return `${attributes.name} - In Stock`;
623
+ }
624
+ }
625
+ },
626
+ relationships: {
627
+ reviews: { hasMany: 'reviews', foreignKey: 'product_id' }
628
+ }
629
+ });
630
+ await api.resources.products.createKnexTable();
631
+
632
+ // Define reviews resource with computed fields
633
+ await api.addResource('reviews', {
634
+ schema: {
635
+ product_id: { type: 'id', belongsTo: 'products', as: 'product', required: true },
636
+ reviewer_name: { type: 'string', required: true },
637
+ rating: { type: 'number', required: true, min: 1, max: 5 },
638
+ comment: { type: 'string', max: 1000 },
639
+ helpful_votes: { type: 'number', default: 0 },
640
+ total_votes: { type: 'number', default: 0 },
641
+ helpfulness_score: {
642
+ type: 'number',
643
+ computed: true, // Mark as computed field
644
+ dependencies: ['helpful_votes', 'total_votes'],
645
+ compute: ({ attributes }) => {
646
+ if (attributes.total_votes === 0) return null;
647
+ return ((attributes.helpful_votes / attributes.total_votes) * 100).toFixed(0);
648
+ }
649
+ }
650
+ }
651
+ });
652
+ await api.resources.reviews.createKnexTable();
653
+ ```
654
+
655
+ The key features to note:
656
+ - `cost` is marked as `normallyHidden` - it won't be returned unless explicitly requested
657
+ - `profit_margin` depends on both `price` and `cost`
658
+ - `helpfulness_score` is computed for each review
659
+
660
+ ## Basic Usage
661
+
662
+ Let's create some data and see how computed fields work:
663
+
664
+ ```javascript
665
+ // Create a product
666
+ const product = await api.resources.products.post({
667
+ name: 'Premium Headphones',
668
+ price: 199.99,
669
+ cost: 89.50
670
+ });
671
+
672
+ // Fetch the product - computed fields are automatically calculated
673
+ const fetchedProduct = await api.resources.products.get({ id: product.id });
674
+ console.log(fetchedProduct);
675
+ // {
676
+ // id: '1',
677
+ // name: 'Premium Headphones',
678
+ // price: 199.99,
679
+ // profit_margin: '55.23', // Computed: (199.99 - 89.50) / 199.99 * 100
680
+ // availability_status: 'Premium Headphones - In Stock' // Async computed field
681
+ // }
682
+ // Note: 'cost' is not included (normallyHidden)
683
+
684
+ // Add some reviews
685
+ const review1 = await api.resources.reviews.post({
686
+ product_id: product.id,
687
+ reviewer_name: 'Alice',
688
+ rating: 5,
689
+ comment: 'Excellent sound quality!',
690
+ helpful_votes: 45,
691
+ total_votes: 50
692
+ });
693
+ console.log(review1);
694
+
695
+ const review2 = await api.resources.reviews.post({
696
+ product_id: product.id,
697
+ reviewer_name: 'Bob',
698
+ rating: 4,
699
+ comment: 'Good, but a bit pricey',
700
+ helpful_votes: 10,
701
+ total_votes: 25
702
+ });
703
+ console.log(review2);
704
+
705
+
706
+ ```
707
+
708
+ ## Sparse Fieldsets and Dependencies
709
+
710
+ When you request a computed field via sparse fieldsets, the system automatically fetches its dependencies:
711
+
712
+ ```javascript
713
+ // Request only name and profit_margin
714
+ const sparseProduct = await api.resources.products.get({
715
+ id: product.id,
716
+ queryParams: {
717
+ fields: { products: 'name,profit_margin' }
718
+ }
719
+ });
720
+ console.log('Product with sparse fields:', sparseProduct);
721
+
722
+ const productWithCost = await api.resources.products.get({
723
+ id: product.id,
724
+ queryParams: {
725
+ fields: { products: 'name,cost,profit_margin' }
726
+ }
727
+ });
728
+ console.log('Product with sparse fields includig cost:', productWithCost);
729
+ ```
730
+
731
+ **Expected output**:
732
+
733
+ ```text
734
+ Product with sparse fields: {
735
+ id: '1',
736
+ name: 'Premium Headphones',
737
+ profit_margin: '55.25',
738
+ availability_status: 'Premium Headphones - In Stock',
739
+ reviews_ids: [ '1', '2' ]
740
+ }
741
+ Product with sparse fields includig cost: {
742
+ id: '1',
743
+ name: 'Premium Headphones',
744
+ cost: 89.5,
745
+ profit_margin: '55.25',
746
+ reviews_ids: [ '1', '2' ]
747
+ }
748
+ ```
749
+
750
+ ## Computed Fields in Included Resources
751
+
752
+ Computed fields work seamlessly with included resources:
753
+
754
+ ```javascript
755
+ // Fetch product with reviews
756
+ const productWithReviews = await api.resources.products.get({
757
+ id: product.id,
758
+ queryParams: {
759
+ include: ['reviews']
760
+ }
761
+ });
762
+ console.log('Product With Reviews:', productWithReviews);
763
+
764
+ // Use sparse fieldsets on included resources
765
+ const productWithSparseReviews = await api.resources.products.get({
766
+ id: product.id,
767
+ queryParams: {
768
+ include: ['reviews'],
769
+ fields: {
770
+ products: 'name,price',
771
+ reviews: 'reviewer_name,rating,helpfulness_score' // Only these fields
772
+ }
773
+ }
774
+ });
775
+ console.log('Product With Sparse Reviews:', productWithSparseReviews);
776
+ ```
777
+
778
+ **Expected Output**:
779
+
780
+ ```text
781
+ Product With Reviews: {
782
+ id: '1',
783
+ name: 'Premium Headphones',
784
+ price: 199.99,
785
+ profit_margin: '55.25',
786
+ availability_status: 'Premium Headphones - In Stock',
787
+ reviews_ids: [ '1', '2' ],
788
+ reviews: [
789
+ {
790
+ id: '1',
791
+ reviewer_name: 'Alice',
792
+ rating: 5,
793
+ comment: 'Excellent sound quality!',
794
+ helpful_votes: 45,
795
+ total_votes: 50,
796
+ helpfulness_score: '90'
797
+ },
798
+ {
799
+ id: '2',
800
+ reviewer_name: 'Bob',
801
+ rating: 4,
802
+ comment: 'Good, but a bit pricey',
803
+ helpful_votes: 10,
804
+ total_votes: 25,
805
+ helpfulness_score: '40'
806
+ }
807
+ ]
808
+ }
809
+ Product With Sparse Reviews: {
810
+ id: '1',
811
+ name: 'Premium Headphones',
812
+ price: 199.99,
813
+ reviews_ids: [ '1', '2' ],
814
+ reviews: [
815
+ {
816
+ id: '1',
817
+ reviewer_name: 'Alice',
818
+ rating: 5,
819
+ helpfulness_score: '90'
820
+ },
821
+ {
822
+ id: '2',
823
+ reviewer_name: 'Bob',
824
+ rating: 4,
825
+ helpfulness_score: '40'
826
+ }
827
+ ]
828
+ }
829
+ ```
830
+
831
+ ## Error Handling
832
+
833
+ Computed fields handle errors gracefully:
834
+
835
+ ```javascript
836
+
837
+ // Create a review with no votes
838
+ const review3 = await api.resources.reviews.post({
839
+ product_id: product.id,
840
+ reviewer_name: 'Charlie',
841
+ rating: 3,
842
+ comment: 'Average product',
843
+ helpful_votes: 0,
844
+ total_votes: 0 // This will cause division by zero
845
+ });
846
+
847
+ const fetchedReviewWithError = await api.resources.reviews.get({ id: review3.id });
848
+ console.log('Fetched review (with error in helpfulness score):', fetchedReviewWithError
849
+ ```
850
+
851
+ **Expected Output**:
852
+
853
+ ```text
854
+ Fetched review (with error in helpfulness score): {
855
+ id: '3',
856
+ reviewer_name: 'Charlie',
857
+ rating: 3,
858
+ comment: 'Average product',
859
+ helpful_votes: 0,
860
+ total_votes: 0,
861
+ helpfulness_score: null,
862
+ product_id: '1'
863
+ }
864
+ ```
865
+
866
+ ## Key Points
867
+
868
+ 1. **Always Computed** - Computed fields are calculated fresh on every request, there's no caching.
869
+
870
+ 2. **Dependencies Are Fetched** - When you request a computed field, all its dependencies are automatically fetched from the database, even if they won't appear in the response.
871
+
872
+ 3. **Works with Sparse Fieldsets** - You can request computed fields just like regular fields using sparse fieldsets.
873
+
874
+ 4. **Hidden Dependencies** - Fields marked as `normallyHidden` can be used as dependencies and will be fetched for computation, but won't appear in the response unless explicitly requested.
875
+
876
+ 5. **Included Resources** - Computed fields are calculated for all resources, whether they're the main resource or included via relationships.
877
+
878
+ 6. **Error Handling** - If a computation fails, the field is set to `null` and an error is logged, but the request continues.
879
+
880
+ ## Async Computed Fields
881
+
882
+ Computed fields can be asynchronous - simply return a Promise or use async/await. The compute function will be awaited during field resolution:
883
+
884
+ ```javascript
885
+ schema: {
886
+ name: { type: 'string', required: true },
887
+ availability_status: {
888
+ type: 'string',
889
+ computed: true, // Mark as computed field
890
+ dependencies: ['name'],
891
+ compute: async ({ attributes }) => {
892
+ // Perform async operation
893
+ await new Promise(resolve => setTimeout(resolve, 10));
894
+ return `${attributes.name} - In Stock`;
895
+ }
896
+ }
897
+ }
898
+ ```
899
+
900
+ ## Best Practices
901
+
902
+ 1. **Keep Computations Simple** - Computed fields should be quick calculations. While async is supported, avoid heavy operations like database queries or external API calls.
903
+
904
+ 2. **Declare All Dependencies** - Always list all fields your computation needs in the `dependencies` array.
905
+
906
+ 3. **Handle Edge Cases** - Check for null values and division by zero in your compute functions.
907
+
908
+ 4. **Consider Performance** - Remember that dependencies are always fetched. If you have expensive computations or many dependencies, consider storing the computed value as a regular field instead.
909
+
910
+ ---
911
+
912
+ # Detailed Guide: Getters and Setters
913
+
914
+ ## Introduction
915
+
916
+ Field getters and setters allow you to transform data as it moves between your API and database:
917
+
918
+ - **Getters**: Transform data when reading from the database (e.g., formatting phone numbers, trimming strings)
919
+ - **Setters**: Transform data before writing to the database (e.g., normalizing emails, hashing passwords)
920
+
921
+ This is different from computed fields, which are virtual fields calculated on-the-fly. Getters and setters work with actual database columns.
922
+
923
+ ## Initial Setup: A Blog System
924
+
925
+ Let's start with a simple blog system without any getters or setters:
926
+
927
+ ```javascript
928
+ import { Api } from 'hooked-api';
929
+ import RestApiPlugin from 'json-rest-api/plugins/core/rest-api-plugin.js';
930
+ import RestApiKnexPlugin from 'json-rest-api/plugins/core/rest-api-knex-plugin.js';
931
+ import knex from 'knex';
932
+
933
+ // Create database connection
934
+ const db = knex({
935
+ client: 'better-sqlite3',
936
+ connection: { filename: './blog.db' }
937
+ });
938
+
939
+ // Create API instance
940
+ const api = new Api({ name: 'blog-api', version: '1.0.0' });
941
+
942
+ // Install plugins
943
+ await api.use(RestApiPlugin);
944
+ await api.use(RestApiKnexPlugin, { knex: db });
945
+
946
+ // Define authors resource (no getters/setters yet)
947
+ await api.addResource('authors', {
948
+ schema: {
949
+ id: { type: 'id' },
950
+ email: { type: 'string', required: true },
951
+ name: { type: 'string', required: true },
952
+ bio: { type: 'string', nullable: true }
953
+ },
954
+ relationships: {
955
+ posts: { hasMany: 'posts', foreignKey: 'author_id' }
956
+ },
957
+ tableName: 'authors'
958
+ });
959
+
960
+ // Define posts resource (no getters/setters yet)
961
+ await api.addResource('posts', {
962
+ schema: {
963
+ id: { type: 'id' },
964
+ title: { type: 'string', required: true },
965
+ content: { type: 'string', required: true },
966
+ author_id: { type: 'number', belongsTo: 'authors', as: 'author' },
967
+ published_at: { type: 'dateTime', default: 'now()' }
968
+ },
969
+ tableName: 'posts'
970
+ });
971
+
972
+ // Create tables
973
+ await api.resources.authors.createKnexTable();
974
+ await api.resources.posts.createKnexTable();
975
+
976
+ // Create test data
977
+ const author = await api.resources.authors.post({
978
+ email: ' Jane.Doe@BLOG.COM ',
979
+ name: ' Jane Doe ',
980
+ bio: ' Software developer and writer '
981
+ });
982
+
983
+ const post1 = await api.resources.posts.post({
984
+ title: ' Getting Started with APIs ',
985
+ content: ' This is my first post about APIs... ',
986
+ author_id: author.id
987
+ });
988
+
989
+ const post2 = await api.resources.posts.post({
990
+ title: ' advanced api patterns ',
991
+ content: ' Let\'s explore some advanced patterns... ',
992
+ author_id: author.id
993
+ });
994
+
995
+ // Fetch author with posts
996
+ const authorWithPosts = await api.resources.authors.get({
997
+ id: author.id,
998
+ queryParams: { include: ['posts'] }
999
+ });
1000
+
1001
+ console.log('Author with posts (no getters):', authorWithPosts);
1002
+ // Notice the messy data:
1003
+ // - Email has spaces and mixed case
1004
+ // - Name and bio have extra spaces
1005
+ // - Post titles are inconsistently cased
1006
+ // - Content has leading/trailing spaces
1007
+ ```
1008
+
1009
+ ## Adding Getters: Transform Data on Read
1010
+
1011
+ Now let's add getters to clean up the data automatically:
1012
+
1013
+ ```javascript
1014
+ // Enhanced authors resource with getters
1015
+ await api.addResource('authors', {
1016
+ schema: {
1017
+ id: { type: 'id' },
1018
+ email: {
1019
+ type: 'string',
1020
+ required: true,
1021
+ getter: (value) => value?.toLowerCase().trim()
1022
+ },
1023
+ name: {
1024
+ type: 'string',
1025
+ required: true,
1026
+ getter: (value) => value?.trim()
1027
+ },
1028
+ bio: {
1029
+ type: 'string',
1030
+ nullable: true,
1031
+ getter: (value) => value?.trim()
1032
+ }
1033
+ },
1034
+ relationships: {
1035
+ posts: { hasMany: 'posts', foreignKey: 'author_id' }
1036
+ },
1037
+ tableName: 'authors'
1038
+ });
1039
+
1040
+ // Enhanced posts resource with getters
1041
+ await api.addResource('posts', {
1042
+ schema: {
1043
+ id: { type: 'id' },
1044
+ title: {
1045
+ type: 'string',
1046
+ required: true,
1047
+ getter: (value) => {
1048
+ // Capitalize first letter of each word
1049
+ return value?.trim()
1050
+ .split(' ')
1051
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
1052
+ .join(' ');
1053
+ }
1054
+ },
1055
+ content: {
1056
+ type: 'string',
1057
+ required: true,
1058
+ getter: (value) => value?.trim()
1059
+ },
1060
+ author_id: { type: 'number', belongsTo: 'authors', as: 'author' },
1061
+ published_at: { type: 'dateTime', default: 'now()' }
1062
+ },
1063
+ tableName: 'posts'
1064
+ });
1065
+
1066
+ // Now fetch the same author with posts
1067
+ const cleanAuthorWithPosts = await api.resources.authors.get({
1068
+ id: author.id,
1069
+ queryParams: { include: ['posts'] }
1070
+ });
1071
+
1072
+ console.log('Author:', cleanAuthorWithPosts);
1073
+ // {
1074
+ // id: '1',
1075
+ // email: 'jane.doe@blog.com', // Normalized
1076
+ // name: 'Jane Doe', // Trimmed
1077
+ // bio: 'Software developer and writer', // Trimmed
1078
+ // posts: [
1079
+ // {
1080
+ // id: '1',
1081
+ // title: 'Getting Started With Apis', // Title case
1082
+ // content: 'This is my first post about APIs...', // Trimmed
1083
+ // author_id: 1,
1084
+ // published_at: '2024-01-15T10:30:00.000Z'
1085
+ // },
1086
+ // {
1087
+ // id: '2',
1088
+ // title: 'Advanced Api Patterns', // Title case
1089
+ // content: 'Let\'s explore some advanced patterns...', // Trimmed
1090
+ // author_id: 1,
1091
+ // published_at: '2024-01-15T10:31:00.000Z'
1092
+ // }
1093
+ // ]
1094
+ // }
1095
+
1096
+ // Getters also work in queries
1097
+ const allPosts = await api.resources.posts.query({
1098
+ queryParams: {
1099
+ include: ['author'],
1100
+ filters: { author_id: author.id }
1101
+ }
1102
+ });
1103
+
1104
+ console.log('All posts with author:', allPosts);
1105
+ // Both posts and included authors have getters applied
1106
+ ```
1107
+
1108
+ ## Adding Setters: Transform Data on Write
1109
+
1110
+ Setters ensure data is normalized before it's stored:
1111
+
1112
+ ```javascript
1113
+ // Add setters to authors
1114
+ await api.addResource('authors', {
1115
+ schema: {
1116
+ id: { type: 'id' },
1117
+ email: {
1118
+ type: 'string',
1119
+ required: true,
1120
+ setter: (value) => value?.toLowerCase().trim(),
1121
+ getter: (value) => value
1122
+ },
1123
+ name: {
1124
+ type: 'string',
1125
+ required: true,
1126
+ setter: (value) => value?.trim(),
1127
+ getter: (value) => value
1128
+ },
1129
+ bio: {
1130
+ type: 'string',
1131
+ nullable: true,
1132
+ setter: (value) => value?.trim(),
1133
+ getter: (value) => value
1134
+ }
1135
+ },
1136
+ relationships: {
1137
+ posts: { hasMany: 'posts', foreignKey: 'author_id' }
1138
+ },
1139
+ tableName: 'authors'
1140
+ });
1141
+
1142
+ // Now when we create an author, data is cleaned before storage
1143
+ const newAuthor = await api.resources.authors.post({
1144
+ email: ' JOHN.SMITH@BLOG.COM ',
1145
+ name: ' John Smith ',
1146
+ bio: ' Tech enthusiast '
1147
+ });
1148
+
1149
+ console.log('New author:', newAuthor);
1150
+ // Data is already clean:
1151
+ // {
1152
+ // id: '2',
1153
+ // email: 'john.smith@blog.com',
1154
+ // name: 'John Smith',
1155
+ // bio: 'Tech enthusiast'
1156
+ // }
1157
+ ```
1158
+
1159
+ ## Async Setters for Secure Data
1160
+
1161
+ Use async setters for operations like password hashing:
1162
+
1163
+ ```javascript
1164
+ await api.addResource('users', {
1165
+ schema: {
1166
+ id: { type: 'id' },
1167
+ email: {
1168
+ type: 'string',
1169
+ required: true,
1170
+ setter: (value) => value?.toLowerCase().trim()
1171
+ },
1172
+ password: {
1173
+ type: 'string',
1174
+ required: true,
1175
+ min: 8,
1176
+ setter: async (value) => {
1177
+ // Simulate password hashing
1178
+ await new Promise(resolve => setTimeout(resolve, 10));
1179
+ return `hashed:${value}`;
1180
+ },
1181
+ getter: () => '[PROTECTED]' // Never expose hashed passwords
1182
+ }
1183
+ },
1184
+ tableName: 'users'
1185
+ });
1186
+
1187
+ const user = await api.resources.users.post({
1188
+ email: ' USER@EXAMPLE.COM ',
1189
+ password: 'mySecretPassword123'
1190
+ });
1191
+
1192
+ console.log('Created user:', user);
1193
+ // {
1194
+ // id: '1',
1195
+ // email: 'user@example.com',
1196
+ // password: '[PROTECTED]'
1197
+ // }
1198
+ ```
1199
+
1200
+ ## Setter Dependencies
1201
+
1202
+ When setters depend on other fields, use `runSetterAfter`:
1203
+
1204
+ ```javascript
1205
+ await api.addResource('products', {
1206
+ schema: {
1207
+ id: { type: 'id' },
1208
+ name: { type: 'string', required: true },
1209
+ base_price: {
1210
+ type: 'number',
1211
+ setter: (value) => Math.round(value * 100) // Convert to cents
1212
+ },
1213
+ tax_rate: {
1214
+ type: 'number',
1215
+ setter: (value) => value || 0
1216
+ },
1217
+ total_price: {
1218
+ type: 'number',
1219
+ setter: (value, { attributes }) => {
1220
+ // Calculate from base_price (already in cents) and tax_rate
1221
+ const total = attributes.base_price * (1 + attributes.tax_rate);
1222
+ return Math.round(total);
1223
+ },
1224
+ runSetterAfter: ['base_price', 'tax_rate']
1225
+ }
1226
+ },
1227
+ tableName: 'products'
1228
+ });
1229
+ ```
1230
+
1231
+ ## Summary
1232
+
1233
+ Getters and setters provide automatic data transformation:
1234
+
1235
+ - **Getters** transform data when reading (including in relationships and queries)
1236
+ - **Setters** transform data before storing
1237
+ - Both support async operations and dependencies
1238
+ - They work with actual database columns
1239
+
1240
+ Common uses:
1241
+ - Email normalization
1242
+ - String trimming
1243
+ - Title case formatting
1244
+ - Password hashing
1245
+ - Price calculations
1246
+ - Data consistency across related records