storm-cli 1.0.0__py3-none-any.whl

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.
src/interpreter.py ADDED
@@ -0,0 +1,2300 @@
1
+
2
+
3
+
4
+
5
+
6
+ from collections import deque
7
+ import os
8
+
9
+ from src.ast import AstType
10
+ from src.enum import Enum
11
+ from src.error_handler import raise_error
12
+ from src.generic_controller_csharp import GENERIC_CONTROLLER_CSHARP, GENERIC_CONTROLLER_TEMPLATE_CSHARP
13
+ from src.generic_mapper_csharp import GENERIC_MAPPER_TEMPLATE_CSHARP
14
+ from src.generic_pagination_csharp import GENERIC_PAGINATION_CSHARP
15
+ from src.generic_query_chsarp import GENERIC_QUERY_CSHARP
16
+ from src.generic_service_csharp import GENERIC_ISERVICE_CSHARP, GENERIC_SERVICE_CSHARP
17
+ from src.parser import Parser
18
+ from src.table import Table
19
+
20
+
21
+ class Interpreter(Parser):
22
+ def __init__(self, filename):
23
+ super().__init__(filename)
24
+ ast = self.parse()
25
+ self.tables = {}
26
+ self.enums = {}
27
+ self.deps = {}
28
+ self._ordered = []
29
+ self._collect(ast)
30
+
31
+ def _collect(self, ast):
32
+ cur = ast.next
33
+ while cur:
34
+ if cur.node_type == AstType.TABLE_DECL:
35
+ name = cur.a.value
36
+ self.tables[name] = cur
37
+ elif cur.node_type == AstType.ENUM_DECL:
38
+ name = cur.a.value
39
+ self.enums[name] = cur
40
+ cur = cur.next
41
+
42
+ table_names = set(self.tables.keys())
43
+
44
+ for name, table_node in self.tables.items():
45
+ deps = set()
46
+ field = table_node.b
47
+ while field:
48
+ type_node = field.b
49
+ type_name = type_node.value.rstrip('?')
50
+ if type_name in table_names and type_name != name:
51
+ deps.add(type_name)
52
+ field = field.next
53
+ self.deps[name] = deps
54
+
55
+ self._ordered = self._topological_sort()
56
+
57
+ def _topological_sort(self):
58
+ indegree = {name: 0 for name in self.tables}
59
+ for name, deps in self.deps.items():
60
+ indegree[name] = len(deps)
61
+
62
+ queue = deque(name for name, deg in indegree.items() if deg == 0)
63
+ ordered = []
64
+
65
+ while queue:
66
+ current = queue.popleft()
67
+ ordered.append(current)
68
+ for name, deps in self.deps.items():
69
+ if current in deps:
70
+ indegree[name] -= 1
71
+ if indegree[name] == 0:
72
+ queue.append(name)
73
+
74
+ if len(ordered) != len(self.tables):
75
+ remaining = set(self.tables.keys()) - set(ordered)
76
+ ordered.extend(remaining)
77
+
78
+ return ordered
79
+
80
+ def _try_op(self, node, op_fn, zero_fn=None):
81
+ try:
82
+ return op_fn()
83
+ except TypeError as e:
84
+ raise_error(self.file_path, self.file_data, f"type error: {e}", node.position)
85
+ except ZeroDivisionError:
86
+ if zero_fn:
87
+ return zero_fn()
88
+ raise_error(self.file_path, self.file_data, "division by zero", node.position)
89
+
90
+ def _evaluate_expression(self, node):
91
+ if node is None:
92
+ return None
93
+
94
+ nt = node.node_type
95
+
96
+ if nt == AstType.INT_LITERAL:
97
+ return int(node.value)
98
+ if nt == AstType.STR_LITERAL:
99
+ return node.value
100
+ if nt == AstType.BOOL_LITERAL:
101
+ return node.value == 'true'
102
+ if nt == AstType.IDENTIFIER:
103
+ return None
104
+
105
+ l = self._evaluate_expression(node.a)
106
+ r = self._evaluate_expression(node.b)
107
+
108
+ if nt == AstType.BINARY_ADD:
109
+ if l is None or r is None:
110
+ return None
111
+ return self._try_op(node, lambda: l + r)
112
+ if nt == AstType.BINARY_SUB:
113
+ if l is None or r is None:
114
+ return None
115
+ return self._try_op(node, lambda: l - r)
116
+ if nt == AstType.BINARY_MUL:
117
+ if l is None or r is None:
118
+ return None
119
+ return self._try_op(node, lambda: l * r)
120
+ if nt == AstType.BINARY_DIV:
121
+ if l is None or r is None:
122
+ return None
123
+ return self._try_op(node, lambda: l / r)
124
+ if nt == AstType.BINARY_MOD:
125
+ if l is None or r is None:
126
+ return None
127
+ return self._try_op(node, lambda: l % r)
128
+
129
+ if nt == AstType.UNARY_MINUS:
130
+ if l is None:
131
+ return None
132
+ return self._try_op(node, lambda: -l)
133
+ if nt == AstType.UNARY_PLUS:
134
+ if l is None:
135
+ return None
136
+ return self._try_op(node, lambda: +l)
137
+ if nt == AstType.UNARY_NOT:
138
+ if l is None:
139
+ return None
140
+ return self._try_op(node, lambda: not l)
141
+ if nt == AstType.UNARY_BITWISE_NOT:
142
+ if l is None:
143
+ return None
144
+ return self._try_op(node, lambda: ~l)
145
+
146
+ if nt == AstType.BINARY_EQ:
147
+ if l is None or r is None:
148
+ return None
149
+ return self._try_op(node, lambda: l == r)
150
+ if nt == AstType.BINARY_NE:
151
+ if l is None or r is None:
152
+ return None
153
+ return self._try_op(node, lambda: l != r)
154
+ if nt == AstType.BINARY_LT:
155
+ if l is None or r is None:
156
+ return None
157
+ return self._try_op(node, lambda: l < r)
158
+ if nt == AstType.BINARY_LTE:
159
+ if l is None or r is None:
160
+ return None
161
+ return self._try_op(node, lambda: l <= r)
162
+ if nt == AstType.BINARY_GT:
163
+ if l is None or r is None:
164
+ return None
165
+ return self._try_op(node, lambda: l > r)
166
+ if nt == AstType.BINARY_GTE:
167
+ if l is None or r is None:
168
+ return None
169
+ return self._try_op(node, lambda: l >= r)
170
+
171
+ if nt == AstType.BINARY_SHL:
172
+ if l is None or r is None:
173
+ return None
174
+ return self._try_op(node, lambda: l << r)
175
+ if nt == AstType.BINARY_SHR:
176
+ if l is None or r is None:
177
+ return None
178
+ return self._try_op(node, lambda: l >> r)
179
+ if nt == AstType.BINARY_BITWISE_AND:
180
+ if l is None or r is None:
181
+ return None
182
+ return self._try_op(node, lambda: l & r)
183
+ if nt == AstType.BINARY_BITWISE_XOR:
184
+ if l is None or r is None:
185
+ return None
186
+ return self._try_op(node, lambda: l ^ r)
187
+ if nt == AstType.BINARY_BITWISE_OR:
188
+ if l is None or r is None:
189
+ return None
190
+ return self._try_op(node, lambda: l | r)
191
+
192
+ if nt == AstType.BINARY_LOGICAL_AND:
193
+ if l is None or r is None:
194
+ return None
195
+ return self._try_op(node, lambda: l and r)
196
+ if nt == AstType.BINARY_LOGICAL_OR:
197
+ if l is None or r is None:
198
+ return None
199
+ return self._try_op(node, lambda: l or r)
200
+
201
+ return None
202
+
203
+ def get_tables(self):
204
+ table_names = set(self.tables.keys())
205
+ result = []
206
+ for name in self._ordered:
207
+ node = self.tables[name]
208
+ table = Table.from_ast(node, table_names)
209
+ for _, ct in table.columns:
210
+ if ct.default_value_node is not None:
211
+ ct.default_value = self._evaluate_expression(ct.default_value_node)
212
+ result.append(table)
213
+ return result
214
+
215
+ def get_enums(self):
216
+ return [Enum.from_ast(node) for node in self.enums.values()]
217
+
218
+ def get_table_order(self):
219
+ return list(self._ordered)
220
+
221
+ # ── code generation ──────────────────────────────────────────────
222
+
223
+ STORM_TO_CSHARP = {
224
+ "int": "int", "long": "long", "float": "float", "double": "double",
225
+ "string": "string", "bool": "bool", "uuid": "Guid", "datetime": "DateTime",
226
+ }
227
+
228
+ CSHARP_TO_ROUTE_CONSTRAINT = {
229
+ "int": "int", "long": "long", "float": "float", "double": "double",
230
+ "bool": "bool", "Guid": "guid", "DateTime": "datetime",
231
+ }
232
+
233
+ def _path_to_namespace(self, path, project_name):
234
+ cleaned = path.lstrip("./").replace("/", ".").replace("\\", ".")
235
+ return f"{project_name}.{cleaned}" if cleaned else project_name
236
+
237
+ def _get_pk_field(self, table_node):
238
+ f = table_node.b
239
+ while f:
240
+ if f.value == "pk":
241
+ return f
242
+ f = f.next
243
+ return None
244
+
245
+ def _get_fk_columns(self, table_node):
246
+ """Return list of (field_name, ref_table, ref_pk_type) for FK columns."""
247
+ fks = []
248
+ table_names = set(self.tables.keys())
249
+ f = table_node.b
250
+ while f:
251
+ type_node = f.b
252
+ type_name = type_node.value.rstrip('?')
253
+ if type_name in table_names:
254
+ field_name = self._pascal_case(f.a.value)
255
+ ref_pk_type = self._get_pk_type(self.tables[type_name])
256
+ fks.append((field_name, type_name, ref_pk_type))
257
+ f = f.next
258
+ return fks
259
+
260
+ def _get_enum_columns(self, table_node):
261
+ """Return list of (field_name, enum_name) for enum-type columns."""
262
+ enums = []
263
+ enum_names = set(self.enums.keys())
264
+ f = table_node.b
265
+ while f:
266
+ type_node = f.b
267
+ type_name = type_node.value.rstrip("?")
268
+ if type_name in enum_names:
269
+ field_name = self._pascal_case(f.a.value)
270
+ enums.append((field_name, type_name))
271
+ f = f.next
272
+ return enums
273
+
274
+ def _get_pk_type(self, table_node):
275
+ pk = self._get_pk_field(table_node)
276
+ if pk is None:
277
+ return "int"
278
+ type_name = pk.b.value.rstrip("?")
279
+ return self.STORM_TO_CSHARP.get(type_name, "int")
280
+
281
+ def _field_csharp_type(self, field_node):
282
+ type_node = field_node.b
283
+ type_name = type_node.value.rstrip("?")
284
+ is_nullable = type_node.value.endswith("?")
285
+
286
+ mapped = self.STORM_TO_CSHARP.get(type_name)
287
+ if mapped:
288
+ if mapped == "string":
289
+ return mapped
290
+ return f"{mapped}?" if is_nullable else mapped
291
+
292
+ if type_name in self.tables:
293
+ ref_pk_type = self._get_pk_type(self.tables[type_name])
294
+ return (ref_pk_type, type_name)
295
+
296
+ if type_name in self.enums:
297
+ enum_name = self._pascal_case(type_name)
298
+ return f"{enum_name}?" if is_nullable else enum_name
299
+
300
+ return "object"
301
+
302
+ @staticmethod
303
+ def _pascal_case(name):
304
+ if not name:
305
+ return name
306
+ return name[0].upper() + name[1:]
307
+
308
+ @staticmethod
309
+ def _camel_case(name):
310
+ """lowercase first char — 'Owner' → 'owner', 'ownerId' → 'ownerId'."""
311
+ if not name:
312
+ return name
313
+ return name[0].lower() + name[1:]
314
+
315
+ def _build_namespaces(self, config, project_name):
316
+ ns = {}
317
+ for key, path in config.items():
318
+ ns[key] = self._path_to_namespace(path, project_name)
319
+ return ns
320
+
321
+ def _substitute_template(self, template, vars_dict):
322
+ result = template
323
+ for key, value in vars_dict.items():
324
+ result = result.replace("$$" + key + "$$", value)
325
+ return result
326
+
327
+ # ── model ────────────────────────────────────────────────────────
328
+
329
+ def _table_uses_enums(self, table_node):
330
+ """Return set of enum names referenced by fields in this table."""
331
+ used = set()
332
+ f = table_node.b
333
+ while f:
334
+ type_node = f.b
335
+ type_name = type_node.value.rstrip("?")
336
+ if type_name in self.enums:
337
+ used.add(type_name)
338
+ f = f.next
339
+ return used
340
+
341
+ # Properties already provided by IdentityUser / IdentityUser<TKey>
342
+ _IDENTITY_USER_FIELDS = {
343
+ "Id", "UserName", "NormalizedUserName", "Email", "NormalizedEmail",
344
+ "EmailConfirmed", "PasswordHash", "SecurityStamp", "ConcurrencyStamp",
345
+ "PhoneNumber", "PhoneNumberConfirmed", "TwoFactorEnabled",
346
+ "LockoutEnd", "LockoutEnabled", "AccessFailedCount",
347
+ }
348
+
349
+ def _generate_model(self, table_node, namespace, enum_ns=""):
350
+ name = table_node.a.value
351
+ pk = self._get_pk_field(table_node)
352
+ pk_name = self._pascal_case(pk.a.value) if pk else "Id"
353
+
354
+ is_user = (name == "User")
355
+ identity_base = ""
356
+ usings = ""
357
+ identity_skip_fields = set()
358
+
359
+ if is_user:
360
+ # Require PK type to be uuid
361
+ pk_type_storm = pk.b.value.rstrip("?")
362
+ if pk_type_storm != "uuid":
363
+ raise_error(
364
+ self.file_path, self.file_data,
365
+ f"User table primary key must be 'uuid', got '{pk_type_storm}'",
366
+ pk.position,
367
+ )
368
+ identity_base = " : IdentityUser<Guid>"
369
+ usings += "using Microsoft.AspNetCore.Identity;\n"
370
+ identity_skip_fields = self._IDENTITY_USER_FIELDS
371
+
372
+ if enum_ns and self._table_uses_enums(table_node):
373
+ usings += f"using {enum_ns};\n"
374
+
375
+ if usings:
376
+ usings += "\n"
377
+
378
+ # collect all field names first to detect FK-id collisions
379
+ all_names = set()
380
+ f = table_node.b
381
+ while f:
382
+ all_names.add(self._pascal_case(f.a.value))
383
+ f = f.next
384
+
385
+ buf = [usings + f"namespace {namespace};", "", f"public class {name}{identity_base}", "{"]
386
+
387
+ f = table_node.b
388
+ while f:
389
+ field_name = self._pascal_case(f.a.value)
390
+ cs_type = self._field_csharp_type(f)
391
+
392
+ # skip fields already provided by IdentityUser
393
+ if is_user and field_name in identity_skip_fields:
394
+ f = f.next
395
+ continue
396
+
397
+ if isinstance(cs_type, tuple):
398
+ ref_pk_type, ref_name = cs_type
399
+ fk_name = f"{field_name}Id"
400
+ if fk_name not in all_names and fk_name not in identity_skip_fields:
401
+ buf.append(f" public {ref_pk_type} {fk_name} {{ get; set; }}")
402
+ buf.append(f" public {ref_name}? {field_name} {{ get; set; }}")
403
+ else:
404
+ buf.append(f" public {cs_type} {field_name} {{ get; set; }}")
405
+
406
+ f = f.next
407
+
408
+ buf.append("}")
409
+ return "\n".join(buf)
410
+
411
+ # ── dto ──────────────────────────────────────────────────────────
412
+
413
+ def _generate_request_dto(self, table_node, namespace, enum_ns=""):
414
+ name = table_node.a.value
415
+ pk = self._get_pk_field(table_node)
416
+ pk_name = self._pascal_case(pk.a.value) if pk else "Id"
417
+ dto_name = f"{name}RequestDto"
418
+
419
+ usings = ""
420
+ if enum_ns and self._table_uses_enums(table_node):
421
+ usings = f"using {enum_ns};\n\n"
422
+
423
+ # collect field names to detect FK-id collisions
424
+ all_names = set()
425
+ fn = table_node.b
426
+ while fn:
427
+ all_names.add(self._pascal_case(fn.a.value))
428
+ fn = fn.next
429
+
430
+ buf = [usings + f"namespace {namespace};", "", f"public class {dto_name}", "{"]
431
+
432
+ f = table_node.b
433
+ while f:
434
+ field_name = self._pascal_case(f.a.value)
435
+ if field_name == pk_name:
436
+ f = f.next
437
+ continue
438
+ cs_type = self._field_csharp_type(f)
439
+ if isinstance(cs_type, tuple):
440
+ ref_pk_type, _ = cs_type
441
+ fk_name = f"{field_name}Id"
442
+ if fk_name not in all_names:
443
+ buf.append(f" public {ref_pk_type} {fk_name} {{ get; set; }}")
444
+ else:
445
+ buf.append(f" public {cs_type} {field_name} {{ get; set; }}")
446
+ f = f.next
447
+
448
+ buf.append("}")
449
+ return "\n".join(buf)
450
+
451
+ def _generate_response_dto(self, table_node, namespace, enum_ns=""):
452
+ name = table_node.a.value
453
+ dto_name = f"{name}ResponseDto"
454
+
455
+ usings = ""
456
+ if enum_ns and self._table_uses_enums(table_node):
457
+ usings = f"using {enum_ns};\n\n"
458
+
459
+ # collect field names to detect FK-id collisions
460
+ all_names = set()
461
+ fn = table_node.b
462
+ while fn:
463
+ all_names.add(self._pascal_case(fn.a.value))
464
+ fn = fn.next
465
+
466
+ buf = [usings + f"namespace {namespace};", "", f"public class {dto_name}", "{"]
467
+
468
+ f = table_node.b
469
+ while f:
470
+ field_name = self._pascal_case(f.a.value)
471
+ cs_type = self._field_csharp_type(f)
472
+ if isinstance(cs_type, tuple):
473
+ ref_pk_type, _ = cs_type
474
+ fk_name = f"{field_name}Id"
475
+ if fk_name not in all_names:
476
+ buf.append(f" public {ref_pk_type} {fk_name} {{ get; set; }}")
477
+ else:
478
+ buf.append(f" public {cs_type} {field_name} {{ get; set; }}")
479
+ f = f.next
480
+
481
+ buf.append("}")
482
+ return "\n".join(buf)
483
+
484
+ def _generate_response_simplified_dto(self, table_node, namespace, enum_ns=""):
485
+ name = table_node.a.value
486
+ pk = self._get_pk_field(table_node)
487
+ pk_name = self._pascal_case(pk.a.value) if pk else "Id"
488
+ pk_type = self.STORM_TO_CSHARP.get(pk.b.value.rstrip("?"), "int") if pk else "int"
489
+ dto_name = f"{name}ResponseSimplifiedDto"
490
+
491
+ usings = ""
492
+ if enum_ns and self._table_uses_enums(table_node):
493
+ usings = f"using {enum_ns};\n\n"
494
+
495
+ buf = [usings + f"namespace {namespace};", "", f"public class {dto_name}", "{"]
496
+ buf.append(f" public {pk_type} {pk_name} {{ get; set; }}")
497
+
498
+ for nf in ["Name", "Title", "Label"]:
499
+ f = table_node.b
500
+ while f:
501
+ if self._pascal_case(f.a.value) == nf:
502
+ cs_type = self._field_csharp_type(f)
503
+ buf.append(f" public {cs_type[0] if isinstance(cs_type, tuple) else cs_type} {nf} {{ get; set; }}")
504
+ break
505
+ f = f.next
506
+
507
+ buf.append("}")
508
+ return "\n".join(buf)
509
+
510
+ # ── enum ─────────────────────────────────────────────────────────
511
+
512
+ def _generate_csharp_enum(self, enum_node, namespace):
513
+ name = enum_node.a.value
514
+ buf = [f"namespace {namespace};", "", f"public enum {name}", "{"]
515
+ cur = enum_node.b
516
+ while cur:
517
+ value = cur.b.value if cur.b else ""
518
+ comment = f" // \"{value}\"" if value else ""
519
+ buf.append(f" {cur.a.value},{comment}")
520
+ cur = cur.next
521
+ buf.append("}")
522
+ return "\n".join(buf)
523
+
524
+ # ── template generators ──────────────────────────────────────────
525
+
526
+ def _generate_iservice(self, table_name, namespace, pk_type, iservice_ns, model_ns, dto_ns, pagination_ns):
527
+ table_node = self.tables[table_name]
528
+ fks = self._get_fk_columns(table_node)
529
+ enum_cols = self._get_enum_columns(table_node)
530
+ extra_methods = ""
531
+ for fk_name, fk_table, fk_pk_type in fks:
532
+ lower_fk = fk_name[0].lower() + fk_name[1:]
533
+ extra_methods += f"\n public Task<PaginatedResult<{table_name}ResponseDto>> PaginateBy{fk_name}Async({fk_pk_type} {lower_fk}Id, PaginateQuery query);"
534
+ for enum_field, enum_name in enum_cols:
535
+ extra_methods += f"\n public Task<PaginatedResult<{table_name}ResponseDto>> PaginateBy{enum_field}Async({enum_name} {enum_field}, PaginateQuery query);"
536
+
537
+ using_pagination = f"using {pagination_ns};\n" if (fks or enum_cols) else ""
538
+
539
+ return f"""\
540
+ {using_pagination}using {iservice_ns};
541
+ using {model_ns};
542
+ using {dto_ns};
543
+
544
+ namespace {namespace};
545
+
546
+ public interface I{table_name}Service : IGenericService<{table_name}, {table_name}ResponseDto, {table_name}RequestDto, {pk_type}>
547
+ {{{extra_methods}
548
+ }}
549
+ """
550
+
551
+ def _generate_service(self, table_name, namespace, pk_type, service_ns, iservice_ns, model_ns, dto_ns, pagination_ns):
552
+ table_node = self.tables[table_name]
553
+ fks = self._get_fk_columns(table_node)
554
+ enum_cols = self._get_enum_columns(table_node)
555
+ extra_methods = ""
556
+ for fk_name, fk_table, fk_pk_type in fks:
557
+ lower_fk = fk_name[0].lower() + fk_name[1:]
558
+ extra_methods += f"""
559
+ public async Task<PaginatedResult<{table_name}ResponseDto>> PaginateBy{fk_name}Async({fk_pk_type} {lower_fk}Id, PaginateQuery query)
560
+ {{
561
+ var q = _table.Where(e => e.{fk_name}Id == {lower_fk}Id);
562
+ return await q
563
+ .ProjectTo<{table_name}ResponseDto>(_mapper.ConfigurationProvider)
564
+ .PaginateAsync(query.Page, query.Rows);
565
+ }}
566
+ """
567
+ for enum_field, enum_name in enum_cols:
568
+ extra_methods += f"""
569
+ public async Task<PaginatedResult<{table_name}ResponseDto>> PaginateBy{enum_field}Async({enum_name} {enum_field}, PaginateQuery query)
570
+ {{
571
+ var q = _table.Where(e => e.{enum_field} == {enum_field});
572
+ return await q
573
+ .ProjectTo<{table_name}ResponseDto>(_mapper.ConfigurationProvider)
574
+ .PaginateAsync(query.Page, query.Rows);
575
+ }}
576
+ """
577
+
578
+ using_pagination = f"using {pagination_ns};\n" if (fks or enum_cols) else ""
579
+
580
+ return f"""\
581
+ {using_pagination}using {service_ns};
582
+ using {iservice_ns};
583
+ using {model_ns};
584
+ using {dto_ns};
585
+ using Microsoft.EntityFrameworkCore;
586
+ using AutoMapper;
587
+ using AutoMapper.QueryableExtensions;
588
+
589
+ namespace {namespace};
590
+
591
+ public class {table_name}Service : GenericService<{table_name}, {table_name}ResponseDto, {table_name}RequestDto, {pk_type}>, I{table_name}Service
592
+ {{
593
+ public {table_name}Service(DbContext context, IMapper mapper) : base(context, mapper) {{ }}
594
+ {extra_methods}
595
+ }}
596
+ """
597
+
598
+ def _generate_controller(self, table_name, namespace, pk_type, controller_ns, iservice_ns, dto_ns, pagination_ns, model_ns):
599
+ table_node = self.tables[table_name]
600
+ fks = self._get_fk_columns(table_node)
601
+ enum_cols = self._get_enum_columns(table_node)
602
+ pk_route_constraint = self.CSHARP_TO_ROUTE_CONSTRAINT.get(pk_type, "")
603
+ id_route = f"{{id:{pk_route_constraint}}}" if pk_route_constraint else "{id}"
604
+
605
+ extra_endpoints = ""
606
+ for fk_name, fk_table, fk_pk_type in fks:
607
+ lower_fk = fk_name[0].lower() + fk_name[1:]
608
+ route_constraint = self.CSHARP_TO_ROUTE_CONSTRAINT.get(fk_pk_type, "")
609
+ typed_param = f"{{{lower_fk}Id}}" if not route_constraint else f"{{{lower_fk}Id:{route_constraint}}}"
610
+ extra_endpoints += f"""
611
+ [HttpGet("{fk_table.lower()}/{typed_param}", Name = "Get{table_name}By{fk_name}")]
612
+ [Tags("{table_name}")]
613
+ [EndpointSummary("Paginated list by {fk_table}")]
614
+ [EndpointDescription("Returns a paginated list of {table_name} records filtered by {fk_table}")]
615
+ [ProducesResponseType(typeof(PaginatedResult<{table_name}ResponseDto>), StatusCodes.Status200OK)]
616
+ public virtual async Task<ActionResult<PaginatedResult<{table_name}ResponseDto>>> IndexBy{fk_name}([FromRoute] {fk_pk_type} {lower_fk}Id,
617
+ [FromQuery] PaginateQuery query)
618
+ {{
619
+ var result = await ((I{table_name}Service)_service).PaginateBy{fk_name}Async({lower_fk}Id, query);
620
+ return Ok(result);
621
+ }}
622
+ """
623
+ for enum_field, enum_name in enum_cols:
624
+ lower_enum = enum_field[0].lower() + enum_field[1:]
625
+ extra_endpoints += f"""
626
+ [HttpGet("{enum_name.lower()}/{{{lower_enum}}}", Name = "Get{table_name}By{enum_field}")]
627
+ [Tags("{table_name}")]
628
+ [EndpointSummary("Paginated list by {enum_field}")]
629
+ [EndpointDescription("Returns a paginated list of {table_name} records filtered by {enum_field} value")]
630
+ [ProducesResponseType(typeof(PaginatedResult<{table_name}ResponseDto>), StatusCodes.Status200OK)]
631
+ public virtual async Task<ActionResult<PaginatedResult<{table_name}ResponseDto>>> IndexBy{enum_field}([FromRoute] {enum_name} {lower_enum},
632
+ [FromQuery] PaginateQuery query)
633
+ {{
634
+ var result = await ((I{table_name}Service)_service).PaginateBy{enum_field}Async({lower_enum}, query);
635
+ return Ok(result);
636
+ }}
637
+ """
638
+
639
+ return f"""\
640
+ using System.Threading.Tasks;
641
+ using Microsoft.AspNetCore.Mvc;
642
+ using {controller_ns};
643
+ using {iservice_ns};
644
+ using {dto_ns};
645
+ using {pagination_ns};
646
+ using {model_ns};
647
+
648
+ namespace {namespace};
649
+
650
+ [ApiController]
651
+ [Route("api/[controller]")]
652
+ public class {table_name}Controller : GenericController<{table_name}, {table_name}ResponseDto, {table_name}RequestDto, {pk_type}>
653
+ {{
654
+ public {table_name}Controller(I{table_name}Service service) : base(service) {{ }}
655
+
656
+ [HttpGet("{id_route}", Name = "Get{table_name}ById")]
657
+ [Tags("{table_name}")]
658
+ [EndpointSummary("Retrieve by id")]
659
+ [EndpointDescription("Returns a single {table_name} record by its unique identifier")]
660
+ [ProducesResponseType(typeof({table_name}ResponseDto), StatusCodes.Status200OK)]
661
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
662
+ public virtual async Task<ActionResult<{table_name}ResponseDto>> Show([FromRoute] {pk_type} id)
663
+ {{
664
+ var result = await _service.GetByIdAsync(id);
665
+ return Ok(result);
666
+ }}
667
+
668
+ [HttpGet(Name = "Get{table_name}Paginated")]
669
+ [Tags("{table_name}")]
670
+ [EndpointSummary("Paginated list")]
671
+ [EndpointDescription("Returns a paginated list of {table_name} records")]
672
+ [ProducesResponseType(typeof(PaginatedResult<{table_name}ResponseDto>), StatusCodes.Status200OK)]
673
+ public virtual async Task<ActionResult<PaginatedResult<{table_name}ResponseDto>>> Index([FromQuery] PaginateQuery query)
674
+ {{
675
+ var result = await _service.PaginateAsync(query);
676
+ return Ok(result);
677
+ }}
678
+ {extra_endpoints}
679
+ [HttpPost(Name = "Create{table_name}")]
680
+ [Tags("{table_name}")]
681
+ [EndpointSummary("Create new")]
682
+ [EndpointDescription("Creates a new {table_name} record from the provided payload")]
683
+ [ProducesResponseType(typeof({table_name}ResponseDto), StatusCodes.Status200OK)]
684
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
685
+ public virtual async Task<ActionResult<{table_name}ResponseDto>> Store([FromBody] {table_name}RequestDto item)
686
+ {{
687
+ var result = await _service.CreateAsync(item);
688
+ return Ok(result);
689
+ }}
690
+
691
+ [HttpPut("{id_route}", Name = "Update{table_name}")]
692
+ [Tags("{table_name}")]
693
+ [EndpointSummary("Update by id")]
694
+ [EndpointDescription("Updates an existing {table_name} record identified by its id with the provided payload")]
695
+ [ProducesResponseType(typeof({table_name}ResponseDto), StatusCodes.Status200OK)]
696
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
697
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
698
+ public virtual async Task<ActionResult<{table_name}ResponseDto>> Update([FromRoute] {pk_type} id, [FromBody] {table_name}RequestDto item)
699
+ {{
700
+ var result = await _service.UpdateAsync(id, item);
701
+ return Ok(result);
702
+ }}
703
+
704
+ [HttpDelete("{id_route}", Name = "Delete{table_name}")]
705
+ [Tags("{table_name}")]
706
+ [EndpointSummary("Delete by id")]
707
+ [EndpointDescription("Deletes a {table_name} record by its unique identifier")]
708
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
709
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
710
+ public virtual async Task<IActionResult> Destroy([FromRoute] {pk_type} id)
711
+ {{
712
+ await _service.DeleteAsync(id);
713
+ return NoContent();
714
+ }}
715
+ }}
716
+ """
717
+
718
+
719
+ def _generate_mapper(self, table_name, namespace, model_ns, dto_ns):
720
+ vars_dict = {
721
+ "config_mapper_path": namespace,
722
+ "config_model_path": model_ns,
723
+ "config_dto_path": dto_ns,
724
+ "Entity": table_name,
725
+ "TRequestDto": f"{table_name}RequestDto",
726
+ "TResponseDto": f"{table_name}ResponseDto",
727
+ "TResponseDtoSimplified": f"{table_name}ResponseSimplifiedDto",
728
+ }
729
+ return self._substitute_template(GENERIC_MAPPER_TEMPLATE_CSHARP, vars_dict)
730
+
731
+ # ── dbcontext ───────────────────────────────────────────────────
732
+
733
+ def _generate_appdbcontext(self, namespace, model_ns):
734
+ usings = f"""\
735
+ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
736
+ using Microsoft.EntityFrameworkCore;
737
+ using {model_ns};
738
+ """
739
+ buf = [usings, f"namespace {namespace};", "", "public class AppDbContext : IdentityDbContext", "{"]
740
+ buf.append(" public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }")
741
+ buf.append("")
742
+
743
+ for name in self._ordered:
744
+ if name == "User":
745
+ continue
746
+ buf.append(f" public DbSet<{name}> {name}s {{ get; set; }} = null!;")
747
+
748
+ buf.append("")
749
+ buf.append(" protected override void OnModelCreating(ModelBuilder builder)")
750
+ buf.append(" {")
751
+ buf.append(" base.OnModelCreating(builder);")
752
+ buf.append(" }")
753
+ buf.append("}")
754
+ return "\n".join(buf)
755
+
756
+ # ── write ────────────────────────────────────────────────────────
757
+
758
+ def _write_file(self, base_dir, path, filename, content):
759
+ dir_path = os.path.join(base_dir, path.lstrip("./"))
760
+ os.makedirs(dir_path, exist_ok=True)
761
+ file_path = os.path.join(dir_path, filename)
762
+ with open(file_path, "w", encoding="utf-8") as f:
763
+ f.write(content)
764
+ print(f" [ok] {file_path}")
765
+
766
+ # ── PHP / Laravel helpers ────────────────────────────────────────
767
+
768
+ STORM_TO_PHP = {
769
+ "int": "int", "long": "int", "float": "float", "double": "float",
770
+ "string": "string", "bool": "bool", "uuid": "string", "datetime": "\\DateTime",
771
+ }
772
+
773
+ STORM_TO_OA_TYPE = {
774
+ "int": "integer", "long": "integer", "float": "number", "double": "number",
775
+ "string": "string", "bool": "boolean", "uuid": "string", "datetime": "string",
776
+ }
777
+
778
+ STORM_TO_MIGRATION = {
779
+ "int": "integer", "long": "bigInteger", "float": "float", "double": "double",
780
+ "string": "string", "bool": "boolean", "uuid": "uuid", "datetime": "dateTime",
781
+ }
782
+
783
+ @staticmethod
784
+ def _snake_case(name):
785
+ """Convert PascalCase or camelCase to snake_case."""
786
+ result = []
787
+ for i, ch in enumerate(name):
788
+ if ch.isupper():
789
+ if i > 0:
790
+ result.append("_")
791
+ result.append(ch.lower())
792
+ else:
793
+ result.append(ch)
794
+ return "".join(result)
795
+
796
+ def _field_php_type(self, field_node):
797
+ """Return (php_type, is_enum, enum_name, is_fk, fk_table)."""
798
+ type_node = field_node.b
799
+ type_name = type_node.value.rstrip("?")
800
+ is_nullable = type_node.value.endswith("?")
801
+
802
+ if type_name in self.STORM_TO_PHP:
803
+ php = self.STORM_TO_PHP[type_name]
804
+ if is_nullable and php != "string":
805
+ return (f"?{php}", False, None, False, None)
806
+ return (php, False, None, False, None)
807
+
808
+ if type_name in self.tables:
809
+ ref_pk_type = self._get_pk_type(self.tables[type_name])
810
+ php_pk = self.STORM_TO_PHP.get(ref_pk_type, "int")
811
+ return (php_pk, False, None, True, type_name)
812
+
813
+ if type_name in self.enums:
814
+ enum_name = type_name
815
+ if is_nullable:
816
+ return (f"?{enum_name}", True, enum_name, False, None)
817
+ return (enum_name, True, enum_name, False, None)
818
+
819
+ return ("mixed", False, None, False, None)
820
+
821
+ def _field_oa_type(self, field_node):
822
+ """Return (oa_type, oa_format, is_enum)."""
823
+ type_node = field_node.b
824
+ type_name = type_node.value.rstrip("?")
825
+
826
+ if type_name in self.STORM_TO_OA_TYPE:
827
+ oa = self.STORM_TO_OA_TYPE[type_name]
828
+ fmt = "date-time" if type_name == "datetime" else None
829
+ return (oa, fmt, False)
830
+
831
+ if type_name in self.tables:
832
+ return ("object", None, False)
833
+
834
+ if type_name in self.enums:
835
+ return ("string", None, True)
836
+
837
+ return ("string", None, False)
838
+
839
+ # ── PHP Enum generator ───────────────────────────────────────────
840
+
841
+ def _generate_php_enum(self, enum_node):
842
+ name = enum_node.a.value
843
+ lines = [
844
+ "<?php",
845
+ "",
846
+ "namespace App\\Static;",
847
+ "",
848
+ "use OpenApi\\Attributes as OA;",
849
+ "",
850
+ f"#[OA\\Schema(",
851
+ f" schema: \"{name}\",",
852
+ f" title: \"{name}\",",
853
+ f" type: \"string\",",
854
+ f" enum: {name}::class,",
855
+ f")]",
856
+ f"enum {name}: string",
857
+ "{",
858
+ ]
859
+
860
+ cur = enum_node.b
861
+ first = True
862
+ while cur:
863
+ key = cur.a.value
864
+ value = cur.b.value if cur.b else ""
865
+ if first:
866
+ lines.append(f" case {key} = \"{value}\";")
867
+ first = False
868
+ else:
869
+ lines.append(f" case {key} = \"{value}\";")
870
+ cur = cur.next
871
+
872
+ lines.append("}")
873
+ return "\n".join(lines)
874
+
875
+ # ── PHP Model generator ──────────────────────────────────────────
876
+
877
+ def _generate_php_model_oa_schemas(self, table_node):
878
+ r"""Generate #[OA\Schema] annotations for a model."""
879
+ name = table_node.a.value
880
+ table_pascal = self._pascal_case(name)
881
+
882
+ required_fields = []
883
+ property_attrs = []
884
+
885
+ f = table_node.b
886
+ while f:
887
+ field_name = self._snake_case(f.a.value)
888
+ oa_type, oa_format, is_enum = self._field_oa_type(f)
889
+ type_node = f.b
890
+ type_name = type_node.value.rstrip("?")
891
+ is_nullable = type_node.value.endswith("?")
892
+
893
+ # Determine if required
894
+ pk = self._get_pk_field(table_node)
895
+ is_pk = (pk is not None and f.a.value == pk.a.value)
896
+
897
+ if not is_nullable and not is_pk:
898
+ required_fields.append(field_name)
899
+ elif type_name == "datetime":
900
+ required_fields.append(field_name)
901
+
902
+ # Build property
903
+ if is_enum:
904
+ prop = f" new OA\\Property(property: \"{field_name}\", type: \"{oa_type}\"),"
905
+ elif oa_format:
906
+ prop = f" new OA\\Property(property: \"{field_name}\", type: \"{oa_type}\", format: \"{oa_format}\"),"
907
+ elif type_name in self.tables:
908
+ # FK property — reference the related model
909
+ ref_name = type_name
910
+ prop = f" new OA\\Property(property: \"{field_name}\", ref: \"#/components/schemas/{ref_name}\"),"
911
+ # Also add the FK id column if not already present
912
+ fk_col = f"{field_name}_id"
913
+ # Only add FK id if it's NOT already in the property list AND not a PK
914
+ fk_already = False
915
+ f2 = table_node.b
916
+ while f2:
917
+ if self._snake_case(f2.a.value) == fk_col:
918
+ fk_already = True
919
+ break
920
+ f2 = f2.next
921
+ if not fk_already:
922
+ fk_pk = self._get_pk_type(self.tables[type_name])
923
+ fk_oa = self.STORM_TO_OA_TYPE.get(fk_pk, "integer")
924
+ property_attrs.append(
925
+ f" new OA\\Property(property: \"{fk_col}\", type: \"{fk_oa}\"),"
926
+ )
927
+ else:
928
+ nullable_suffix = ", nullable: true" if is_nullable else ""
929
+ prop = f" new OA\\Property(property: \"{field_name}\", type: \"{oa_type}\"{nullable_suffix}),"
930
+
931
+ property_attrs.append(prop)
932
+ f = f.next
933
+
934
+ required_str = ",\n ".join(f'"{r}"' for r in required_fields)
935
+ if required_str:
936
+ required_block = f" required: [\n {required_str}\n ],"
937
+ else:
938
+ required_block = ""
939
+
940
+ properties_block = "\n".join(property_attrs)
941
+
942
+ oa_schemas = f"""
943
+ #[OA\\Schema(
944
+ schema: "{table_pascal}",
945
+ title: "{table_pascal}",
946
+ type: "object",
947
+ {required_block}
948
+ properties: [
949
+ {properties_block}
950
+ ]
951
+ )]
952
+
953
+ #[OA\\Schema(
954
+ schema: "Paginated{table_pascal}",
955
+ title: "Paginated{table_pascal}",
956
+ type: "object",
957
+ properties: [
958
+ new OA\\Property(property: "data", type: "array", items: new OA\\Items(ref: "#/components/schemas/{table_pascal}")),
959
+ new OA\\Property(property: "current_page", type: "integer"),
960
+ new OA\\Property(property: "last_page", type: "integer"),
961
+ new OA\\Property(property: "per_page", type: "integer"),
962
+ new OA\\Property(property: "total", type: "integer"),
963
+ new OA\\Property(property: "from", type: "integer", nullable: true),
964
+ new OA\\Property(property: "to", type: "integer", nullable: true),
965
+ ]
966
+ )]
967
+
968
+ #[OA\\Schema(
969
+ schema: "Paginated{table_pascal}Response200",
970
+ type: "object",
971
+ properties: [
972
+ new OA\\Property(property: "success", type: "boolean", example: true),
973
+ new OA\\Property(property: "data", ref: "#/components/schemas/Paginated{table_pascal}")
974
+ ]
975
+ )]
976
+
977
+ #[OA\\Schema(
978
+ schema: "Get{table_pascal}Response200",
979
+ type: "object",
980
+ properties: [
981
+ new OA\\Property(property: "success", type: "boolean", example: true),
982
+ new OA\\Property(property: "data", ref: "#/components/schemas/{table_pascal}")
983
+ ]
984
+ )]
985
+
986
+ #[OA\\Schema(
987
+ schema: "Get{table_pascal}sResponse200",
988
+ type: "object",
989
+ properties: [
990
+ new OA\\Property(property: "success", type: "boolean", example: true),
991
+ new OA\\Property(property: "data", type: "array", items: new OA\\Items(ref: "#/components/schemas/{table_pascal}"))
992
+ ]
993
+ )]
994
+
995
+ #[OA\\Schema(
996
+ schema: "Create{table_pascal}Response200",
997
+ type: "object",
998
+ properties: [
999
+ new OA\\Property(property: "success", type: "boolean", example: true),
1000
+ new OA\\Property(property: "data", ref: "#/components/schemas/{table_pascal}")
1001
+ ]
1002
+ )]
1003
+
1004
+ #[OA\\Schema(
1005
+ schema: "Update{table_pascal}Response200",
1006
+ type: "object",
1007
+ properties: [
1008
+ new OA\\Property(property: "success", type: "boolean", example: true),
1009
+ new OA\\Property(property: "data", ref: "#/components/schemas/{table_pascal}")
1010
+ ]
1011
+ )]
1012
+
1013
+ #[OA\\Schema(
1014
+ schema: "Delete{table_pascal}Response200",
1015
+ type: "object",
1016
+ properties: [
1017
+ new OA\\Property(property: "success", type: "boolean", example: true)
1018
+ ]
1019
+ )]"""
1020
+ return oa_schemas
1021
+
1022
+ def _get_reverse_fks(self, table_name):
1023
+ """Return list of (other_table, fk_field_name) where other_table has FK to table_name."""
1024
+ reverse = []
1025
+ for other_name, other_node in self.tables.items():
1026
+ if other_name == table_name:
1027
+ continue
1028
+ f = other_node.b
1029
+ while f:
1030
+ type_node = f.b
1031
+ type_name = type_node.value.rstrip("?")
1032
+ if type_name == table_name:
1033
+ field_name = self._snake_case(f.a.value)
1034
+ reverse.append((other_name, field_name))
1035
+ f = f.next
1036
+ return reverse
1037
+
1038
+ def _generate_php_model(self, table_node, namespace):
1039
+ name = table_node.a.value
1040
+ table_snake = self._snake_case(name)
1041
+ name_lower = name.lower()
1042
+
1043
+ # Determine which relationships exist
1044
+ has_fk = False
1045
+ f = table_node.b
1046
+ while f:
1047
+ type_node = f.b
1048
+ type_name = type_node.value.rstrip("?")
1049
+ if type_name in self.tables:
1050
+ has_fk = True
1051
+ break
1052
+ f = f.next
1053
+
1054
+ reverse_fks = self._get_reverse_fks(name)
1055
+ has_has_many = len(reverse_fks) > 0
1056
+
1057
+ lines = [
1058
+ "<?php",
1059
+ "",
1060
+ "namespace App\\Models;",
1061
+ "",
1062
+ "use Illuminate\\Database\\Eloquent\\Model;",
1063
+ ]
1064
+ if has_fk:
1065
+ lines.append("use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;")
1066
+ if has_has_many:
1067
+ lines.append("use Illuminate\\Database\\Eloquent\\Relations\\HasMany;")
1068
+ lines.append("use OpenApi\\Attributes as OA;")
1069
+
1070
+ # Add enum imports
1071
+ used_enums = self._table_uses_enums(table_node)
1072
+ for enum_name in used_enums:
1073
+ lines.append(f"use App\\Static\\{enum_name};")
1074
+
1075
+ lines.append("")
1076
+
1077
+ # OA Schemas
1078
+ lines.append(self._generate_php_model_oa_schemas(table_node).strip())
1079
+
1080
+ lines.append(f"class {name} extends Model")
1081
+ lines.append("{")
1082
+
1083
+ # $table
1084
+ lines.append(f" protected $table = '{table_snake}';")
1085
+
1086
+ # $fillable
1087
+ fillable = []
1088
+ # Collect all snake_case field names for FK-id dedup
1089
+ all_snake_fields = set()
1090
+ f = table_node.b
1091
+ while f:
1092
+ all_snake_fields.add(self._snake_case(f.a.value))
1093
+ f = f.next
1094
+
1095
+ f = table_node.b
1096
+ pk = self._get_pk_field(table_node)
1097
+ while f:
1098
+ field_name = self._snake_case(f.a.value)
1099
+ if pk and f.a.value == pk.a.value:
1100
+ pass # Skip PK from fillable
1101
+ else:
1102
+ type_node = f.b
1103
+ type_name = type_node.value.rstrip("?")
1104
+ if type_name in self.tables:
1105
+ fk_id = f"{field_name}_id"
1106
+ if fk_id not in all_snake_fields:
1107
+ fillable.append(fk_id)
1108
+ else:
1109
+ fillable.append(field_name)
1110
+ f = f.next
1111
+ if fillable:
1112
+ fillable_str = "', '".join(fillable)
1113
+ lines.append(f" protected $fillable = ['{fillable_str}'];")
1114
+
1115
+ # $casts — enum fields
1116
+ casts = []
1117
+ f = table_node.b
1118
+ while f:
1119
+ type_node = f.b
1120
+ type_name = type_node.value.rstrip("?")
1121
+ if type_name in self.enums:
1122
+ field_name = self._snake_case(f.a.value)
1123
+ casts.append(f" '{field_name}' => {type_name}::class,")
1124
+ elif type_name == "datetime":
1125
+ field_name = self._snake_case(f.a.value)
1126
+ casts.append(f" '{field_name}' => 'datetime',")
1127
+ f = f.next
1128
+
1129
+ if casts:
1130
+ lines.append("")
1131
+ lines.append(" protected function casts(): array")
1132
+ lines.append(" {")
1133
+ lines.append(" return [")
1134
+ lines.extend(casts)
1135
+ lines.append(" ];")
1136
+ lines.append(" }")
1137
+
1138
+ # Relationships — BelongsTo (FKs)
1139
+ has_rels = False
1140
+ f = table_node.b
1141
+ while f:
1142
+ type_node = f.b
1143
+ type_name = type_node.value.rstrip("?")
1144
+ if type_name in self.tables:
1145
+ if not has_rels:
1146
+ lines.append("")
1147
+ has_rels = True
1148
+ field_name = self._snake_case(f.a.value)
1149
+ parts = field_name.split("_")
1150
+ camel_method = parts[0] + "".join(p.title() for p in parts[1:])
1151
+ lines.append(f" public function {camel_method}(): BelongsTo")
1152
+ lines.append(" {")
1153
+ lines.append(f" return $this->belongsTo({type_name}::class, '{field_name}_id');")
1154
+ lines.append(" }")
1155
+ lines.append("")
1156
+ f = f.next
1157
+
1158
+ # Relationships — HasMany (reverse FKs)
1159
+ for other_name, fk_field in reverse_fks:
1160
+ other_snake = self._snake_case(other_name)
1161
+ other_plural = other_snake + "s"
1162
+ method_name = other_plural # e.g. "products" for Product → User
1163
+ lines.append(f" public function {method_name}(): HasMany")
1164
+ lines.append(" {")
1165
+ lines.append(f" return $this->hasMany({other_name}::class, '{fk_field}_id');")
1166
+ lines.append(" }")
1167
+ lines.append("")
1168
+
1169
+ lines.append("}")
1170
+ return "\n".join(lines)
1171
+
1172
+ # ── PHP Controller generator ─────────────────────────────────────
1173
+
1174
+ def _generate_php_controller(self, table_node, namespace):
1175
+ name = table_node.a.value
1176
+ table_pascal = self._pascal_case(name)
1177
+ table_snake = self._snake_case(name)
1178
+ table_kebab = self._snake_case(name).replace("_", "-")
1179
+
1180
+ pk = self._get_pk_field(table_node)
1181
+ pk_name = self._snake_case(pk.a.value) if pk else "id"
1182
+ pk_php_type = self._field_php_type(pk)[0] if pk else "int"
1183
+ pk_oa_type = self.STORM_TO_OA_TYPE.get(
1184
+ pk.b.value.rstrip("?") if pk else "int", "integer"
1185
+ )
1186
+
1187
+ # Get filterable FK columns
1188
+ fk_params = []
1189
+ f = table_node.b
1190
+ while f:
1191
+ type_node = f.b
1192
+ type_name = type_node.value.rstrip("?")
1193
+ if type_name in self.tables:
1194
+ fk_col = self._snake_case(f.a.value) + "_id"
1195
+ fk_name = type_name # PascalCase for tag
1196
+ fk_oa_type = self.STORM_TO_OA_TYPE.get(
1197
+ self._get_pk_type(self.tables[type_name]), "integer"
1198
+ )
1199
+ fk_params.append((fk_col, fk_name, fk_oa_type))
1200
+ f = f.next
1201
+
1202
+ # Build FK parameter annotations
1203
+ fk_param_lines = ""
1204
+ for fk_col, fk_name, fk_oa_type in fk_params:
1205
+ fk_param_lines += f"""
1206
+ #[OA\\Parameter(
1207
+ name: "filter[{fk_col}]",
1208
+ in: "query",
1209
+ description: "Filter by {fk_name} ID",
1210
+ required: false,
1211
+ schema: new OA\\Schema(type: "{fk_oa_type}")
1212
+ )]"""
1213
+
1214
+ # Build query params for index
1215
+ searchable_text_fields = []
1216
+ f = table_node.b
1217
+ while f:
1218
+ type_node = f.b
1219
+ type_name = type_node.value.rstrip("?")
1220
+ if type_name == "string":
1221
+ searchable_text_fields.append(self._snake_case(f.a.value))
1222
+ f = f.next
1223
+
1224
+ # Route prefix - lowercase kebab-case
1225
+ route_prefix = table_snake.replace("_", "-")
1226
+
1227
+ # ── FK paginate-by endpoints (inserted between show() and store()) ─
1228
+ fk_endpoint = ""
1229
+ f = table_node.b
1230
+ while f:
1231
+ type_node = f.b
1232
+ type_name = type_node.value.rstrip("?")
1233
+ if type_name in self.tables:
1234
+ fk_field_pascal = self._pascal_case(f.a.value)
1235
+ fk_param = self._camel_case(fk_field_pascal) + "Id" # e.g. ownerId
1236
+ fk_table_pascal = self._pascal_case(type_name)
1237
+ fk_table_lower = type_name.lower()
1238
+ fk_pk_type = self._get_pk_type(self.tables[type_name])
1239
+ fk_oa_param_type = self.STORM_TO_OA_TYPE.get(fk_pk_type, "integer")
1240
+ fk_endpoint += f"""
1241
+ #[OA\\Get(
1242
+ path: "/api/{route_prefix}/{fk_table_lower}/{{{fk_param}}}",
1243
+ summary: "Get paginated list of {table_pascal} by {fk_table_pascal}",
1244
+ tags: ["{table_pascal}"],
1245
+ description: "Retrieve a paginated list of {table_pascal} filtered by {fk_table_pascal} ID",
1246
+ operationId: "get{table_pascal}By{fk_table_pascal}",
1247
+ )]
1248
+ #[OA\\Parameter(
1249
+ name: "{fk_param}",
1250
+ in: "path",
1251
+ required: true,
1252
+ schema: new OA\\Schema(type: "{fk_oa_param_type}")
1253
+ )]
1254
+ #[OA\\Parameter(
1255
+ name: "page",
1256
+ in: "query",
1257
+ description: "Page number",
1258
+ required: false,
1259
+ schema: new OA\\Schema(type: "integer", default: 1)
1260
+ )]
1261
+ #[OA\\Parameter(
1262
+ name: "rows",
1263
+ in: "query",
1264
+ description: "Number of items per page",
1265
+ required: false,
1266
+ schema: new OA\\Schema(type: "integer", default: 10)
1267
+ )]
1268
+ #[OA\\Response(
1269
+ response: 200,
1270
+ description: "Successful operation",
1271
+ content: new OA\\JsonContent(ref: "#/components/schemas/Paginated{table_pascal}Response200")
1272
+ )]
1273
+ #[OA\\Response(
1274
+ response: 401,
1275
+ description: "Unauthenticated",
1276
+ content: new OA\\JsonContent(ref: "#/components/schemas/UnauthenticatedResponse")
1277
+ )]
1278
+ #[OA\\Response(
1279
+ response: 403,
1280
+ description: "Forbidden",
1281
+ content: new OA\\JsonContent(ref: "#/components/schemas/ForbiddenResponse")
1282
+ )]
1283
+ public function indexBy{fk_table_pascal}(${fk_param}): JsonResponse
1284
+ {{
1285
+ $data = $this->service->paginateBy{fk_table_pascal}(${fk_param}, request()->only(['page', 'rows']));
1286
+ return $this->ok($data);
1287
+ }}
1288
+ """
1289
+ f = f.next
1290
+
1291
+ # ── Enum-value paginate-by endpoints ──────────────────────────
1292
+ f = table_node.b
1293
+ while f:
1294
+ type_node = f.b
1295
+ type_name = type_node.value.rstrip("?")
1296
+ if type_name in self.enums:
1297
+ field_pascal = self._pascal_case(f.a.value)
1298
+ field_snake = self._snake_case(f.a.value)
1299
+ enum_name = type_name
1300
+ fk_endpoint += f"""
1301
+ #[OA\\Get(
1302
+ path: "/api/{route_prefix}/{enum_name.lower()}/{{{field_snake}}}",
1303
+ summary: "Get paginated list of {table_pascal} by {field_pascal}",
1304
+ tags: ["{table_pascal}"],
1305
+ description: "Retrieve a paginated list of {table_pascal} filtered by {field_pascal} value",
1306
+ operationId: "get{table_pascal}By{field_pascal}",
1307
+ )]
1308
+ #[OA\\Parameter(
1309
+ name: "{field_snake}",
1310
+ in: "path",
1311
+ required: true,
1312
+ schema: new OA\\Schema(type: "string")
1313
+ )]
1314
+ #[OA\\Parameter(
1315
+ name: "page",
1316
+ in: "query",
1317
+ description: "Page number",
1318
+ required: false,
1319
+ schema: new OA\\Schema(type: "integer", default: 1)
1320
+ )]
1321
+ #[OA\\Parameter(
1322
+ name: "rows",
1323
+ in: "query",
1324
+ description: "Number of items per page",
1325
+ required: false,
1326
+ schema: new OA\\Schema(type: "integer", default: 10)
1327
+ )]
1328
+ #[OA\\Response(
1329
+ response: 200,
1330
+ description: "Successful operation",
1331
+ content: new OA\\JsonContent(ref: "#/components/schemas/Paginated{table_pascal}Response200")
1332
+ )]
1333
+ #[OA\\Response(
1334
+ response: 401,
1335
+ description: "Unauthenticated",
1336
+ content: new OA\\JsonContent(ref: "#/components/schemas/UnauthenticatedResponse")
1337
+ )]
1338
+ #[OA\\Response(
1339
+ response: 403,
1340
+ description: "Forbidden",
1341
+ content: new OA\\JsonContent(ref: "#/components/schemas/ForbiddenResponse")
1342
+ )]
1343
+ public function indexBy{field_pascal}(${field_snake}): JsonResponse
1344
+ {{
1345
+ $data = $this->service->paginateBy{field_pascal}(${field_snake}, request()->only(['page', 'rows']));
1346
+ return $this->ok($data);
1347
+ }}
1348
+ """
1349
+ f = f.next
1350
+
1351
+ code = f"""<?php
1352
+
1353
+ namespace App\\Controllers;
1354
+
1355
+ use App\\Services\\{table_pascal}Service;
1356
+ use Illuminate\\Database\\Eloquent\\ModelNotFoundException;
1357
+ use Illuminate\\Http\\JsonResponse;
1358
+ use Illuminate\\Http\\Request;
1359
+ use Illuminate\\Routing\\Controller;
1360
+ use OpenApi\\Attributes as OA;
1361
+
1362
+ class {table_pascal}Controller extends Controller
1363
+ {{
1364
+ public function __construct(
1365
+ protected {table_pascal}Service $service
1366
+ ) {{
1367
+ }}
1368
+
1369
+ #[OA\\Get(
1370
+ path: "/api/{route_prefix}",
1371
+ summary: "Get paginated list of {table_pascal}",
1372
+ tags: ["{table_pascal}"],
1373
+ description: "Retrieve a paginated list of {table_pascal} with optional search",
1374
+ operationId: "get{table_pascal}Paginated",
1375
+ )]
1376
+ #[OA\\Parameter(
1377
+ name: "search",
1378
+ in: "query",
1379
+ description: "Search term",
1380
+ required: false,
1381
+ schema: new OA\\Schema(type: "string")
1382
+ )]
1383
+ #[OA\\Parameter(
1384
+ name: "page",
1385
+ in: "query",
1386
+ description: "Page number",
1387
+ required: false,
1388
+ schema: new OA\\Schema(type: "integer", default: 1)
1389
+ )]
1390
+ #[OA\\Parameter(
1391
+ name: "rows",
1392
+ in: "query",
1393
+ description: "Number of items per page",
1394
+ required: false,
1395
+ schema: new OA\\Schema(type: "integer", default: 10)
1396
+ )]{fk_param_lines}
1397
+ #[OA\\Response(
1398
+ response: 200,
1399
+ description: "Successful operation",
1400
+ content: new OA\\JsonContent(ref: "#/components/schemas/Paginated{table_pascal}Response200")
1401
+ )]
1402
+ #[OA\\Response(
1403
+ response: 401,
1404
+ description: "Unauthenticated",
1405
+ content: new OA\\JsonContent(ref: "#/components/schemas/UnauthenticatedResponse")
1406
+ )]
1407
+ #[OA\\Response(
1408
+ response: 403,
1409
+ description: "Forbidden",
1410
+ content: new OA\\JsonContent(ref: "#/components/schemas/ForbiddenResponse")
1411
+ )]
1412
+ public function index(Request $request): JsonResponse
1413
+ {{
1414
+ $filters = $request->query('filter', []);
1415
+ $data = $this->service->paginate(
1416
+ $request->query('search'),
1417
+ array_merge($request->only(['page', 'rows']), $filters ? ['filter' => $filters] : [])
1418
+ );
1419
+ return $this->ok($data);
1420
+ }}
1421
+
1422
+ #[OA\\Get(
1423
+ path: "/api/{route_prefix}/{{{pk_name}}}",
1424
+ summary: "Get a specific {table_pascal}",
1425
+ tags: ["{table_pascal}"],
1426
+ description: "Retrieve a {table_pascal} by its ID",
1427
+ operationId: "get{table_pascal}ById",
1428
+ )]
1429
+ #[OA\\Parameter(
1430
+ name: "{pk_name}",
1431
+ in: "path",
1432
+ required: true,
1433
+ schema: new OA\\Schema(type: "{pk_oa_type}")
1434
+ )]
1435
+ #[OA\\Response(
1436
+ response: 200,
1437
+ description: "Successful operation",
1438
+ content: new OA\\JsonContent(ref: "#/components/schemas/Get{table_pascal}Response200")
1439
+ )]
1440
+ #[OA\\Response(
1441
+ response: 401,
1442
+ description: "Unauthenticated",
1443
+ content: new OA\\JsonContent(ref: "#/components/schemas/UnauthenticatedResponse")
1444
+ )]
1445
+ #[OA\\Response(
1446
+ response: 403,
1447
+ description: "Forbidden",
1448
+ content: new OA\\JsonContent(ref: "#/components/schemas/ForbiddenResponse")
1449
+ )]
1450
+ #[OA\\Response(
1451
+ response: 404,
1452
+ description: "{table_pascal} not found"
1453
+ )]
1454
+ public function show(${pk_name}): JsonResponse
1455
+ {{
1456
+ try {{
1457
+ return $this->ok($this->service->getById(${pk_name}));
1458
+ }} catch (ModelNotFoundException $e) {{
1459
+ return $this->notFound('{table_pascal} not found');
1460
+ }}
1461
+ }}
1462
+ {fk_endpoint}
1463
+ #[OA\\Post(
1464
+ path: "/api/{route_prefix}/create",
1465
+ summary: "Create a new {table_pascal}",
1466
+ tags: ["{table_pascal}"],
1467
+ description: "Create a new {table_pascal} with the provided details",
1468
+ operationId: "create{table_pascal}",
1469
+ )]
1470
+ #[OA\\RequestBody(
1471
+ required: true,
1472
+ content: new OA\\JsonContent(ref: "#/components/schemas/{table_pascal}")
1473
+ )]
1474
+ #[OA\\Response(
1475
+ response: 200,
1476
+ description: "{table_pascal} created successfully",
1477
+ content: new OA\\JsonContent(ref: "#/components/schemas/Create{table_pascal}Response200")
1478
+ )]
1479
+ #[OA\\Response(
1480
+ response: 401,
1481
+ description: "Unauthenticated",
1482
+ content: new OA\\JsonContent(ref: "#/components/schemas/UnauthenticatedResponse")
1483
+ )]
1484
+ #[OA\\Response(
1485
+ response: 403,
1486
+ description: "Forbidden",
1487
+ content: new OA\\JsonContent(ref: "#/components/schemas/ForbiddenResponse")
1488
+ )]
1489
+ #[OA\\Response(
1490
+ response: 422,
1491
+ description: "Validation error",
1492
+ content: new OA\\JsonContent(ref: "#/components/schemas/ValidationErrorResponse")
1493
+ )]
1494
+ #[OA\\Response(
1495
+ response: 500,
1496
+ description: "Internal server error",
1497
+ content: new OA\\JsonContent(ref: "#/components/schemas/InternalServerErrorResponse")
1498
+ )]
1499
+ public function store(Request $request): JsonResponse
1500
+ {{
1501
+ $data = $request->validate($this->service->rules());
1502
+ return $this->ok($this->service->create($data));
1503
+ }}
1504
+
1505
+ #[OA\\Put(
1506
+ path: "/api/{route_prefix}/update/{{{pk_name}}}",
1507
+ summary: "Update a {table_pascal}",
1508
+ tags: ["{table_pascal}"],
1509
+ description: "Update an existing {table_pascal} with the provided details",
1510
+ operationId: "update{table_pascal}",
1511
+ )]
1512
+ #[OA\\Parameter(
1513
+ name: "{pk_name}",
1514
+ in: "path",
1515
+ required: true,
1516
+ schema: new OA\\Schema(type: "{pk_oa_type}"),
1517
+ )]
1518
+ #[OA\\RequestBody(
1519
+ required: true,
1520
+ content: new OA\\JsonContent(ref: "#/components/schemas/{table_pascal}")
1521
+ )]
1522
+ #[OA\\Response(
1523
+ response: 200,
1524
+ description: "{table_pascal} updated successfully",
1525
+ content: new OA\\JsonContent(ref: "#/components/schemas/Update{table_pascal}Response200")
1526
+ )]
1527
+ #[OA\\Response(
1528
+ response: 401,
1529
+ description: "Unauthenticated",
1530
+ content: new OA\\JsonContent(ref: "#/components/schemas/UnauthenticatedResponse")
1531
+ )]
1532
+ #[OA\\Response(
1533
+ response: 403,
1534
+ description: "Forbidden",
1535
+ content: new OA\\JsonContent(ref: "#/components/schemas/ForbiddenResponse")
1536
+ )]
1537
+ #[OA\\Response(
1538
+ response: 404,
1539
+ description: "{table_pascal} not found"
1540
+ )]
1541
+ #[OA\\Response(
1542
+ response: 422,
1543
+ description: "Validation error",
1544
+ content: new OA\\JsonContent(ref: "#/components/schemas/ValidationErrorResponse")
1545
+ )]
1546
+ #[OA\\Response(
1547
+ response: 500,
1548
+ description: "Internal server error",
1549
+ content: new OA\\JsonContent(ref: "#/components/schemas/InternalServerErrorResponse")
1550
+ )]
1551
+ public function update(Request $request, ${pk_name}): JsonResponse
1552
+ {{
1553
+ $data = $request->validate($this->service->rules(${pk_name}));
1554
+ try {{
1555
+ return $this->ok($this->service->update(${pk_name}, $data));
1556
+ }} catch (ModelNotFoundException $e) {{
1557
+ return $this->notFound('{table_pascal} not found');
1558
+ }}
1559
+ }}
1560
+
1561
+ #[OA\\Delete(
1562
+ path: "/api/{route_prefix}/delete/{{{pk_name}}}",
1563
+ summary: "Delete a {table_pascal}",
1564
+ tags: ["{table_pascal}"],
1565
+ description: "Delete a {table_pascal} by its ID",
1566
+ operationId: "delete{table_pascal}",
1567
+ )]
1568
+ #[OA\\Parameter(
1569
+ name: "{pk_name}",
1570
+ in: "path",
1571
+ required: true,
1572
+ schema: new OA\\Schema(type: "{pk_oa_type}")
1573
+ )]
1574
+ #[OA\\Response(
1575
+ response: 200,
1576
+ description: "{table_pascal} deleted successfully",
1577
+ content: new OA\\JsonContent(ref: "#/components/schemas/Delete{table_pascal}Response200")
1578
+ )]
1579
+ #[OA\\Response(
1580
+ response: 401,
1581
+ description: "Unauthenticated",
1582
+ content: new OA\\JsonContent(ref: "#/components/schemas/UnauthenticatedResponse")
1583
+ )]
1584
+ #[OA\\Response(
1585
+ response: 403,
1586
+ description: "Forbidden",
1587
+ content: new OA\\JsonContent(ref: "#/components/schemas/ForbiddenResponse")
1588
+ )]
1589
+ #[OA\\Response(
1590
+ response: 404,
1591
+ description: "{table_pascal} not found"
1592
+ )]
1593
+ #[OA\\Response(
1594
+ response: 500,
1595
+ description: "Internal server error",
1596
+ content: new OA\\JsonContent(ref: "#/components/schemas/InternalServerErrorResponse")
1597
+ )]
1598
+ public function destroy(${pk_name}): JsonResponse
1599
+ {{
1600
+ try {{
1601
+ $this->service->delete(${pk_name});
1602
+ return $this->ok(null, '{table_pascal} deleted successfully');
1603
+ }} catch (ModelNotFoundException $e) {{
1604
+ return $this->notFound('{table_pascal} not found');
1605
+ }}
1606
+ }}
1607
+ }}"""
1608
+ return code
1609
+
1610
+ # ── PHP Service generator ─────────────────────────────────────────
1611
+
1612
+ def _generate_php_service(self, table_node, namespace):
1613
+ name = table_node.a.value
1614
+ table_pascal = self._pascal_case(name)
1615
+ table_snake = self._snake_case(name)
1616
+
1617
+ # Build validation rules
1618
+ rules_lines = []
1619
+ f = table_node.b
1620
+ pk = self._get_pk_field(table_node)
1621
+ while f:
1622
+ field_name = self._snake_case(f.a.value)
1623
+ type_node = f.b
1624
+ type_name = type_node.value.rstrip("?")
1625
+ is_nullable = type_node.value.endswith("?")
1626
+ if pk and f.a.value == pk.a.value:
1627
+ f = f.next
1628
+ continue
1629
+
1630
+ ts = self.STORM_TO_MIGRATION.get(type_name, "string")
1631
+ rules = []
1632
+ if not is_nullable:
1633
+ rules.append("required")
1634
+ else:
1635
+ rules.append("nullable")
1636
+
1637
+ if ts in ("integer", "bigInteger"):
1638
+ rules.append("integer")
1639
+ elif ts in ("float", "double"):
1640
+ rules.append("numeric")
1641
+ elif ts == "string":
1642
+ rules.append("string")
1643
+ # Extract max from attrs if present
1644
+ if type_node.a and type_node.a.a:
1645
+ cur = type_node.a.a
1646
+ while cur:
1647
+ if cur.a.value == "max":
1648
+ try:
1649
+ rules.append(f"max:{cur.b.value}")
1650
+ except (ValueError, AttributeError):
1651
+ pass
1652
+ cur = cur.next
1653
+ elif ts == "uuid":
1654
+ rules.append("uuid")
1655
+ elif ts == "boolean":
1656
+ rules.append("boolean")
1657
+ elif ts == "dateTime":
1658
+ rules.append("date")
1659
+
1660
+ if type_name in self.tables:
1661
+ rules.append(f"exists:{self._snake_case(type_name)},id")
1662
+
1663
+ rules_lines.append(f" '{field_name}' => '{'|'.join(rules)}',")
1664
+ f = f.next
1665
+
1666
+ rules_block = "\n".join(rules_lines)
1667
+
1668
+ # FK fields for filters
1669
+ fk_filters = []
1670
+ f = table_node.b
1671
+ while f:
1672
+ type_node = f.b
1673
+ type_name = type_node.value.rstrip("?")
1674
+ if type_name in self.tables:
1675
+ fk_col = self._snake_case(f.a.value) + "_id"
1676
+ fk_filters.append(fk_col)
1677
+ f = f.next
1678
+
1679
+ has_filters_line = f" protected array $filterable = ['" + "', '".join(fk_filters) + "'];" if fk_filters else " protected array $filterable = [];"
1680
+
1681
+ # FK pagination methods (like C# PaginateByOwnerAsync)
1682
+ fk_methods = ""
1683
+ f = table_node.b
1684
+ while f:
1685
+ type_node = f.b
1686
+ type_name = type_node.value.rstrip("?")
1687
+ if type_name in self.tables:
1688
+ fk_field_pascal = self._pascal_case(f.a.value)
1689
+ fk_param = self._camel_case(fk_field_pascal) + "Id" # e.g. ownerId
1690
+ fk_db_col = self._snake_case(f.a.value) + "_id" # e.g. owner_id (DB column)
1691
+ fk_table_pascal = self._pascal_case(type_name)
1692
+ fk_methods += f"""
1693
+ public function paginateBy{fk_table_pascal}(mixed ${fk_param}, array $options = []): \\Illuminate\\Contracts\\Pagination\\LengthAwarePaginator
1694
+ {{
1695
+ $query = {table_pascal}::where('{fk_db_col}', ${fk_param});
1696
+
1697
+ $page = (int)($options['page'] ?? 1);
1698
+ $rows = (int)($options['rows'] ?? 10);
1699
+
1700
+ return $query->paginate($rows, ['*'], 'page', $page);
1701
+ }}
1702
+ """
1703
+ f = f.next
1704
+
1705
+ # Enum pagination methods (like C# PaginateByStatusAsync)
1706
+ f = table_node.b
1707
+ while f:
1708
+ type_node = f.b
1709
+ type_name = type_node.value.rstrip("?")
1710
+ if type_name in self.enums:
1711
+ field_pascal = self._pascal_case(f.a.value)
1712
+ field_snake = self._snake_case(f.a.value)
1713
+ enum_name = type_name
1714
+ fk_methods += f"""
1715
+ public function paginateBy{field_pascal}(${field_snake}, array $options = []): \\Illuminate\\Contracts\\Pagination\\LengthAwarePaginator
1716
+ {{
1717
+ $query = {table_pascal}::where('{field_snake}', ${field_snake});
1718
+
1719
+ $page = (int)($options['page'] ?? 1);
1720
+ $rows = (int)($options['rows'] ?? 10);
1721
+
1722
+ return $query->paginate($rows, ['*'], 'page', $page);
1723
+ }}
1724
+ """
1725
+ f = f.next
1726
+
1727
+ return f"""<?php
1728
+
1729
+ namespace App\\Services;
1730
+
1731
+ use App\\Models\\{table_pascal};
1732
+ use Illuminate\\Database\\Eloquent\\ModelNotFoundException;
1733
+
1734
+ class {table_pascal}Service
1735
+ {{
1736
+ {has_filters_line}
1737
+
1738
+ /** Searchable text fields. */
1739
+ protected array $searchable = ['name'];
1740
+
1741
+ public function paginate(?string $search = null, array $options = [])
1742
+ {{
1743
+ $query = {table_pascal}::query();
1744
+
1745
+ if ($search && $this->searchable) {{
1746
+ $query->where(function ($q) use ($search) {{
1747
+ foreach ($this->searchable as $field) {{
1748
+ $q->orWhere($field, 'like', "%{{$search}}%");
1749
+ }}
1750
+ }});
1751
+ }}
1752
+
1753
+ // Apply filters
1754
+ $filters = $options['filter'] ?? [];
1755
+ foreach ($filters as $key => $value) {{
1756
+ if (in_array($key, $this->filterable)) {{
1757
+ $query->where($key, $value);
1758
+ }}
1759
+ }}
1760
+
1761
+ $page = (int)($options['page'] ?? 1);
1762
+ $rows = (int)($options['rows'] ?? 10);
1763
+
1764
+ return $query->paginate($rows, ['*'], 'page', $page);
1765
+ }}
1766
+
1767
+ public function getById($id): {table_pascal}
1768
+ {{
1769
+ return {table_pascal}::findOrFail($id);
1770
+ }}
1771
+ {fk_methods}
1772
+ public function create(array $data): {table_pascal}
1773
+ {{
1774
+ return {table_pascal}::create($data);
1775
+ }}
1776
+
1777
+ public function update($id, array $data): {table_pascal}
1778
+ {{
1779
+ $model = {table_pascal}::findOrFail($id);
1780
+ $model->update($data);
1781
+ return $model->fresh();
1782
+ }}
1783
+
1784
+ public function delete($id): void
1785
+ {{
1786
+ $model = {table_pascal}::findOrFail($id);
1787
+ $model->delete();
1788
+ }}
1789
+
1790
+ public function rules($id = null): array
1791
+ {{
1792
+ return [
1793
+ {rules_block}
1794
+ ];
1795
+ }}
1796
+ }}"""
1797
+
1798
+ # ── PHP Migration generator ───────────────────────────────────────
1799
+
1800
+ def _generate_php_migration(self, table_node, namespace):
1801
+ name = table_node.a.value
1802
+ table_snake = self._snake_case(name)
1803
+ table_plural = table_snake + "s" # Simple plural
1804
+
1805
+ lines = [
1806
+ "<?php",
1807
+ "",
1808
+ "use Illuminate\\Database\\Migrations\\Migration;",
1809
+ "use Illuminate\\Database\\Schema\\Blueprint;",
1810
+ "use Illuminate\\Support\\Facades\\Schema;",
1811
+ "",
1812
+ "return new class extends Migration",
1813
+ "{",
1814
+ " public function up(): void",
1815
+ " {",
1816
+ f" Schema::create('{table_plural}', function (Blueprint $table) {{",
1817
+ ]
1818
+
1819
+ f = table_node.b
1820
+ pk = self._get_pk_field(table_node)
1821
+ has_pk = False
1822
+ while f:
1823
+ field_name = self._snake_case(f.a.value)
1824
+ type_node = f.b
1825
+ type_name = type_node.value.rstrip("?")
1826
+ is_nullable = type_node.value.endswith("?")
1827
+
1828
+ ts = self.STORM_TO_MIGRATION.get(type_name)
1829
+
1830
+ if pk and f.a.value == pk.a.value:
1831
+ # Primary key
1832
+ if ts == "uuid":
1833
+ lines.append(f" $table->uuid('{field_name}')->primary();")
1834
+ elif ts == "integer":
1835
+ lines.append(f" $table->id('{field_name}');")
1836
+ else:
1837
+ lines.append(f" $table->id('{field_name}');")
1838
+ has_pk = True
1839
+ elif type_name in self.tables:
1840
+ # Foreign key with cascade delete
1841
+ lines.append(f" $table->foreignId('{field_name}_id')->constrained('{self._snake_case(type_name)}s')->onDelete('cascade');")
1842
+ elif ts:
1843
+ col_def = f" $table->{ts}('{field_name}')"
1844
+ if is_nullable:
1845
+ col_def += "->nullable()"
1846
+ if ts == "string":
1847
+ max_len = 255
1848
+ # Check for max attribute
1849
+ if type_node.a:
1850
+ attrs_node = type_node.a
1851
+ if attrs_node and attrs_node.a:
1852
+ cur = attrs_node.a
1853
+ while cur:
1854
+ if cur.a.value == "max":
1855
+ try:
1856
+ max_len = int(cur.b.value)
1857
+ except ValueError:
1858
+ pass
1859
+ cur = cur.next
1860
+ col_def = f" $table->string('{field_name}', {max_len})"
1861
+ if is_nullable:
1862
+ col_def += "->nullable()"
1863
+ lines.append(col_def + ";")
1864
+ f = f.next
1865
+
1866
+ # Add timestamps if not already present
1867
+ has_created = False
1868
+ has_updated = False
1869
+ f = table_node.b
1870
+ while f:
1871
+ fn = self._snake_case(f.a.value)
1872
+ if fn == "created_at":
1873
+ has_created = True
1874
+ if fn == "updated_at":
1875
+ has_updated = True
1876
+ f = f.next
1877
+ if has_created and has_updated:
1878
+ pass # Handled by field decls above
1879
+ else:
1880
+ lines.append(" $table->timestamps();")
1881
+
1882
+ lines.append(" });")
1883
+ lines.append(" }")
1884
+ lines.append("")
1885
+ lines.append(" public function down(): void")
1886
+ lines.append(" {")
1887
+ lines.append(f" Schema::dropIfExists('{table_plural}');")
1888
+ lines.append(" }")
1889
+ lines.append("};")
1890
+
1891
+ return "\n".join(lines)
1892
+
1893
+ # ── PHP Base Controller ───────────────────────────────────────────
1894
+
1895
+ PHP_BASE_CONTROLLER = """<?php
1896
+
1897
+ namespace App\\Controllers;
1898
+
1899
+ use Illuminate\\Routing\\Controller as BaseController;
1900
+ use Illuminate\\Http\\JsonResponse;
1901
+ use OpenApi\\Attributes as OA;
1902
+
1903
+ #[OA\\Schema(
1904
+ schema: "UnauthenticatedResponse",
1905
+ type: "object",
1906
+ properties: [
1907
+ new OA\\Property(property: "success", type: "boolean", example: false),
1908
+ new OA\\Property(property: "message", type: "string", example: "Unauthenticated"),
1909
+ ]
1910
+ )]
1911
+ #[OA\\Schema(
1912
+ schema: "ForbiddenResponse",
1913
+ type: "object",
1914
+ properties: [
1915
+ new OA\\Property(property: "success", type: "boolean", example: false),
1916
+ new OA\\Property(property: "message", type: "string", example: "Forbidden"),
1917
+ ]
1918
+ )]
1919
+ #[OA\\Schema(
1920
+ schema: "ValidationErrorResponse",
1921
+ type: "object",
1922
+ properties: [
1923
+ new OA\\Property(property: "success", type: "boolean", example: false),
1924
+ new OA\\Property(property: "message", type: "string", example: "Validation error"),
1925
+ new OA\\Property(property: "errors", type: "object"),
1926
+ ]
1927
+ )]
1928
+ #[OA\\Schema(
1929
+ schema: "InternalServerErrorResponse",
1930
+ type: "object",
1931
+ properties: [
1932
+ new OA\\Property(property: "success", type: "boolean", example: false),
1933
+ new OA\\Property(property: "message", type: "string", example: "Internal server error"),
1934
+ ]
1935
+ )]
1936
+ class Controller extends BaseController
1937
+ {
1938
+ protected function ok(mixed $data = null, string $message = 'Success', int $status = 200): JsonResponse
1939
+ {
1940
+ $response = [
1941
+ 'success' => true,
1942
+ 'message' => $message,
1943
+ ];
1944
+ if ($data !== null) {
1945
+ $response['data'] = $data;
1946
+ }
1947
+ return response()->json($response, $status);
1948
+ }
1949
+
1950
+ protected function notFound(string $message = 'Resource not found'): JsonResponse
1951
+ {
1952
+ return response()->json([
1953
+ 'success' => false,
1954
+ 'message' => $message,
1955
+ ], 404);
1956
+ }
1957
+ }"""
1958
+
1959
+ # ── PHP Route generator ───────────────────────────────────────────
1960
+
1961
+ def _generate_php_routes(self):
1962
+ """Generate routes/api.php with all resource routes."""
1963
+ lines = [
1964
+ "<?php",
1965
+ "",
1966
+ "use Illuminate\\Support\\Facades\\Route;",
1967
+ "use App\\Controllers\\Controller;",
1968
+ ]
1969
+
1970
+ for name in self._ordered:
1971
+ name_pascal = self._pascal_case(name)
1972
+ name_kebab = self._snake_case(name).replace("_", "-")
1973
+ lines.append(f"use App\\Controllers\\{name_pascal}Controller;")
1974
+
1975
+ lines.append("")
1976
+ lines.append("Route::prefix('api')->group(function () {")
1977
+
1978
+ for name in self._ordered:
1979
+ name_pascal = self._pascal_case(name)
1980
+ name_kebab = self._snake_case(name).replace("_", "-")
1981
+ lines.append(f" Route::get('/{name_kebab}', [{name_pascal}Controller::class, 'index']);")
1982
+ lines.append(f" Route::get('/{name_kebab}/{{id}}', [{name_pascal}Controller::class, 'show']);")
1983
+
1984
+ # FK paginate-by routes
1985
+ node = self.tables[name]
1986
+ f = node.b
1987
+ while f:
1988
+ type_node = f.b
1989
+ type_name = type_node.value.rstrip("?")
1990
+ if type_name in self.tables:
1991
+ fk_param = self._camel_case(self._pascal_case(f.a.value)) + "Id" # ownerId
1992
+ fk_table_lower = type_name.lower() # user
1993
+ fk_table_pascal = self._pascal_case(type_name)
1994
+ lines.append(f" Route::get('/{name_kebab}/{fk_table_lower}/{{{fk_param}}}', [{name_pascal}Controller::class, 'indexBy{fk_table_pascal}']);")
1995
+ f = f.next
1996
+
1997
+ # Enum-value paginate-by routes
1998
+ f = node.b
1999
+ while f:
2000
+ type_node = f.b
2001
+ type_name = type_node.value.rstrip("?")
2002
+ if type_name in self.enums:
2003
+ field_snake = self._snake_case(f.a.value)
2004
+ field_pascal = self._pascal_case(f.a.value)
2005
+ enum_lower = type_name.lower()
2006
+ lines.append(f" Route::get('/{name_kebab}/{enum_lower}/{{{field_snake}}}', [{name_pascal}Controller::class, 'indexBy{field_pascal}']);")
2007
+ f = f.next
2008
+
2009
+ lines.append(f" Route::post('/{name_kebab}/create', [{name_pascal}Controller::class, 'store']);")
2010
+ lines.append(f" Route::put('/{name_kebab}/update/{{id}}', [{name_pascal}Controller::class, 'update']);")
2011
+ lines.append(f" Route::delete('/{name_kebab}/delete/{{id}}', [{name_pascal}Controller::class, 'destroy']);")
2012
+ lines.append("")
2013
+
2014
+ lines.append("});")
2015
+ return "\n".join(lines)
2016
+
2017
+ # ── PHP AppServiceProvider ────────────────────────────────────────
2018
+
2019
+ PHP_APP_SERVICE_PROVIDER = """<?php
2020
+
2021
+ namespace App\\Providers;
2022
+
2023
+ use Illuminate\\Support\\ServiceProvider;
2024
+
2025
+ class AppServiceProvider extends ServiceProvider
2026
+ {
2027
+ public function register(): void
2028
+ {
2029
+ // Bind services here
2030
+ }
2031
+
2032
+ public function boot(): void
2033
+ {
2034
+ //
2035
+ }
2036
+ }"""
2037
+
2038
+ # ── entry point ──────────────────────────────────────────────────
2039
+
2040
+ def generate(self, config, project_name, output_dir="."):
2041
+ # Detect template: Laravel = MigrationsPath present, C# = DbContextPath
2042
+ is_laravel = "MigrationsPath" in config
2043
+
2044
+ if is_laravel:
2045
+ self._generate_laravel(config, output_dir)
2046
+ else:
2047
+ self._generate_csharp(config, project_name, output_dir)
2048
+
2049
+ def _generate_laravel(self, config, output_dir):
2050
+ """Generate PHP/Laravel code from the schema."""
2051
+ print("\nGenerating Laravel PHP code...")
2052
+
2053
+ # ── Base Controller ───────────────────────────────────────────
2054
+ self._write_file(
2055
+ output_dir, config["ControllerPath"], "Controller.php",
2056
+ self.PHP_BASE_CONTROLLER,
2057
+ )
2058
+
2059
+ # ── Enums ─────────────────────────────────────────────────────
2060
+ for enum_node in self.enums.values():
2061
+ enum_code = self._generate_php_enum(enum_node)
2062
+ ename = enum_node.a.value
2063
+ self._write_file(output_dir, config["EnumPath"], f"{ename}.php", enum_code)
2064
+
2065
+ # ── Per-table files ───────────────────────────────────────────
2066
+ for name in self._ordered:
2067
+ node = self.tables[name]
2068
+
2069
+ # Model
2070
+ model_code = self._generate_php_model(node, config["ModelPath"])
2071
+ self._write_file(output_dir, config["ModelPath"], f"{name}.php", model_code)
2072
+
2073
+ # Service
2074
+ svc_code = self._generate_php_service(node, config["ServicePath"])
2075
+ self._write_file(output_dir, config["ServicePath"], f"{name}Service.php", svc_code)
2076
+
2077
+ # Controller
2078
+ ctrl_code = self._generate_php_controller(node, config["ControllerPath"])
2079
+ self._write_file(output_dir, config["ControllerPath"], f"{name}Controller.php", ctrl_code)
2080
+
2081
+ # Migration
2082
+ mig_code = self._generate_php_migration(node, config["MigrationsPath"])
2083
+ # Migration filename uses timestamp prefix
2084
+ import datetime
2085
+ ts = datetime.datetime.now().strftime("%Y_%m_%d_%H%M%S")
2086
+ table_snake = self._snake_case(name)
2087
+ mig_filename = f"{ts}_create_{table_snake}s_table.php"
2088
+ self._write_file(output_dir, config["MigrationsPath"], mig_filename, mig_code)
2089
+
2090
+ # ── Routes ────────────────────────────────────────────────────
2091
+ routes_code = self._generate_php_routes()
2092
+ self._write_file(output_dir, "routes", "api.php", routes_code)
2093
+
2094
+ print(" [ok] code generation complete")
2095
+ return
2096
+
2097
+ # ── C# / .NET generation ─────────────────────────────────────
2098
+ def _generate_csharp(self, config, project_name, output_dir):
2099
+ ns = self._build_namespaces(config, project_name)
2100
+
2101
+ model_ns = ns.get("ModelPath", project_name)
2102
+ dto_ns = ns.get("DtoPath", project_name)
2103
+ controller_ns = ns.get("ControllerPath", project_name)
2104
+ iservice_ns = ns.get("IServicePath", ns.get("IServicesPath", project_name))
2105
+ service_ns = ns.get("ServicePath", project_name)
2106
+ mapper_ns = ns.get("MapperPath", project_name)
2107
+ enum_ns = ns.get("EnumPath", project_name)
2108
+ pagination_ns = f"{dto_ns}.Shared"
2109
+
2110
+ print("\nGenerating code...")
2111
+
2112
+ # ── base generic files (once) ─────────────────────────────────
2113
+ base_vars = {
2114
+ "config_dto_path": dto_ns, "config_pagination_path": pagination_ns,
2115
+ "config_iservice_path": iservice_ns, "config_service_path": service_ns,
2116
+ "config_model_path": model_ns, "config_mapper_path": mapper_ns,
2117
+ "config_controller_path": controller_ns,
2118
+ }
2119
+
2120
+ isvc_base = self._substitute_template(GENERIC_ISERVICE_CSHARP, {
2121
+ k: v for k, v in base_vars.items() if k in GENERIC_ISERVICE_CSHARP
2122
+ })
2123
+ svc_base = self._substitute_template(GENERIC_SERVICE_CSHARP, {
2124
+ k: v for k, v in base_vars.items() if k in GENERIC_SERVICE_CSHARP
2125
+ })
2126
+ ctrl_base = self._substitute_template(GENERIC_CONTROLLER_CSHARP, {
2127
+ k: v for k, v in base_vars.items() if k in GENERIC_CONTROLLER_CSHARP
2128
+ })
2129
+ pag_base = self._substitute_template(GENERIC_PAGINATION_CSHARP, {"config_pagination_path": pagination_ns})
2130
+ query_base = self._substitute_template(GENERIC_QUERY_CSHARP, {"config_pagination_path": pagination_ns})
2131
+
2132
+ self._write_file(output_dir, config.get("IServicesPath", config.get("IServicePath", ".")), "IGenericService.cs", isvc_base)
2133
+ self._write_file(output_dir, config["ServicePath"], "GenericService.cs", svc_base)
2134
+ self._write_file(output_dir, config["ControllerPath"], "GenericController.cs", ctrl_base)
2135
+ self._write_file(output_dir, config["DtoPath"] + "/Shared", "PaginatedResult.cs", pag_base)
2136
+ self._write_file(output_dir, config["DtoPath"] + "/Shared", "PaginateQuery.cs", query_base)
2137
+
2138
+ # ── IServiceMarker (for Scrutor assembly scanning) ────────────
2139
+ # ── Program.cs ───────────────────────────────────────────────
2140
+ is_clean = "IServicesPath" in config
2141
+ proj_ns = project_name
2142
+ first_svc = f"{self._ordered[0]}Service" if self._ordered else "Service"
2143
+ svc_ns = f"{proj_ns}.INFRASTRUCTURE.Services" if is_clean else f"{proj_ns}.Services"
2144
+
2145
+ usings = [
2146
+ "using System.Reflection;",
2147
+ "using Microsoft.AspNetCore.Identity;",
2148
+ "using Microsoft.EntityFrameworkCore;",
2149
+ "using Scrutor;",
2150
+ "using Scalar.AspNetCore;",
2151
+ ]
2152
+ if is_clean:
2153
+ usings += [
2154
+ f"using {proj_ns}.DOMAIN.Models;",
2155
+ f"using {proj_ns}.APPLICATION.IServices;",
2156
+ f"using {proj_ns}.APPLICATION.Dtos.Shared;",
2157
+ f"using {proj_ns}.INFRASTRUCTURE.Data;",
2158
+ f"using {proj_ns}.INFRASTRUCTURE.Services;",
2159
+ f"using {proj_ns}.INFRASTRUCTURE.Mappers;",
2160
+ ]
2161
+ else:
2162
+ usings += [
2163
+ f"using {proj_ns}.Data;",
2164
+ f"using {proj_ns}.IServices;",
2165
+ f"using {proj_ns}.Services;",
2166
+ f"using {proj_ns}.Mappers;",
2167
+ f"using {proj_ns}.Models;",
2168
+ ]
2169
+
2170
+ pg_content = f"""\
2171
+ {chr(10).join(usings)}
2172
+
2173
+ var builder = WebApplication.CreateBuilder(args);
2174
+
2175
+ builder.Services.AddDbContext<AppDbContext>(options =>
2176
+ options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
2177
+
2178
+ // ── Identity ────────────────────────────────────────────────────────
2179
+ builder.Services.AddIdentity<User, IdentityRole<Guid>>()
2180
+ .AddEntityFrameworkStores<AppDbContext>()
2181
+ .AddTokenProvider<DataProtectorTokenProvider<User>>(TokenOptions.DefaultProvider)
2182
+ .AddDefaultTokenProviders();
2183
+
2184
+ builder.Services.ConfigureApplicationCookie(options =>
2185
+ {{
2186
+ options.Cookie.Name = "authToken";
2187
+ options.Cookie.HttpOnly = true;
2188
+ options.Cookie.IsEssential = true;
2189
+ options.Cookie.MaxAge = TimeSpan.FromDays(7);
2190
+ options.Cookie.Path = "/";
2191
+ options.Cookie.SameSite = SameSiteMode.None;
2192
+ var isProduction = string.Equals(
2193
+ Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development",
2194
+ "Production", StringComparison.OrdinalIgnoreCase);
2195
+ options.Cookie.SecurePolicy = isProduction
2196
+ ? CookieSecurePolicy.Always
2197
+ : CookieSecurePolicy.SameAsRequest;
2198
+ }});
2199
+
2200
+ builder.Services.Configure<IdentityOptions>(options =>
2201
+ {{
2202
+ options.Password.RequireDigit = false;
2203
+ options.Password.RequireLowercase = false;
2204
+ options.Password.RequireNonAlphanumeric = false;
2205
+ options.Password.RequireUppercase = false;
2206
+ options.Password.RequiredLength = 3;
2207
+ options.Password.RequiredUniqueChars = 0;
2208
+ options.User.RequireUniqueEmail = true;
2209
+ }});
2210
+
2211
+ // Register AutoMapper with the executing assembly
2212
+ builder.Services.AddAutoMapper(cfg =>
2213
+ {{
2214
+ cfg.AddMaps(Assembly.GetExecutingAssembly());
2215
+ }});
2216
+
2217
+ // Register all services that inherit from GenericService<,,,> in the assembly of {first_svc}
2218
+ builder.Services.Scan(scan => scan
2219
+ .FromAssemblyOf<{first_svc}>()
2220
+ .AddClasses(classes => classes
2221
+ .InNamespaces("{svc_ns}")
2222
+ .AssignableTo(typeof(GenericService<,,,>)))
2223
+ .AsImplementedInterfaces()
2224
+ .WithScopedLifetime());
2225
+
2226
+ builder.Services.AddControllers();
2227
+ builder.Services.AddOpenApi();
2228
+
2229
+ var app = builder.Build();
2230
+
2231
+ // ── Swagger / Scalar ────────────────────────────────────────────────
2232
+ app.UseSwagger(options =>
2233
+ {{
2234
+ options.RouteTemplate = "openapi/{{documentName}}.json";
2235
+ }});
2236
+ app.MapScalarApiReference(options =>
2237
+ {{
2238
+ options.WithTitle("{proj_ns}");
2239
+ options.WithTheme(ScalarTheme.BluePlanet);
2240
+ options.WithDefaultHttpClient(ScalarTarget.JavaScript, ScalarClient.Axios);
2241
+ options.WithPreferredScheme("Bearer");
2242
+ }});
2243
+
2244
+ app.UseHttpsRedirection();
2245
+ app.UseAuthentication();
2246
+ app.UseAuthorization();
2247
+ app.MapControllers();
2248
+ app.Run();
2249
+ """
2250
+ if is_clean:
2251
+ self._write_file(output_dir, "API", "Program.cs", pg_content)
2252
+ else:
2253
+ self._write_file(output_dir, ".", "Program.cs", pg_content)
2254
+
2255
+ # ── AppDbContext ──────────────────────────────────────────────
2256
+ dbcontext_ns = ns.get("DbContextPath", project_name)
2257
+ dbcontext_code = self._generate_appdbcontext(dbcontext_ns, model_ns)
2258
+ self._write_file(output_dir, config["DbContextPath"], "AppDbContext.cs", dbcontext_code)
2259
+
2260
+ # ── per-table files ──────────────────────────────────────────
2261
+ for name in self._ordered:
2262
+ node = self.tables[name]
2263
+ pk_type = self._get_pk_type(node)
2264
+ table_dto_ns = f"{dto_ns}.{name}Dto"
2265
+ table_dto_path = config["DtoPath"] + f"/{name}Dto"
2266
+
2267
+ model_code = self._generate_model(node, model_ns, enum_ns)
2268
+ self._write_file(output_dir, config["ModelPath"], f"{name}.cs", model_code)
2269
+
2270
+ req_code = self._generate_request_dto(node, table_dto_ns, enum_ns)
2271
+ self._write_file(output_dir, table_dto_path, f"{name}RequestDto.cs", req_code)
2272
+
2273
+ res_code = self._generate_response_dto(node, table_dto_ns, enum_ns)
2274
+ self._write_file(output_dir, table_dto_path, f"{name}ResponseDto.cs", res_code)
2275
+
2276
+ simp_code = self._generate_response_simplified_dto(node, table_dto_ns, enum_ns)
2277
+ self._write_file(output_dir, table_dto_path, f"{name}ResponseSimplifiedDto.cs", simp_code)
2278
+
2279
+ isvc_code = self._generate_iservice(name, iservice_ns, pk_type, iservice_ns, model_ns, table_dto_ns, pagination_ns)
2280
+ self._write_file(output_dir, config.get("IServicesPath", config.get("IServicePath", ".")), f"I{name}Service.cs", isvc_code)
2281
+
2282
+ svc_code = self._generate_service(name, service_ns, pk_type, service_ns, iservice_ns, model_ns, table_dto_ns, pagination_ns)
2283
+ self._write_file(output_dir, config["ServicePath"], f"{name}Service.cs", svc_code)
2284
+
2285
+ ctrl_code = self._generate_controller(name, controller_ns, pk_type, controller_ns, iservice_ns, table_dto_ns, pagination_ns, model_ns)
2286
+ self._write_file(output_dir, config["ControllerPath"], f"{name}Controller.cs", ctrl_code)
2287
+
2288
+ map_code = self._generate_mapper(name, mapper_ns, model_ns, table_dto_ns)
2289
+ self._write_file(output_dir, config["MapperPath"], f"{name}MappingProfile.cs", map_code)
2290
+
2291
+ # ── per-enum files ───────────────────────────────────────────
2292
+ for enum_node in self.enums.values():
2293
+ enum_code = self._generate_csharp_enum(enum_node, enum_ns)
2294
+ ename = enum_node.a.value
2295
+ self._write_file(output_dir, config["EnumPath"], f"{ename}.cs", enum_code)
2296
+
2297
+ print(" [ok] code generation complete")
2298
+
2299
+
2300
+