swallowkit 1.0.0-beta.16 → 1.0.0-beta.18

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.
@@ -18,6 +18,10 @@ export function generateCompactAzureFunctionsCRUD(model: ModelInfo, sharedPackag
18
18
  const modelKebab = toKebabCase(modelName);
19
19
  const schemaName = model.schemaName;
20
20
 
21
+ const partitionKeyPath = model.partitionKey; // e.g. "/tenantId"
22
+ const partitionKeyField = partitionKeyPath.slice(1); // e.g. "tenantId"
23
+ const isIdPartition = partitionKeyField === 'id';
24
+
21
25
  const hasAuth = !!authPolicy;
22
26
  const authImport = hasAuth ? `\n${generateAuthImportTS()}\n` : '';
23
27
  const readGuard = hasAuth ? `\n${generateAuthGuardTS(authPolicy!, 'read')}\n` : '';
@@ -27,6 +31,34 @@ export function generateCompactAzureFunctionsCRUD(model: ModelInfo, sharedPackag
27
31
  if (authErr) return authErr;\n`
28
32
  : '';
29
33
 
34
+ // SDK クライアント初期化ヘルパー(PK≠/id の場合、および delete で使用)
35
+ const sdkClientInit = `const { CosmosClient } = await import('@azure/cosmos');
36
+ const endpoint = process.env.CosmosDBConnection__accountEndpoint;
37
+ let client: InstanceType<typeof CosmosClient>;
38
+ if (endpoint) {
39
+ const { DefaultAzureCredential } = await import('@azure/identity');
40
+ client = new CosmosClient({ endpoint, aadCredentials: new DefaultAzureCredential() });
41
+ } else {
42
+ client = new CosmosClient(process.env.CosmosDBConnection!);
43
+ }
44
+ const database = client.database(process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase');
45
+ const container = database.container(containerName);`;
46
+
47
+ // getById ハンドラー: PK=/id の場合は input binding、それ以外は SDK
48
+ const getByIdHandler = isIdPartition
49
+ ? generateGetByIdWithBinding(modelCamel, schemaName, readGuard, authCatchBlock)
50
+ : generateGetByIdWithSdk(modelCamel, schemaName, readGuard, authCatchBlock, sdkClientInit);
51
+
52
+ // update ハンドラー: PK=/id の場合は input binding、それ以外は SDK
53
+ const updateHandler = isIdPartition
54
+ ? generateUpdateWithBinding(modelCamel, schemaName, writeGuard, authCatchBlock)
55
+ : generateUpdateWithSdk(modelCamel, schemaName, partitionKeyField, writeGuard, authCatchBlock, sdkClientInit);
56
+
57
+ // delete ハンドラー: 常に SDK(既存パターン)、PK に応じて分岐
58
+ const deleteHandler = isIdPartition
59
+ ? generateDeleteIdPartition(modelCamel, writeGuard, authCatchBlock, sdkClientInit)
60
+ : generateDeleteCustomPartition(modelCamel, partitionKeyField, writeGuard, authCatchBlock, sdkClientInit);
61
+
30
62
  return `import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
31
63
  import { z } from 'zod/v4';
32
64
  import crypto from 'crypto';
@@ -68,38 +100,7 @@ ${authCatchBlock} context.error(\`Error fetching from \${containerName}:\`,
68
100
  },
69
101
  });
70
102
 
71
- // GET /api/${modelCamel}/{id} - ID指定取得
72
- app.http('${modelCamel}-get-by-id', {
73
- methods: ['GET'],
74
- route: '${modelCamel}/{id}',
75
- authLevel: 'anonymous',
76
- extraInputs: [
77
- {
78
- type: 'cosmosDB',
79
- name: 'cosmosInput',
80
- databaseName: process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase',
81
- containerName,
82
- connection: 'CosmosDBConnection',
83
- id: '{id}',
84
- partitionKey: '{id}',
85
- },
86
- ],
87
- handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
88
- try {${readGuard}
89
- const document = context.extraInputs.get('cosmosInput');
90
-
91
- if (!document) {
92
- return { status: 404, jsonBody: { error: 'Item not found' } };
93
- }
94
-
95
- const validated = ${schemaName}.parse(document);
96
- return { status: 200, jsonBody: validated };
97
- } catch (error) {
98
- ${authCatchBlock} context.error(\`Error fetching item from \${containerName}:\`, error);
99
- return { status: 500, jsonBody: { error: 'Failed to fetch item' } };
100
- }
101
- },
102
- });
103
+ ${getByIdHandler}
103
104
 
104
105
  // POST /api/${modelCamel} - 新規作成
105
106
  app.http('${modelCamel}-create', {
@@ -145,7 +146,87 @@ ${authCatchBlock} context.error(\`Error creating item in \${containerName}:
145
146
  },
146
147
  });
147
148
 
148
- // PUT /api/${modelCamel}/{id} - 更新
149
+ ${updateHandler}
150
+
151
+ ${deleteHandler}
152
+ `;
153
+ }
154
+
155
+ // --- TS getById ハンドラー生成ヘルパー ---
156
+
157
+ function generateGetByIdWithBinding(modelCamel: string, schemaName: string, readGuard: string, authCatchBlock: string): string {
158
+ return `// GET /api/${modelCamel}/{id} - ID指定取得
159
+ app.http('${modelCamel}-get-by-id', {
160
+ methods: ['GET'],
161
+ route: '${modelCamel}/{id}',
162
+ authLevel: 'anonymous',
163
+ extraInputs: [
164
+ {
165
+ type: 'cosmosDB',
166
+ name: 'cosmosInput',
167
+ databaseName: process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase',
168
+ containerName,
169
+ connection: 'CosmosDBConnection',
170
+ id: '{id}',
171
+ partitionKey: '{id}',
172
+ },
173
+ ],
174
+ handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
175
+ try {${readGuard}
176
+ const document = context.extraInputs.get('cosmosInput');
177
+
178
+ if (!document) {
179
+ return { status: 404, jsonBody: { error: 'Item not found' } };
180
+ }
181
+
182
+ const validated = ${schemaName}.parse(document);
183
+ return { status: 200, jsonBody: validated };
184
+ } catch (error) {
185
+ ${authCatchBlock} context.error(\`Error fetching item from \${containerName}:\`, error);
186
+ return { status: 500, jsonBody: { error: 'Failed to fetch item' } };
187
+ }
188
+ },
189
+ });`;
190
+ }
191
+
192
+ function generateGetByIdWithSdk(modelCamel: string, schemaName: string, readGuard: string, authCatchBlock: string, sdkClientInit: string): string {
193
+ return `// GET /api/${modelCamel}/{id} - ID指定取得 (custom partition key)
194
+ app.http('${modelCamel}-get-by-id', {
195
+ methods: ['GET'],
196
+ route: '${modelCamel}/{id}',
197
+ authLevel: 'anonymous',
198
+ handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
199
+ try {${readGuard}
200
+ const id = request.params.id;
201
+ if (!id) {
202
+ return { status: 400, jsonBody: { error: 'ID is required' } };
203
+ }
204
+
205
+ ${sdkClientInit}
206
+
207
+ const { resources } = await container.items.query({
208
+ query: 'SELECT * FROM c WHERE c.id = @id',
209
+ parameters: [{ name: '@id', value: id }],
210
+ }).fetchAll();
211
+
212
+ if (!resources || resources.length === 0) {
213
+ return { status: 404, jsonBody: { error: 'Item not found' } };
214
+ }
215
+
216
+ const validated = ${schemaName}.parse(resources[0]);
217
+ return { status: 200, jsonBody: validated };
218
+ } catch (error) {
219
+ ${authCatchBlock} context.error(\`Error fetching item from \${containerName}:\`, error);
220
+ return { status: 500, jsonBody: { error: 'Failed to fetch item' } };
221
+ }
222
+ },
223
+ });`;
224
+ }
225
+
226
+ // --- TS update ハンドラー生成ヘルパー ---
227
+
228
+ function generateUpdateWithBinding(modelCamel: string, schemaName: string, writeGuard: string, authCatchBlock: string): string {
229
+ return `// PUT /api/${modelCamel}/{id} - 更新
149
230
  app.http('${modelCamel}-update', {
150
231
  methods: ['PUT'],
151
232
  route: '${modelCamel}/{id}',
@@ -206,9 +287,66 @@ ${authCatchBlock} context.error(\`Error updating item in \${containerName}:
206
287
  return { status: 500, jsonBody: { error: 'Failed to update item' } };
207
288
  }
208
289
  },
209
- });
290
+ });`;
291
+ }
292
+
293
+ function generateUpdateWithSdk(modelCamel: string, schemaName: string, partitionKeyField: string, writeGuard: string, authCatchBlock: string, sdkClientInit: string): string {
294
+ return `// PUT /api/${modelCamel}/{id} - 更新 (custom partition key)
295
+ app.http('${modelCamel}-update', {
296
+ methods: ['PUT'],
297
+ route: '${modelCamel}/{id}',
298
+ authLevel: 'anonymous',
299
+ handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
300
+ try {${writeGuard}
301
+ const id = request.params.id;
302
+ if (!id) {
303
+ return { status: 400, jsonBody: { error: 'ID is required' } };
304
+ }
305
+
306
+ ${sdkClientInit}
210
307
 
211
- // DELETE /api/${modelCamel}/{id} - 削除
308
+ const { resources } = await container.items.query({
309
+ query: 'SELECT * FROM c WHERE c.id = @id',
310
+ parameters: [{ name: '@id', value: id }],
311
+ }).fetchAll();
312
+
313
+ if (!resources || resources.length === 0) {
314
+ return { status: 404, jsonBody: { error: 'Item not found' } };
315
+ }
316
+
317
+ const existingDocument = resources[0];
318
+ const body = await request.json() as any;
319
+ const { createdAt, updatedAt, ...userData } = body;
320
+
321
+ const dataWithManagedFields = {
322
+ ...userData,
323
+ id,
324
+ createdAt: existingDocument.createdAt,
325
+ updatedAt: new Date().toISOString(),
326
+ };
327
+
328
+ const result = ${schemaName}.safeParse(dataWithManagedFields);
329
+
330
+ if (!result.success) {
331
+ context.error('Validation failed:', result.error.issues);
332
+ return { status: 400, jsonBody: { error: 'Validation failed', details: result.error.issues } };
333
+ }
334
+
335
+ const pkValue = result.data.${partitionKeyField};
336
+ await container.items.upsert(result.data);
337
+ return { status: 200, jsonBody: result.data };
338
+ } catch (error) {
339
+ ${authCatchBlock} context.error(\`Error updating item in \${containerName}:\`, error);
340
+ return { status: 500, jsonBody: { error: 'Failed to update item' } };
341
+ }
342
+ },
343
+ });`;
344
+ }
345
+
346
+ // --- TS delete ハンドラー生成ヘルパー ---
347
+
348
+ function generateDeleteIdPartition(modelCamel: string, writeGuard: string, authCatchBlock: string, sdkClientInit: string): string {
349
+ return `// DELETE /api/${modelCamel}/{id} - 削除
212
350
  app.http('${modelCamel}-delete', {
213
351
  methods: ['DELETE'],
214
352
  route: '${modelCamel}/{id}',
@@ -220,17 +358,7 @@ app.http('${modelCamel}-delete', {
220
358
  return { status: 400, jsonBody: { error: 'ID is required' } };
221
359
  }
222
360
 
223
- const { CosmosClient } = await import('@azure/cosmos');
224
- const endpoint = process.env.CosmosDBConnection__accountEndpoint;
225
- let client: InstanceType<typeof CosmosClient>;
226
- if (endpoint) {
227
- const { DefaultAzureCredential } = await import('@azure/identity');
228
- client = new CosmosClient({ endpoint, aadCredentials: new DefaultAzureCredential() });
229
- } else {
230
- client = new CosmosClient(process.env.CosmosDBConnection!);
231
- }
232
- const database = client.database(process.env.COSMOS_DB_DATABASE_NAME || 'AppDatabase');
233
- const container = database.container(containerName);
361
+ ${sdkClientInit}
234
362
 
235
363
  await container.item(id, id).delete();
236
364
  context.log(\`Deleted item \${id} from \${containerName}\`);
@@ -244,8 +372,48 @@ ${authCatchBlock} context.error(\`Error deleting item from \${containerName
244
372
  return { status: 500, jsonBody: { error: 'Failed to delete item' } };
245
373
  }
246
374
  },
247
- });
248
- `;
375
+ });`;
376
+ }
377
+
378
+ function generateDeleteCustomPartition(modelCamel: string, partitionKeyField: string, writeGuard: string, authCatchBlock: string, sdkClientInit: string): string {
379
+ return `// DELETE /api/${modelCamel}/{id} - 削除 (custom partition key)
380
+ app.http('${modelCamel}-delete', {
381
+ methods: ['DELETE'],
382
+ route: '${modelCamel}/{id}',
383
+ authLevel: 'anonymous',
384
+ handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
385
+ try {${writeGuard}
386
+ const id = request.params.id;
387
+ if (!id) {
388
+ return { status: 400, jsonBody: { error: 'ID is required' } };
389
+ }
390
+
391
+ ${sdkClientInit}
392
+
393
+ // パーティションキー値を取得するためにドキュメントを読み取り
394
+ const { resources } = await container.items.query({
395
+ query: 'SELECT * FROM c WHERE c.id = @id',
396
+ parameters: [{ name: '@id', value: id }],
397
+ }).fetchAll();
398
+
399
+ if (!resources || resources.length === 0) {
400
+ return { status: 404, jsonBody: { error: 'Item not found' } };
401
+ }
402
+
403
+ const pkValue = resources[0].${partitionKeyField};
404
+ await container.item(id, pkValue).delete();
405
+ context.log(\`Deleted item \${id} from \${containerName}\`);
406
+
407
+ return { status: 204 };
408
+ } catch (error: any) {
409
+ if (error.code === 404) {
410
+ return { status: 404, jsonBody: { error: 'Item not found' } };
411
+ }
412
+ ${authCatchBlock} context.error(\`Error deleting item from \${containerName}:\`, error);
413
+ return { status: 500, jsonBody: { error: 'Failed to delete item' } };
414
+ }
415
+ },
416
+ });`;
249
417
  }
250
418
 
251
419
  export function generateCSharpAzureFunctionsCRUD(model: ModelInfo, authPolicy?: ModelAuthPolicy): string {
@@ -254,11 +422,78 @@ export function generateCSharpAzureFunctionsCRUD(model: ModelInfo, authPolicy?:
254
422
  const className = `${modelName}Functions`;
255
423
  const containerName = modelName.endsWith('s') ? modelName : `${modelName}s`;
256
424
 
425
+ const partitionKeyPath = model.partitionKey;
426
+ const partitionKeyField = partitionKeyPath.slice(1);
427
+ const isIdPartition = partitionKeyField === 'id';
428
+
257
429
  const hasAuth = !!authPolicy;
258
430
  const authUsing = hasAuth ? 'using Functions.Auth;\n' : '';
259
431
  const readGuard = hasAuth ? `\n${generateAuthGuardCSharp(authPolicy!, 'read')}\n` : '';
260
432
  const writeGuard = hasAuth ? `\n${generateAuthGuardCSharp(authPolicy!, 'write')}\n` : '';
261
433
 
434
+ // PK値の取得ロジック(create 用)
435
+ const createPkExpr = isIdPartition
436
+ ? 'id'
437
+ : `payload["${partitionKeyField}"]?.GetValue<string>() ?? throw new InvalidOperationException("Partition key field '${partitionKeyField}' is required.")`;
438
+
439
+ // ReadCosmosItemAsync: PK=/id の場合は直接読み取り、それ以外はクエリ
440
+ const readItemMethod = isIdPartition
441
+ ? ` private static async Task<JsonObject> ReadCosmosItemAsync(Container container, string id)
442
+ {
443
+ var response = await container.ReadItemStreamAsync(id, new PartitionKey(id));
444
+ if (response.StatusCode == HttpStatusCode.NotFound)
445
+ {
446
+ throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, response.Headers.ActivityId, response.Headers.RequestCharge);
447
+ }
448
+
449
+ if (!response.IsSuccessStatusCode)
450
+ {
451
+ throw new InvalidOperationException($"Cosmos read failed with status {(int)response.StatusCode}.");
452
+ }
453
+
454
+ using var document = await JsonDocument.ParseAsync(response.Content);
455
+ return JsonNode.Parse(document.RootElement.GetRawText())?.AsObject()
456
+ ?? throw new JsonException("Cosmos item payload must be a JSON object.");
457
+ }`
458
+ : ` private static async Task<JsonObject> ReadCosmosItemAsync(Container container, string id)
459
+ {
460
+ var query = new QueryDefinition("SELECT * FROM c WHERE c.id = @id")
461
+ .WithParameter("@id", id);
462
+ using var iterator = container.GetItemQueryStreamIterator(query);
463
+
464
+ while (iterator.HasMoreResults)
465
+ {
466
+ var page = await iterator.ReadNextAsync();
467
+ if (!page.IsSuccessStatusCode)
468
+ {
469
+ throw new InvalidOperationException($"Cosmos query failed with status {(int)page.StatusCode}.");
470
+ }
471
+
472
+ using var document = await JsonDocument.ParseAsync(page.Content);
473
+ if (document.RootElement.TryGetProperty("Documents", out var documents))
474
+ {
475
+ foreach (var item in documents.EnumerateArray())
476
+ {
477
+ return JsonNode.Parse(item.GetRawText())?.AsObject()
478
+ ?? throw new JsonException("Cosmos item payload must be a JSON object.");
479
+ }
480
+ }
481
+ }
482
+
483
+ throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, "", 0);
484
+ }`;
485
+
486
+ // Replace / Delete の PK 解決ロジック
487
+ const replacePkExpr = isIdPartition
488
+ ? 'new PartitionKey(id)'
489
+ : `new PartitionKey(payload["${partitionKeyField}"]?.GetValue<string>())`;
490
+
491
+ const deleteLogic = isIdPartition
492
+ ? ` await container.DeleteItemAsync<JsonObject>(id, new PartitionKey(id));`
493
+ : ` var existing = await ReadCosmosItemAsync(container, id);
494
+ var pkValue = existing["${partitionKeyField}"]?.GetValue<string>();
495
+ await container.DeleteItemAsync<JsonObject>(id, new PartitionKey(pkValue));`;
496
+
262
497
  return `using System.Net;
263
498
  using System.Text;
264
499
  using System.Text.Json;
@@ -351,23 +586,7 @@ public sealed class ${className}
351
586
  private static Stream CreateJsonStream(JsonObject payload) =>
352
587
  new MemoryStream(Encoding.UTF8.GetBytes(payload.ToJsonString()));
353
588
 
354
- private static async Task<JsonObject> ReadCosmosItemAsync(Container container, string id)
355
- {
356
- var response = await container.ReadItemStreamAsync(id, new PartitionKey(id));
357
- if (response.StatusCode == HttpStatusCode.NotFound)
358
- {
359
- throw new CosmosException("Item not found", HttpStatusCode.NotFound, 0, response.Headers.ActivityId, response.Headers.RequestCharge);
360
- }
361
-
362
- if (!response.IsSuccessStatusCode)
363
- {
364
- throw new InvalidOperationException($"Cosmos read failed with status {(int)response.StatusCode}.");
365
- }
366
-
367
- using var document = await JsonDocument.ParseAsync(response.Content);
368
- return JsonNode.Parse(document.RootElement.GetRawText())?.AsObject()
369
- ?? throw new JsonException("Cosmos item payload must be a JSON object.");
370
- }
589
+ ${readItemMethod}
371
590
 
372
591
  private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData request, HttpStatusCode status, object payload)
373
592
  {
@@ -454,7 +673,8 @@ public sealed class ${className}
454
673
  using var client = CreateCosmosClient();
455
674
  var container = GetContainer(client);
456
675
  using var stream = CreateJsonStream(payload);
457
- var response = await container.CreateItemStreamAsync(stream, new PartitionKey(id));
676
+ var pkValue = ${createPkExpr};
677
+ var response = await container.CreateItemStreamAsync(stream, new PartitionKey(pkValue));
458
678
  if (!response.IsSuccessStatusCode)
459
679
  {
460
680
  throw new InvalidOperationException($"Cosmos create failed with status {(int)response.StatusCode}.");
@@ -499,7 +719,7 @@ public sealed class ${className}
499
719
  var payload = BuildManagedDocument(body, id, createdAt, DateTimeOffset.UtcNow.ToString("O"));
500
720
 
501
721
  using var stream = CreateJsonStream(payload);
502
- var response = await container.ReplaceItemStreamAsync(stream, id, new PartitionKey(id));
722
+ var response = await container.ReplaceItemStreamAsync(stream, id, ${replacePkExpr});
503
723
  if (!response.IsSuccessStatusCode)
504
724
  {
505
725
  throw new InvalidOperationException($"Cosmos replace failed with status {(int)response.StatusCode}.");
@@ -528,7 +748,7 @@ public sealed class ${className}
528
748
  {${writeGuard}
529
749
  using var client = CreateCosmosClient();
530
750
  var container = GetContainer(client);
531
- await container.DeleteItemAsync<JsonObject>(id, new PartitionKey(id));
751
+ ${deleteLogic}
532
752
 
533
753
  return request.CreateResponse(HttpStatusCode.NoContent);
534
754
  }
@@ -555,6 +775,10 @@ export function generatePythonAzureFunctionsCRUD(model: ModelInfo, authPolicy?:
555
775
  const modelSnake = toKebabCase(modelName).replace(/-/g, "_");
556
776
  const containerName = modelName.endsWith('s') ? modelName : `${modelName}s`;
557
777
 
778
+ const partitionKeyPath = model.partitionKey;
779
+ const partitionKeyField = partitionKeyPath.slice(1);
780
+ const isIdPartition = partitionKeyField === 'id';
781
+
558
782
  const hasAuth = !!authPolicy;
559
783
  const authImport = hasAuth ? '\nfrom auth.jwt_helper import require_auth, require_roles, handle_auth_error\n' : '';
560
784
  // generateAuthGuardPython outputs at 4-space indent; inside try: we need 8-space
@@ -564,6 +788,76 @@ export function generatePythonAzureFunctionsCRUD(model: ModelInfo, authPolicy?:
564
788
  const writeGuard = hasAuth ? '\n' + writeGuardRaw.split('\n').map(l => ' ' + l).join('\n') : '';
565
789
  const authCatch = hasAuth ? `\n auth_err = handle_auth_error(exc)\n if auth_err:\n return auth_err` : '';
566
790
 
791
+ // getById / update の読み取りロジック
792
+ const getByIdRead = isIdPartition
793
+ ? `container.read_item(item=item_id, partition_key=item_id)`
794
+ : `list(container.query_items(
795
+ query="SELECT * FROM c WHERE c.id = @id",
796
+ parameters=[{"name": "@id", "value": item_id}],
797
+ enable_cross_partition_query=True,
798
+ ))`;
799
+
800
+ const getByIdBody = isIdPartition
801
+ ? ` container = _get_container()
802
+ item = container.read_item(item=item_id, partition_key=item_id)
803
+ return _json_response(item, 200)`
804
+ : ` container = _get_container()
805
+ items = list(container.query_items(
806
+ query="SELECT * FROM c WHERE c.id = @id",
807
+ parameters=[{"name": "@id", "value": item_id}],
808
+ enable_cross_partition_query=True,
809
+ ))
810
+ if not items:
811
+ return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
812
+ return _json_response(items[0], 200)`;
813
+
814
+ const updateBody = isIdPartition
815
+ ? ` container = _get_container()
816
+ existing = container.read_item(item=item_id, partition_key=item_id)
817
+ body = req.get_json()
818
+ payload = _build_managed_document(
819
+ body,
820
+ item_id,
821
+ existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
822
+ datetime.now(timezone.utc).isoformat(),
823
+ )
824
+ container.replace_item(item=item_id, body=payload)
825
+ return _json_response(payload, 200)`
826
+ : ` container = _get_container()
827
+ items = list(container.query_items(
828
+ query="SELECT * FROM c WHERE c.id = @id",
829
+ parameters=[{"name": "@id", "value": item_id}],
830
+ enable_cross_partition_query=True,
831
+ ))
832
+ if not items:
833
+ return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
834
+ existing = items[0]
835
+ body = req.get_json()
836
+ payload = _build_managed_document(
837
+ body,
838
+ item_id,
839
+ existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
840
+ datetime.now(timezone.utc).isoformat(),
841
+ )
842
+ container.replace_item(item=existing, body=payload)
843
+ return _json_response(payload, 200)`;
844
+
845
+ const deleteBody = isIdPartition
846
+ ? ` container = _get_container()
847
+ container.delete_item(item=item_id, partition_key=item_id)
848
+ return func.HttpResponse(status_code=204)`
849
+ : ` container = _get_container()
850
+ items = list(container.query_items(
851
+ query="SELECT * FROM c WHERE c.id = @id",
852
+ parameters=[{"name": "@id", "value": item_id}],
853
+ enable_cross_partition_query=True,
854
+ ))
855
+ if not items:
856
+ return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
857
+ pk_value = items[0].get("${partitionKeyField}")
858
+ container.delete_item(item=item_id, partition_key=pk_value)
859
+ return func.HttpResponse(status_code=204)`;
860
+
567
861
  return {
568
862
  registration: `from blueprints.${modelSnake} import bp as ${modelSnake}_bp\napp.register_blueprint(${modelSnake}_bp)`,
569
863
  blueprint: `import json
@@ -634,9 +928,7 @@ def ${modelSnake}_get_all(req: func.HttpRequest) -> func.HttpResponse:
634
928
  def ${modelSnake}_get_by_id(req: func.HttpRequest) -> func.HttpResponse:
635
929
  item_id = req.route_params.get("id")
636
930
  try:${readGuard}
637
- container = _get_container()
638
- item = container.read_item(item=item_id, partition_key=item_id)
639
- return _json_response(item, 200)
931
+ ${getByIdBody}
640
932
  except exceptions.CosmosResourceNotFoundError:
641
933
  return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
642
934
  except Exception as exc:${authCatch}
@@ -664,17 +956,7 @@ def ${modelSnake}_create(req: func.HttpRequest) -> func.HttpResponse:
664
956
  def ${modelSnake}_update(req: func.HttpRequest) -> func.HttpResponse:
665
957
  item_id = req.route_params.get("id")
666
958
  try:${writeGuard}
667
- container = _get_container()
668
- existing = container.read_item(item=item_id, partition_key=item_id)
669
- body = req.get_json()
670
- payload = _build_managed_document(
671
- body,
672
- item_id,
673
- existing.get("createdAt") or datetime.now(timezone.utc).isoformat(),
674
- datetime.now(timezone.utc).isoformat(),
675
- )
676
- container.replace_item(item=item_id, body=payload)
677
- return _json_response(payload, 200)
959
+ ${updateBody}
678
960
  except exceptions.CosmosResourceNotFoundError:
679
961
  return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
680
962
  except ValueError:
@@ -687,9 +969,7 @@ def ${modelSnake}_update(req: func.HttpRequest) -> func.HttpResponse:
687
969
  def ${modelSnake}_delete(req: func.HttpRequest) -> func.HttpResponse:
688
970
  item_id = req.route_params.get("id")
689
971
  try:${writeGuard}
690
- container = _get_container()
691
- container.delete_item(item=item_id, partition_key=item_id)
692
- return func.HttpResponse(status_code=204)
972
+ ${deleteBody}
693
973
  except exceptions.CosmosResourceNotFoundError:
694
974
  return _json_response({"error": "${modelName} not found", "id": item_id}, 404)
695
975
  except Exception as exc:${authCatch}
@@ -20,6 +20,7 @@ export interface ModelInfo {
20
20
  nestedSchemaRefs: NestedSchemaRef[]; // ネストしたスキーマ参照
21
21
  connectorConfig?: ModelConnectorConfig; // コネクタメタデータ(外部データソース用)
22
22
  authPolicy?: ModelAuthPolicy; // 認可ポリシー(ロールベースアクセス制御用)
23
+ partitionKey: string; // Cosmos DB パーティションキーパス(デフォルト: "/id")
23
24
  }
24
25
 
25
26
  export interface FieldInfo {
@@ -89,6 +90,9 @@ export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
89
90
 
90
91
  // authPolicy を抽出(ロールベースアクセス制御用メタデータ)
91
92
  const authPolicy = parseAuthPolicy(content);
93
+
94
+ // partitionKey を抽出(Cosmos DB パーティションキー)
95
+ const partitionKey = parsePartitionKey(content);
92
96
 
93
97
  // ネストしたスキーマ参照を検出
94
98
  const nestedSchemaRefs = detectNestedSchemaRefs(modelPath, content, schemaName);
@@ -103,6 +107,17 @@ export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
103
107
  const hasId = fields.some(f => f.name === "id");
104
108
  const hasCreatedAt = fields.some(f => f.name === "createdAt");
105
109
  const hasUpdatedAt = fields.some(f => f.name === "updatedAt");
110
+
111
+ // パーティションキーのバリデーション
112
+ if (partitionKey !== '/id') {
113
+ if (!partitionKey.startsWith('/')) {
114
+ console.warn(`⚠️ [${modelName}] partitionKey should start with '/': got '${partitionKey}'`);
115
+ }
116
+ const pkField = partitionKey.startsWith('/') ? partitionKey.slice(1) : partitionKey;
117
+ if (fields.length > 0 && !fields.some(f => f.name === pkField)) {
118
+ console.warn(`⚠️ [${modelName}] partitionKey field '${pkField}' not found in schema fields`);
119
+ }
120
+ }
106
121
 
107
122
  return {
108
123
  name: modelName,
@@ -114,6 +129,7 @@ export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
114
129
  hasCreatedAt,
115
130
  hasUpdatedAt,
116
131
  nestedSchemaRefs,
132
+ partitionKey,
117
133
  ...(connectorConfig ? { connectorConfig } : {}),
118
134
  ...(authPolicy ? { authPolicy } : {}),
119
135
  };
@@ -778,6 +794,16 @@ export function parseAuthPolicy(content: string): ModelAuthPolicy | undefined {
778
794
  };
779
795
  }
780
796
 
797
+ /**
798
+ * パーティションキーを抽出する
799
+ * export const partitionKey = '/tenantId' のようなエクスポートを検出
800
+ * 未指定の場合はデフォルト '/id' を返す
801
+ */
802
+ export function parsePartitionKey(content: string): string {
803
+ const match = content.match(/export\s+const\s+partitionKey\s*=\s*['"]([^'"]+)['"]/);
804
+ return match ? match[1] : '/id';
805
+ }
806
+
781
807
  /**
782
808
  * 文字列を kebab-case に変換
783
809
  */