Skip to content

Commit 44b1a48

Browse files
committed
feat: support uniqueConstraints on custom data models (#136)
本体 geonicdb#1268 の一意制約(複合ユニーク)に CLI を追従させる。 - models create/update: uniqueConstraints を JSON ペイロードで指定可能 (パススルー)。ヘルプ・examples に宣言例と全置換セマンティクスを追記 - models get/list: table 形式で uniqueConstraints を 「制約名(フィールド, ...)」の可読形式で表示 - 409 AlreadyExists: サーバーの違反制約名入りメッセージを表示し、 一意制約違反時は models get で制約を確認するヒントを stderr に出力 - types.ts に UniqueConstraint 型を追加 - README に一意制約の使い方・エラー表示例を追記
1 parent de3c37a commit 44b1a48

9 files changed

Lines changed: 200 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77

88
## [Unreleased]
99

10+
### 2026-07-15
11+
- **Feat**: カスタムデータモデルの一意制約(複合ユニーク, geonicdb#1268)に対応 (#136)
12+
- `models create` / `models update` の JSON ペイロードで `uniqueConstraints` を指定可能に(本体へパススルー)。ヘルプ・examples に宣言例と全置換 (`[]` で全削除) のセマンティクスを追記
13+
- `models get` / `models list` の table 形式で `uniqueConstraints``制約名(フィールド, ...)` の可読形式で表示(json 形式は従来通りそのまま出力)
14+
- 409 AlreadyExists のエラー表示を改善 — サーバーが返す違反制約名入りメッセージをそのまま表示し、一意制約違反の場合は `geonic models get <type>` で制約を確認するヒントを stderr に表示
15+
- `UniqueConstraint` 型を `types.ts` に追加
16+
1017
### 2026-07-14
1118
- **Fix**: list 系コマンドがサーバーのデフォルトページサイズ (20件) で結果を暗黙に切り捨てていた問題を修正 (#141)
1219
- `--limit`/`--offset` 未指定時は `X-Total-Count` に従って全ページを自動取得するように変更。対象: `admin users/tenants/policies/api-keys/oauth-clients list`, `me api-keys/oauth-clients/policies list`, `rules list`, `custom-data-models list`, `catalog datasets list`

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,40 @@ Temporal entityOperations query supports: `--aggr-methods`, `--aggr-period`.
440440

441441
`models` is available as an alias for `custom-data-models`.
442442

443+
#### 一意制約(複合ユニーク)
444+
445+
データモデルに `uniqueConstraints` を宣言すると、指定した属性の組み合わせの一意性がサーバ側(DB レベル)で強制されます。重複するエンティティの作成・更新は `409 AlreadyExists` となり、違反した制約名がエラーメッセージに含まれます。
446+
447+
```console
448+
$ geonic models create '{
449+
"type": "RoomReservation",
450+
"domain": "building",
451+
"description": "Room reservation",
452+
"propertyDetails": {
453+
"room": {"ngsiType": "Property", "valueType": "string", "example": "R1"},
454+
"date": {"ngsiType": "Property", "valueType": "string", "example": "2026-07-15"},
455+
"startTime": {"ngsiType": "Property", "valueType": "string", "example": "10:00"}
456+
},
457+
"uniqueConstraints": [
458+
{"name": "no-double-booking", "fields": ["room", "date", "startTime"]}
459+
]
460+
}'
461+
```
462+
463+
- `fields``propertyDetails` に定義済みの scalar 型属性(string / number / integer / boolean / uri / datetime)のみ指定できます(1 制約 1〜8 個、モデルあたり最大 10 制約)
464+
- 制約は宣言フィールドを**すべて**持つエンティティにのみ適用されます
465+
- `models update``uniqueConstraints` は全置換です(`[]` で全削除)
466+
- 既存エンティティが重複している状態で制約を追加すると `400` になります(先に重複を解消してください)
467+
- 定義済みの制約は `geonic models get <type>` で確認できます(table 形式では `制約名(フィールド, ...)` 表記)
468+
469+
重複作成時のエラー表示例:
470+
471+
```console
472+
$ geonic entities create '{"id":"urn:ngsi-ld:RoomReservation:002","type":"RoomReservation","room":{"type":"Property","value":"R1"},"date":{"type":"Property","value":"2026-07-15"},"startTime":{"type":"Property","value":"10:00"}}'
473+
Error: Entity already exists: violates unique constraint 'no-double-booking' on fields [room, date, startTime]
474+
Hint: inspect the model's unique constraints with `geonic models get <type>`.
475+
```
476+
443477
### catalog — DCAT-AP catalog
444478

445479
| Subcommand | Description |

src/commands/models.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function registerModelsCommand(program: Command): void {
4343
// models get
4444
const get = models
4545
.command("get <id>")
46-
.description("Get a data model's full schema including property definitions and constraints")
46+
.description("Get a data model's full schema including property definitions, validation rules, and unique constraints")
4747
.action(
4848
withErrorHandler(async (id: unknown, _opts: unknown, cmd: Command) => {
4949
const client = createClient(cmd);
@@ -81,7 +81,11 @@ export function registerModelsCommand(program: Command): void {
8181
' "propertyDetails": {\n' +
8282
' "temperature": {"ngsiType": "Property", "valueType": "Number", "example": 25}\n' +
8383
" }\n" +
84-
" }",
84+
" }\n\n" +
85+
"Optional uniqueConstraints (composite unique, enforced server-side):\n" +
86+
' "uniqueConstraints": [{"name": "no-double-booking", "fields": ["room", "date", "startTime"]}]\n' +
87+
" Fields must be declared in propertyDetails with a scalar valueType.\n" +
88+
" Duplicate entities are rejected with 409 AlreadyExists (constraint name in the message).",
8589
)
8690
.action(
8791
withErrorHandler(async (json: unknown, _opts: unknown, cmd: Command) => {
@@ -107,6 +111,10 @@ export function registerModelsCommand(program: Command): void {
107111
description: "Create from stdin pipe",
108112
command: "cat model.json | geonic models create",
109113
},
114+
{
115+
description: "Create with a composite unique constraint (no double booking)",
116+
command: `geonic models create '{"type":"RoomReservation","domain":"building","description":"Room reservation","propertyDetails":{"room":{"ngsiType":"Property","valueType":"string","example":"R1"},"date":{"ngsiType":"Property","valueType":"string","example":"2026-07-15"},"startTime":{"ngsiType":"Property","valueType":"string","example":"10:00"}},"uniqueConstraints":[{"name":"no-double-booking","fields":["room","date","startTime"]}]}'`,
117+
},
110118
]);
111119

112120
// models update
@@ -116,7 +124,9 @@ export function registerModelsCommand(program: Command): void {
116124
.description(
117125
"Update a model\n\n" +
118126
"JSON payload: only specified fields are updated.\n" +
119-
' e.g. {"description": "Updated model"}',
127+
' e.g. {"description": "Updated model"}\n\n' +
128+
"uniqueConstraints replaces the whole constraint list (send [] to remove all).\n" +
129+
"Adding a constraint fails with 400 if existing entities already violate it.",
120130
)
121131
.action(
122132
withErrorHandler(
@@ -148,6 +158,14 @@ export function registerModelsCommand(program: Command): void {
148158
description: "Update from stdin pipe",
149159
command: "cat model.json | geonic models update <model-id>",
150160
},
161+
{
162+
description: "Replace unique constraints",
163+
command: `geonic models update RoomReservation '{"uniqueConstraints":[{"name":"no-double-booking","fields":["room","date","startTime"]}]}'`,
164+
},
165+
{
166+
description: "Remove all unique constraints",
167+
command: `geonic models update RoomReservation '{"uniqueConstraints":[]}'`,
168+
},
151169
]);
152170

153171
// models delete

src/helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ export function withErrorHandler<T extends unknown[]>(fn: (...args: T) => Promis
201201
} else {
202202
printError(err.message);
203203
}
204+
} else if (err instanceof GdbClientError && err.status === 409) {
205+
// 409 AlreadyExists — サーバのメッセージに違反した一意制約名が含まれる (#136)
206+
printError(err.message);
207+
if (/violates unique constraint/i.test(err.message)) {
208+
printWarning("Hint: inspect the model's unique constraints with `geonic models get <type>`.");
209+
}
204210
} else if (err instanceof Error) {
205211
printError(err.message);
206212
} else {

src/output.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import chalk from "chalk";
2-
import type { OutputFormat } from "./types.js";
2+
import type { OutputFormat, UniqueConstraint } from "./types.js";
33

44
export function formatOutput(data: unknown, format: OutputFormat): string {
55
switch (format) {
@@ -132,12 +132,36 @@ function isGeoJSON(v: unknown): boolean {
132132
return typeof o.type === "string" && "coordinates" in o;
133133
}
134134

135+
/**
136+
* カスタムデータモデルの uniqueConstraints 形式 ({name, fields[]} の配列) か判定する (#136)
137+
*/
138+
function isUniqueConstraintArray(val: unknown): val is UniqueConstraint[] {
139+
return (
140+
Array.isArray(val) &&
141+
val.length > 0 &&
142+
val.every(
143+
(v) =>
144+
typeof v === "object" &&
145+
v !== null &&
146+
typeof (v as Record<string, unknown>).name === "string" &&
147+
Array.isArray((v as Record<string, unknown>).fields) &&
148+
((v as Record<string, unknown>).fields as unknown[]).every((f) => typeof f === "string") &&
149+
Object.keys(v as object).length === 2,
150+
)
151+
);
152+
}
153+
135154
function cellValue(val: unknown): string {
136155
if (val === undefined || val === null) return "";
137156
if (typeof val !== "object") return String(val);
138157
if (Array.isArray(val)) {
139158
const scalarArray = val.every((v) => v === null || typeof v !== "object");
140-
return scalarArray ? val.map(String).join(", ") : JSON.stringify(val);
159+
if (scalarArray) return val.map(String).join(", ");
160+
// uniqueConstraints は「制約名(フィールド, ...)」の可読形式で表示 (#136)
161+
if (isUniqueConstraintArray(val)) {
162+
return val.map((c) => `${c.name}(${c.fields.join(", ")})`).join("; ");
163+
}
164+
return JSON.stringify(val);
141165
}
142166
const obj = val as Record<string, unknown>;
143167
if (isGeoJSON(obj)) return formatGeoJSON(obj);

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export interface ClientResponse<T = unknown> {
5858
count?: number;
5959
}
6060

61+
/** カスタムデータモデルの一意制約(複合ユニーク, geonicdb#1268 / #136) */
62+
export interface UniqueConstraint {
63+
name: string;
64+
fields: string[];
65+
}
66+
6167
export interface NgsiError {
6268
error?: string;
6369
description?: string;

tests/helpers.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,36 @@ describe("helpers", () => {
355355
);
356356
});
357357

358+
it("prints server message and hint on 409 unique constraint violation (#136)", async () => {
359+
const err = new GdbClientError(
360+
"Entity already exists: violates unique constraint 'no-double-booking' on fields [room, date, startTime]",
361+
409,
362+
{ error: "AlreadyExists" },
363+
);
364+
const fn = vi.fn().mockRejectedValue(err);
365+
const wrapped = withErrorHandler(fn);
366+
await expect(wrapped()).rejects.toThrow("process.exit");
367+
expect(printError).toHaveBeenCalledWith(
368+
expect.stringContaining("violates unique constraint 'no-double-booking'"),
369+
);
370+
expect(printWarning).toHaveBeenCalledWith(
371+
expect.stringContaining("geonic models get"),
372+
);
373+
expect(exitSpy).toHaveBeenCalledWith(1);
374+
});
375+
376+
it("prints plain message on 409 without unique constraint context", async () => {
377+
const err = new GdbClientError("Entity already exists: urn:ngsi-ld:Room:001", 409, {
378+
error: "AlreadyExists",
379+
});
380+
const fn = vi.fn().mockRejectedValue(err);
381+
const wrapped = withErrorHandler(fn);
382+
await expect(wrapped()).rejects.toThrow("process.exit");
383+
expect(printError).toHaveBeenCalledWith("Entity already exists: urn:ngsi-ld:Room:001");
384+
expect(printWarning).not.toHaveBeenCalled();
385+
expect(exitSpy).toHaveBeenCalledWith(1);
386+
});
387+
358388
it("prints generic message on 403 without entity type detail", async () => {
359389
const err = new GdbClientError("Access denied", 403, {
360390
detail: "insufficient permissions",

tests/models.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,27 @@ describe("models (custom-data-models) command", () => {
7474
});
7575
});
7676

77+
describe("create with uniqueConstraints (#136)", () => {
78+
it("passes uniqueConstraints through in the request body", async () => {
79+
const modelData = {
80+
type: "RoomReservation",
81+
domain: "building",
82+
description: "Room reservation",
83+
propertyDetails: {
84+
room: { ngsiType: "Property", valueType: "string", example: "R1" },
85+
},
86+
uniqueConstraints: [{ name: "no-double-booking", fields: ["room", "date", "startTime"] }],
87+
};
88+
vi.mocked(parseJsonInput).mockResolvedValue(modelData);
89+
mockClient.rawRequest.mockResolvedValue(mockResponse({ type: "RoomReservation" }, 201));
90+
91+
await runCommand(program, ["models", "create", JSON.stringify(modelData)]);
92+
93+
expect(mockClient.rawRequest).toHaveBeenCalledWith("POST", "/custom-data-models", { body: modelData });
94+
expect(printSuccess).toHaveBeenCalledWith("Model created.");
95+
});
96+
});
97+
7798
describe("update", () => {
7899
it("parses JSON and patches via rawRequest", async () => {
79100
const patchData = { name: "UpdatedModel" };
@@ -93,6 +114,36 @@ describe("models (custom-data-models) command", () => {
93114
});
94115
});
95116

117+
describe("update uniqueConstraints (#136)", () => {
118+
it("replaces the constraint list via PATCH", async () => {
119+
const patchData = { uniqueConstraints: [{ name: "unique-code", fields: ["code"] }] };
120+
vi.mocked(parseJsonInput).mockResolvedValue(patchData);
121+
mockClient.rawRequest.mockResolvedValue(mockResponse({ type: "Slot" }, 200));
122+
123+
await runCommand(program, ["models", "update", "Slot", JSON.stringify(patchData)]);
124+
125+
expect(mockClient.rawRequest).toHaveBeenCalledWith(
126+
"PATCH",
127+
`/custom-data-models/${encodeURIComponent("Slot")}`,
128+
{ body: patchData },
129+
);
130+
});
131+
132+
it("removes all constraints with an empty array", async () => {
133+
const patchData = { uniqueConstraints: [] };
134+
vi.mocked(parseJsonInput).mockResolvedValue(patchData);
135+
mockClient.rawRequest.mockResolvedValue(mockResponse({ type: "Slot" }, 200));
136+
137+
await runCommand(program, ["models", "update", "Slot", JSON.stringify(patchData)]);
138+
139+
expect(mockClient.rawRequest).toHaveBeenCalledWith(
140+
"PATCH",
141+
`/custom-data-models/${encodeURIComponent("Slot")}`,
142+
{ body: patchData },
143+
);
144+
});
145+
});
146+
96147
describe("delete", () => {
97148
it("deletes model via rawRequest", async () => {
98149
mockClient.rawRequest.mockResolvedValue(mockResponse(undefined, 204));

tests/output.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,25 @@ describe("output", () => {
5656
expect(result).toContain("23.5");
5757
});
5858

59+
it("renders uniqueConstraints as name(fields) (#136)", () => {
60+
const data = {
61+
type: "RoomReservation",
62+
uniqueConstraints: [
63+
{ name: "no-double-booking", fields: ["room", "date", "startTime"] },
64+
{ name: "unique-code", fields: ["code"] },
65+
],
66+
};
67+
const result = formatOutput(data, "table");
68+
expect(result).toContain("no-double-booking(room, date, startTime)");
69+
expect(result).toContain("unique-code(code)");
70+
});
71+
72+
it("keeps JSON rendering for non-constraint object arrays", () => {
73+
const data = { items: [{ name: "x", other: true }] };
74+
const result = formatOutput(data, "table");
75+
expect(result).toContain(JSON.stringify([{ name: "x", other: true }]));
76+
});
77+
5978
it("returns String(data) for non-array non-object data", () => {
6079
const result = formatOutput("hello-string", "table");
6180
expect(result).toBe("hello-string");

0 commit comments

Comments
 (0)