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.
- main.py +753 -0
- src/__init__.py +2 -0
- src/ast.py +100 -0
- src/column.py +56 -0
- src/enum.py +21 -0
- src/error_handler.py +35 -0
- src/generic_controller_csharp.py +106 -0
- src/generic_mapper_csharp.py +35 -0
- src/generic_pagination_csharp.py +44 -0
- src/generic_query_chsarp.py +18 -0
- src/generic_service_csharp.py +112 -0
- src/interpreter.py +2300 -0
- src/keyword.py +18 -0
- src/parser.py +397 -0
- src/pos.py +11 -0
- src/table.py +37 -0
- src/template.py +14 -0
- src/tok.py +12 -0
- src/tok_type.py +13 -0
- src/tokenizer.py +238 -0
- storm_cli-1.0.0.dist-info/METADATA +350 -0
- storm_cli-1.0.0.dist-info/RECORD +26 -0
- storm_cli-1.0.0.dist-info/WHEEL +5 -0
- storm_cli-1.0.0.dist-info/entry_points.txt +2 -0
- storm_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- storm_cli-1.0.0.dist-info/top_level.txt +2 -0
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
|
+
|