@odata-effect/odata-effect-generator 0.1.2 → 0.2.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.
Files changed (58) hide show
  1. package/README.md +223 -5
  2. package/dist/cjs/Cli.js +6 -2
  3. package/dist/cjs/Cli.js.map +1 -1
  4. package/dist/cjs/generator/Generator.js +33 -15
  5. package/dist/cjs/generator/Generator.js.map +1 -1
  6. package/dist/cjs/generator/IndexGenerator.js +19 -73
  7. package/dist/cjs/generator/IndexGenerator.js.map +1 -1
  8. package/dist/cjs/generator/NamingHelper.js +4 -1
  9. package/dist/cjs/generator/NamingHelper.js.map +1 -1
  10. package/dist/cjs/generator/NavigationGenerator.js +338 -0
  11. package/dist/cjs/generator/NavigationGenerator.js.map +1 -0
  12. package/dist/cjs/generator/OperationsGenerator.js +384 -0
  13. package/dist/cjs/generator/OperationsGenerator.js.map +1 -0
  14. package/dist/cjs/generator/QueryModelsGenerator.js +2 -2
  15. package/dist/cjs/generator/QueryModelsGenerator.js.map +1 -1
  16. package/dist/cjs/generator/ServiceFnGenerator.js +1 -1
  17. package/dist/cjs/generator/ServiceFnGenerator.js.map +1 -1
  18. package/dist/cjs/generator/ServiceFnPromiseGenerator.js +2 -2
  19. package/dist/cjs/generator/ServiceFnPromiseGenerator.js.map +1 -1
  20. package/dist/dts/Cli.d.ts.map +1 -1
  21. package/dist/dts/generator/Generator.d.ts +2 -0
  22. package/dist/dts/generator/Generator.d.ts.map +1 -1
  23. package/dist/dts/generator/IndexGenerator.d.ts.map +1 -1
  24. package/dist/dts/generator/NamingHelper.d.ts.map +1 -1
  25. package/dist/dts/generator/NavigationGenerator.d.ts +55 -0
  26. package/dist/dts/generator/NavigationGenerator.d.ts.map +1 -0
  27. package/dist/dts/generator/OperationsGenerator.d.ts +50 -0
  28. package/dist/dts/generator/OperationsGenerator.d.ts.map +1 -0
  29. package/dist/esm/Cli.js +6 -2
  30. package/dist/esm/Cli.js.map +1 -1
  31. package/dist/esm/generator/Generator.js +33 -15
  32. package/dist/esm/generator/Generator.js.map +1 -1
  33. package/dist/esm/generator/IndexGenerator.js +19 -73
  34. package/dist/esm/generator/IndexGenerator.js.map +1 -1
  35. package/dist/esm/generator/NamingHelper.js +4 -1
  36. package/dist/esm/generator/NamingHelper.js.map +1 -1
  37. package/dist/esm/generator/NavigationGenerator.js +330 -0
  38. package/dist/esm/generator/NavigationGenerator.js.map +1 -0
  39. package/dist/esm/generator/OperationsGenerator.js +375 -0
  40. package/dist/esm/generator/OperationsGenerator.js.map +1 -0
  41. package/dist/esm/generator/QueryModelsGenerator.js +2 -2
  42. package/dist/esm/generator/QueryModelsGenerator.js.map +1 -1
  43. package/dist/esm/generator/ServiceFnGenerator.js +1 -1
  44. package/dist/esm/generator/ServiceFnGenerator.js.map +1 -1
  45. package/dist/esm/generator/ServiceFnPromiseGenerator.js +2 -2
  46. package/dist/esm/generator/ServiceFnPromiseGenerator.js.map +1 -1
  47. package/generator/NavigationGenerator/package.json +6 -0
  48. package/generator/OperationsGenerator/package.json +6 -0
  49. package/package.json +17 -1
  50. package/src/Cli.ts +8 -2
  51. package/src/generator/Generator.ts +43 -13
  52. package/src/generator/IndexGenerator.ts +21 -74
  53. package/src/generator/NamingHelper.ts +6 -1
  54. package/src/generator/NavigationGenerator.ts +451 -0
  55. package/src/generator/OperationsGenerator.ts +481 -0
  56. package/src/generator/QueryModelsGenerator.ts +2 -2
  57. package/src/generator/ServiceFnGenerator.ts +1 -1
  58. package/src/generator/ServiceFnPromiseGenerator.ts +2 -2
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Generator for type-safe, tree-shakable navigation path builders.
3
+ *
4
+ * Generates branded path types and navigation functions that can be composed
5
+ * with pipe() for type-safe OData path construction.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { pipe } from "effect"
10
+ * import { People, byKey, trips, planItems, asFlight } from "./PathBuilders"
11
+ *
12
+ * const path = pipe(
13
+ * People,
14
+ * byKey("russellwhyte"),
15
+ * trips,
16
+ * byKey(0),
17
+ * planItems,
18
+ * asFlight
19
+ * )
20
+ * ```
21
+ *
22
+ * @since 1.0.0
23
+ */
24
+ import type {
25
+ DataModel,
26
+ EntityTypeModel
27
+ } from "../model/DataModel.js"
28
+ import type { ODataVersion } from "../parser/EdmxSchema.js"
29
+ import {
30
+ getClassName,
31
+ toCamelCase
32
+ } from "./NamingHelper.js"
33
+
34
+ /**
35
+ * Version-specific configuration.
36
+ */
37
+ interface VersionConfig {
38
+ readonly odataNamespace: string
39
+ readonly clientModule: string
40
+ readonly queryOptionsType: string
41
+ }
42
+
43
+ const V2_CONFIG: VersionConfig = {
44
+ odataNamespace: "OData",
45
+ clientModule: "ODataClient",
46
+ queryOptionsType: "ODataQueryOptions"
47
+ }
48
+
49
+ const V4_CONFIG: VersionConfig = {
50
+ odataNamespace: "ODataV4",
51
+ clientModule: "ODataV4Client",
52
+ queryOptionsType: "ODataV4QueryOptions"
53
+ }
54
+
55
+ const getVersionConfig = (version: ODataVersion): VersionConfig =>
56
+ version === "V4" ? V4_CONFIG : V2_CONFIG
57
+
58
+ /**
59
+ * Get the path builders module name.
60
+ */
61
+ export const getPathBuildersModuleName = (): string => "PathBuilders"
62
+
63
+ /**
64
+ * Generated navigation file.
65
+ *
66
+ * @since 1.0.0
67
+ * @category types
68
+ */
69
+ export interface GeneratedNavigationFile {
70
+ readonly fileName: string
71
+ readonly content: string
72
+ }
73
+
74
+ /**
75
+ * Result of navigation generation.
76
+ *
77
+ * @since 1.0.0
78
+ * @category types
79
+ */
80
+ export interface NavigationGenerationResult {
81
+ readonly navigationFiles: ReadonlyArray<GeneratedNavigationFile>
82
+ }
83
+
84
+ /**
85
+ * Track navigation property info for collision detection.
86
+ */
87
+ interface NavPropertyInfo {
88
+ readonly propertyName: string
89
+ readonly odataName: string
90
+ readonly sourceEntityName: string
91
+ readonly targetEntityName: string
92
+ readonly isCollection: boolean
93
+ }
94
+
95
+ /**
96
+ * Collect all navigation properties across all entity types.
97
+ */
98
+ const collectAllNavigationProperties = (dataModel: DataModel): ReadonlyArray<NavPropertyInfo> => {
99
+ const navProps: Array<NavPropertyInfo> = []
100
+
101
+ for (const entityType of dataModel.entityTypes.values()) {
102
+ for (const navProp of entityType.navigationProperties) {
103
+ const targetTypeName = getClassName(navProp.targetType)
104
+ navProps.push({
105
+ propertyName: navProp.name,
106
+ odataName: navProp.odataName,
107
+ sourceEntityName: entityType.name,
108
+ targetEntityName: targetTypeName,
109
+ isCollection: navProp.isCollection
110
+ })
111
+ }
112
+ }
113
+
114
+ return navProps
115
+ }
116
+
117
+ /**
118
+ * Check if a navigation property name has collisions (same name, different source entities).
119
+ */
120
+ const findCollisions = (navProps: ReadonlyArray<NavPropertyInfo>): Set<string> => {
121
+ const collisions = new Set<string>()
122
+ const seen = new Map<string, NavPropertyInfo>()
123
+
124
+ for (const prop of navProps) {
125
+ const existing = seen.get(prop.propertyName)
126
+ if (existing) {
127
+ // Collision if same name but different source entity
128
+ if (existing.sourceEntityName !== prop.sourceEntityName) {
129
+ collisions.add(prop.propertyName)
130
+ }
131
+ } else {
132
+ seen.set(prop.propertyName, prop)
133
+ }
134
+ }
135
+
136
+ return collisions
137
+ }
138
+
139
+ /**
140
+ * Get the function name for a navigation property.
141
+ */
142
+ const getNavFunctionName = (
143
+ prop: NavPropertyInfo,
144
+ hasCollision: boolean
145
+ ): string => {
146
+ if (hasCollision) {
147
+ // Qualified name: personTrips, orderTrips
148
+ return toCamelCase(`${prop.sourceEntityName}_${prop.propertyName}`)
149
+ }
150
+ return toCamelCase(prop.propertyName)
151
+ }
152
+
153
+ /**
154
+ * Collect derived types for an entity type (for type casting).
155
+ */
156
+ const getDerivedTypes = (
157
+ baseTypeFqName: string,
158
+ dataModel: DataModel
159
+ ): ReadonlyArray<EntityTypeModel> => {
160
+ const derived: Array<EntityTypeModel> = []
161
+
162
+ for (const entityType of dataModel.entityTypes.values()) {
163
+ if (entityType.baseType === baseTypeFqName) {
164
+ derived.push(entityType)
165
+ // Recursively get further derived types
166
+ derived.push(...getDerivedTypes(entityType.fqName, dataModel))
167
+ }
168
+ }
169
+
170
+ return derived
171
+ }
172
+
173
+ /**
174
+ * Generate navigation builders.
175
+ *
176
+ * @since 1.0.0
177
+ * @category generation
178
+ */
179
+ export const generateNavigations = (dataModel: DataModel): NavigationGenerationResult => {
180
+ const moduleName = getPathBuildersModuleName()
181
+ const content = generatePathBuildersFile(dataModel)
182
+
183
+ return {
184
+ navigationFiles: [{
185
+ fileName: `${moduleName}.ts`,
186
+ content
187
+ }]
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Generate the PathBuilders.ts file.
193
+ */
194
+ const generatePathBuildersFile = (dataModel: DataModel): string => {
195
+ const lines: Array<string> = []
196
+ const versionConfig = getVersionConfig(dataModel.version)
197
+
198
+ // Collect all nav properties and find collisions
199
+ const allNavProps = collectAllNavigationProperties(dataModel)
200
+ const collisions = findCollisions(allNavProps)
201
+
202
+ // Collect all entity type names that need to be referenced
203
+ const referencedTypes = collectReferencedTypes(dataModel)
204
+
205
+ // Header
206
+ lines.push(`/**`)
207
+ lines.push(` * Type-safe, tree-shakable path builders for OData navigation.`)
208
+ lines.push(` * Generated by odata-effect-gen.`)
209
+ lines.push(` *`)
210
+ lines.push(` * @example`)
211
+ lines.push(` * \`\`\`typescript`)
212
+ lines.push(` * import { pipe } from "effect"`)
213
+ lines.push(` * import { People, byKey, trips, planItems } from "./PathBuilders"`)
214
+ lines.push(` *`)
215
+ lines.push(` * const path = pipe(`)
216
+ lines.push(` * People,`)
217
+ lines.push(` * byKey("russellwhyte"),`)
218
+ lines.push(` * trips,`)
219
+ lines.push(` * byKey(0),`)
220
+ lines.push(` * planItems`)
221
+ lines.push(` * )`)
222
+ lines.push(` * \`\`\``)
223
+ lines.push(` *`)
224
+ lines.push(` * @since 1.0.0`)
225
+ lines.push(` */`)
226
+ lines.push(``)
227
+
228
+ // Imports
229
+ lines.push(`import * as ${versionConfig.odataNamespace} from "@odata-effect/odata-effect/${versionConfig.odataNamespace}"`)
230
+ lines.push(`import type { ${versionConfig.queryOptionsType} } from "@odata-effect/odata-effect/${versionConfig.clientModule}"`)
231
+ lines.push(`import type * as Schema from "effect/Schema"`)
232
+ lines.push(``)
233
+
234
+ // Import model types with Model suffix to avoid collision with entity set names
235
+ if (referencedTypes.size > 0) {
236
+ lines.push(`import type {`)
237
+ const typesList = Array.from(referencedTypes).sort()
238
+ for (let i = 0; i < typesList.length; i++) {
239
+ const isLast = i === typesList.length - 1
240
+ lines.push(` ${typesList[i]} as ${typesList[i]}Model${isLast ? "" : ","}`)
241
+ }
242
+ lines.push(`} from "./Models"`)
243
+ lines.push(``)
244
+ }
245
+
246
+ // Path branded type
247
+ lines.push(`// ============================================================================`)
248
+ lines.push(`// Path Types`)
249
+ lines.push(`// ============================================================================`)
250
+ lines.push(``)
251
+ lines.push(`/**`)
252
+ lines.push(` * Branded path type that tracks the entity type and whether it's a collection.`)
253
+ lines.push(` * This enables type-safe navigation - you can only navigate to properties`)
254
+ lines.push(` * that exist on the current entity type.`)
255
+ lines.push(` *`)
256
+ lines.push(` * @since 1.0.0`)
257
+ lines.push(` * @category types`)
258
+ lines.push(` */`)
259
+ lines.push(`export type Path<TEntity, IsCollection extends boolean = false> = string & {`)
260
+ lines.push(` readonly _entity: TEntity`)
261
+ lines.push(` readonly _collection: IsCollection`)
262
+ lines.push(`}`)
263
+ lines.push(``)
264
+
265
+ // Entity set roots (PascalCase, types use Model suffix to avoid collision)
266
+ lines.push(`// ============================================================================`)
267
+ lines.push(`// Entity Set Roots`)
268
+ lines.push(`// ============================================================================`)
269
+ lines.push(``)
270
+ for (const entitySet of dataModel.entitySets.values()) {
271
+ const entityType = dataModel.entityTypes.get(entitySet.entityTypeFqName)
272
+ if (entityType) {
273
+ lines.push(`/**`)
274
+ lines.push(` * Root path for ${entitySet.name} entity set.`)
275
+ lines.push(` *`)
276
+ lines.push(` * @since 1.0.0`)
277
+ lines.push(` * @category entity-sets`)
278
+ lines.push(` */`)
279
+ lines.push(`export const ${entitySet.name}: Path<${entityType.name}Model, true> = "${entitySet.name}" as Path<${entityType.name}Model, true>`)
280
+ lines.push(``)
281
+ }
282
+ }
283
+
284
+ // byKey function
285
+ lines.push(`// ============================================================================`)
286
+ lines.push(`// Key Access`)
287
+ lines.push(`// ============================================================================`)
288
+ lines.push(``)
289
+ lines.push(`/**`)
290
+ lines.push(` * Navigate to a specific entity by key.`)
291
+ lines.push(` * Works on any collection path.`)
292
+ lines.push(` *`)
293
+ lines.push(` * @example`)
294
+ lines.push(` * \`\`\`typescript`)
295
+ lines.push(` * pipe(People, byKey("russellwhyte")) // Path<PersonModel, false>`)
296
+ lines.push(` * pipe(Airports, byKey("KSFO")) // Path<AirportModel, false>`)
297
+ lines.push(` * \`\`\``)
298
+ lines.push(` *`)
299
+ lines.push(` * @since 1.0.0`)
300
+ lines.push(` * @category navigation`)
301
+ lines.push(` */`)
302
+ lines.push(`export const byKey = <T>(key: string | number) =>`)
303
+ lines.push(` (base: Path<T, true>): Path<T, false> =>`)
304
+ lines.push(` \`\${base}(\${typeof key === "string" ? \`'\${key}'\` : key})\` as Path<T, false>`)
305
+ lines.push(``)
306
+
307
+ // Navigation property functions
308
+ lines.push(`// ============================================================================`)
309
+ lines.push(`// Navigation Properties`)
310
+ lines.push(`// ============================================================================`)
311
+ lines.push(``)
312
+
313
+ // Group nav props by source entity for better organization
314
+ const navPropsBySource = new Map<string, Array<NavPropertyInfo>>()
315
+ for (const prop of allNavProps) {
316
+ const existing = navPropsBySource.get(prop.sourceEntityName) || []
317
+ existing.push(prop)
318
+ navPropsBySource.set(prop.sourceEntityName, existing)
319
+ }
320
+
321
+ for (const [sourceEntity, props] of navPropsBySource) {
322
+ lines.push(`// From ${sourceEntity}`)
323
+ for (const prop of props) {
324
+ const hasCollision = collisions.has(prop.propertyName)
325
+ const fnName = getNavFunctionName(prop, hasCollision)
326
+ const targetType = prop.targetEntityName
327
+
328
+ lines.push(`/**`)
329
+ lines.push(` * Navigate to ${prop.odataName}${prop.isCollection ? " collection" : ""}.`)
330
+ lines.push(` *`)
331
+ lines.push(` * @since 1.0.0`)
332
+ lines.push(` * @category navigation`)
333
+ lines.push(` */`)
334
+ lines.push(`export const ${fnName} = (base: Path<${sourceEntity}Model, false>): Path<${targetType}Model, ${prop.isCollection}> =>`)
335
+ lines.push(` \`\${base}/${prop.odataName}\` as Path<${targetType}Model, ${prop.isCollection}>`)
336
+ lines.push(``)
337
+ }
338
+ }
339
+
340
+ // Type casting functions for derived types
341
+ const castFunctions = generateTypeCastFunctions(dataModel)
342
+ if (castFunctions.length > 0) {
343
+ lines.push(`// ============================================================================`)
344
+ lines.push(`// Type Casting (for entity inheritance)`)
345
+ lines.push(`// ============================================================================`)
346
+ lines.push(``)
347
+ lines.push(...castFunctions)
348
+ }
349
+
350
+ // Terminal operations
351
+ lines.push(`// ============================================================================`)
352
+ lines.push(`// Terminal Operations`)
353
+ lines.push(`// ============================================================================`)
354
+ lines.push(``)
355
+ lines.push(`/**`)
356
+ lines.push(` * Fetch a collection of entities at the given path.`)
357
+ lines.push(` *`)
358
+ lines.push(` * @example`)
359
+ lines.push(` * \`\`\`typescript`)
360
+ lines.push(` * const allPeople = yield* pipe(People, fetchCollection(Person))`)
361
+ lines.push(` * \`\`\``)
362
+ lines.push(` *`)
363
+ lines.push(` * @since 1.0.0`)
364
+ lines.push(` * @category operations`)
365
+ lines.push(` */`)
366
+ lines.push(`export const fetchCollection = <T, I>(schema: Schema.Schema<T, I>) =>`)
367
+ lines.push(` (path: Path<T, true>, options?: ${versionConfig.queryOptionsType}) =>`)
368
+ lines.push(` ${versionConfig.odataNamespace}.getCollection(path, schema, options)`)
369
+ lines.push(``)
370
+ lines.push(`/**`)
371
+ lines.push(` * Fetch a single entity at the given path.`)
372
+ lines.push(` *`)
373
+ lines.push(` * @example`)
374
+ lines.push(` * \`\`\`typescript`)
375
+ lines.push(` * const person = yield* pipe(People, byKey("russell"), fetchOne(Person))`)
376
+ lines.push(` * \`\`\``)
377
+ lines.push(` *`)
378
+ lines.push(` * @since 1.0.0`)
379
+ lines.push(` * @category operations`)
380
+ lines.push(` */`)
381
+ lines.push(`export const fetchOne = <T, I>(schema: Schema.Schema<T, I>) =>`)
382
+ lines.push(` (path: Path<T, false>, options?: ${versionConfig.queryOptionsType}) =>`)
383
+ lines.push(` ${versionConfig.odataNamespace}.get(path, schema, options)`)
384
+ lines.push(``)
385
+
386
+ return lines.join("\n")
387
+ }
388
+
389
+ /**
390
+ * Generate type casting functions for derived types.
391
+ */
392
+ const generateTypeCastFunctions = (dataModel: DataModel): Array<string> => {
393
+ const lines: Array<string> = []
394
+ const processed = new Set<string>()
395
+
396
+ for (const entityType of dataModel.entityTypes.values()) {
397
+ const derivedTypes = getDerivedTypes(entityType.fqName, dataModel)
398
+
399
+ for (const derived of derivedTypes) {
400
+ const fnName = `as${derived.name}`
401
+ if (processed.has(fnName)) continue
402
+ processed.add(fnName)
403
+
404
+ const castPath = `${dataModel.namespace}.${derived.odataName}`
405
+
406
+ lines.push(`/**`)
407
+ lines.push(` * Cast collection to ${derived.name} (filters to derived type).`)
408
+ lines.push(` *`)
409
+ lines.push(` * @since 1.0.0`)
410
+ lines.push(` * @category casting`)
411
+ lines.push(` */`)
412
+ lines.push(`export const ${fnName} = (base: Path<${entityType.name}Model, true>): Path<${derived.name}Model, true> =>`)
413
+ lines.push(` \`\${base}/${castPath}\` as Path<${derived.name}Model, true>`)
414
+ lines.push(``)
415
+ }
416
+ }
417
+
418
+ return lines
419
+ }
420
+
421
+ /**
422
+ * Collect all entity type names referenced in paths.
423
+ */
424
+ const collectReferencedTypes = (dataModel: DataModel): Set<string> => {
425
+ const types = new Set<string>()
426
+
427
+ // Entity set entity types
428
+ for (const entitySet of dataModel.entitySets.values()) {
429
+ const entityType = dataModel.entityTypes.get(entitySet.entityTypeFqName)
430
+ if (entityType) {
431
+ types.add(entityType.name)
432
+ }
433
+ }
434
+
435
+ // Navigation target types
436
+ for (const entityType of dataModel.entityTypes.values()) {
437
+ for (const navProp of entityType.navigationProperties) {
438
+ types.add(getClassName(navProp.targetType))
439
+ }
440
+ }
441
+
442
+ // Derived types
443
+ for (const entityType of dataModel.entityTypes.values()) {
444
+ const derivedTypes = getDerivedTypes(entityType.fqName, dataModel)
445
+ for (const derived of derivedTypes) {
446
+ types.add(derived.name)
447
+ }
448
+ }
449
+
450
+ return types
451
+ }