@izumisy-tailor/tailor-data-viewer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -0
- package/package.json +47 -0
- package/src/component/column-selector.tsx +264 -0
- package/src/component/data-table.tsx +428 -0
- package/src/component/data-view-tab-content.tsx +324 -0
- package/src/component/data-viewer.tsx +280 -0
- package/src/component/hooks/use-accessible-tables.ts +22 -0
- package/src/component/hooks/use-column-state.ts +281 -0
- package/src/component/hooks/use-relation-data.ts +387 -0
- package/src/component/hooks/use-table-data.ts +317 -0
- package/src/component/index.ts +15 -0
- package/src/component/pagination.tsx +56 -0
- package/src/component/relation-content.tsx +250 -0
- package/src/component/saved-view-context.tsx +145 -0
- package/src/component/search-filter.tsx +319 -0
- package/src/component/single-record-tab-content.tsx +676 -0
- package/src/component/table-selector.tsx +102 -0
- package/src/component/types.ts +20 -0
- package/src/component/view-save-load.tsx +112 -0
- package/src/generator/metadata-generator.ts +461 -0
- package/src/lib/utils.ts +6 -0
- package/src/providers/graphql-client.ts +31 -0
- package/src/styles/theme.css +105 -0
- package/src/types/table-metadata.ts +73 -0
- package/src/ui/alert.tsx +66 -0
- package/src/ui/badge.tsx +46 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/checkbox.tsx +30 -0
- package/src/ui/collapsible.tsx +31 -0
- package/src/ui/dialog.tsx +143 -0
- package/src/ui/dropdown-menu.tsx +255 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/select.tsx +188 -0
- package/src/ui/table.tsx +116 -0
- package/src/utils/query-builder.ts +190 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Database } from "lucide-react";
|
|
2
|
+
import type { TableMetadata } from "../types/table-metadata";
|
|
3
|
+
import {
|
|
4
|
+
Select,
|
|
5
|
+
SelectContent,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectTrigger,
|
|
8
|
+
SelectValue,
|
|
9
|
+
} from "../ui/select";
|
|
10
|
+
|
|
11
|
+
interface TableSelectorProps {
|
|
12
|
+
tables: TableMetadata[];
|
|
13
|
+
selectedTable: TableMetadata | null;
|
|
14
|
+
onSelect: (table: TableMetadata) => void;
|
|
15
|
+
centered?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Dropdown selector for choosing a table to view
|
|
20
|
+
*/
|
|
21
|
+
export function TableSelector({
|
|
22
|
+
tables,
|
|
23
|
+
selectedTable,
|
|
24
|
+
onSelect,
|
|
25
|
+
centered = false,
|
|
26
|
+
}: TableSelectorProps) {
|
|
27
|
+
const handleValueChange = (tableName: string) => {
|
|
28
|
+
const table = tables.find((t) => t.name === tableName);
|
|
29
|
+
if (table) {
|
|
30
|
+
onSelect(table);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Empty state: show centered dropdown with icon and description
|
|
35
|
+
if (centered) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex flex-col items-center space-y-4">
|
|
38
|
+
<div className="text-center">
|
|
39
|
+
<Database className="text-muted-foreground mx-auto mb-2 h-10 w-10" />
|
|
40
|
+
<h3 className="text-lg font-semibold">テーブルを選択</h3>
|
|
41
|
+
<p className="text-muted-foreground text-sm">
|
|
42
|
+
閲覧するテーブルを選択してください
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
<Select
|
|
46
|
+
value={selectedTable?.name ?? ""}
|
|
47
|
+
onValueChange={handleValueChange}
|
|
48
|
+
>
|
|
49
|
+
<SelectTrigger className="w-[400px]">
|
|
50
|
+
<SelectValue placeholder="テーブルを選択..." />
|
|
51
|
+
</SelectTrigger>
|
|
52
|
+
<SelectContent className="max-h-[400px]">
|
|
53
|
+
{tables.map((table) => (
|
|
54
|
+
<SelectItem
|
|
55
|
+
key={table.name}
|
|
56
|
+
value={table.name}
|
|
57
|
+
className="flex flex-col items-start py-2"
|
|
58
|
+
>
|
|
59
|
+
<div className="flex w-full flex-col gap-0.5">
|
|
60
|
+
<span className="font-medium">{table.name}</span>
|
|
61
|
+
{table.description && (
|
|
62
|
+
<span className="text-muted-foreground text-xs">
|
|
63
|
+
{table.description}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
</SelectItem>
|
|
68
|
+
))}
|
|
69
|
+
</SelectContent>
|
|
70
|
+
</Select>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex items-center gap-2">
|
|
77
|
+
<label className="text-muted-foreground text-sm font-medium">
|
|
78
|
+
テーブル:
|
|
79
|
+
</label>
|
|
80
|
+
<Select
|
|
81
|
+
value={selectedTable?.name ?? ""}
|
|
82
|
+
onValueChange={handleValueChange}
|
|
83
|
+
>
|
|
84
|
+
<SelectTrigger className="w-[250px]">
|
|
85
|
+
<SelectValue placeholder="テーブルを選択..." />
|
|
86
|
+
</SelectTrigger>
|
|
87
|
+
<SelectContent>
|
|
88
|
+
{tables.map((table) => (
|
|
89
|
+
<SelectItem key={table.name} value={table.name}>
|
|
90
|
+
{table.name}
|
|
91
|
+
{table.description && (
|
|
92
|
+
<span className="text-muted-foreground ml-2 text-xs">
|
|
93
|
+
({table.description})
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</SelectItem>
|
|
97
|
+
))}
|
|
98
|
+
</SelectContent>
|
|
99
|
+
</Select>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FieldType } from "../types/table-metadata";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Search filter condition for a single field
|
|
5
|
+
*/
|
|
6
|
+
export interface SearchFilter {
|
|
7
|
+
/** Field name to filter */
|
|
8
|
+
field: string;
|
|
9
|
+
/** Field type (determines UI input type and query format) */
|
|
10
|
+
fieldType: FieldType;
|
|
11
|
+
/** Filter value (string for string/number/enum, boolean for boolean) */
|
|
12
|
+
value: string | boolean;
|
|
13
|
+
/** Enum values (if fieldType is "enum") */
|
|
14
|
+
enumValues?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Search filters state - a collection of filters to be applied with AND logic
|
|
19
|
+
*/
|
|
20
|
+
export type SearchFilters = SearchFilter[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import { Save, Filter, Columns } from "lucide-react";
|
|
3
|
+
import { Button } from "../ui/button";
|
|
4
|
+
import { Input } from "../ui/input";
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
DialogTrigger,
|
|
13
|
+
} from "../ui/dialog";
|
|
14
|
+
import type { ExpandedRelationFields } from "../types/table-metadata";
|
|
15
|
+
import { useSavedViews, type SaveViewInput } from "./saved-view-context";
|
|
16
|
+
import type { SearchFilters } from "./types";
|
|
17
|
+
|
|
18
|
+
interface ViewSaveProps {
|
|
19
|
+
tableName: string;
|
|
20
|
+
filters: SearchFilters;
|
|
21
|
+
selectedFields: string[];
|
|
22
|
+
selectedRelations: string[];
|
|
23
|
+
expandedRelationFields: ExpandedRelationFields;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* View save control
|
|
28
|
+
* Allows saving views (filters + column selections)
|
|
29
|
+
*/
|
|
30
|
+
export function ViewSave({
|
|
31
|
+
tableName,
|
|
32
|
+
filters,
|
|
33
|
+
selectedFields,
|
|
34
|
+
selectedRelations,
|
|
35
|
+
expandedRelationFields,
|
|
36
|
+
}: ViewSaveProps) {
|
|
37
|
+
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
|
38
|
+
const [viewName, setViewName] = useState("");
|
|
39
|
+
|
|
40
|
+
const { saveView } = useSavedViews();
|
|
41
|
+
|
|
42
|
+
const handleSaveView = useCallback(() => {
|
|
43
|
+
if (!viewName.trim()) return;
|
|
44
|
+
|
|
45
|
+
const input: SaveViewInput = {
|
|
46
|
+
name: viewName.trim(),
|
|
47
|
+
tableName,
|
|
48
|
+
filters,
|
|
49
|
+
selectedFields,
|
|
50
|
+
selectedRelations,
|
|
51
|
+
expandedRelationFields,
|
|
52
|
+
};
|
|
53
|
+
saveView(input);
|
|
54
|
+
setViewName("");
|
|
55
|
+
setSaveDialogOpen(false);
|
|
56
|
+
}, [
|
|
57
|
+
viewName,
|
|
58
|
+
tableName,
|
|
59
|
+
filters,
|
|
60
|
+
selectedFields,
|
|
61
|
+
selectedRelations,
|
|
62
|
+
expandedRelationFields,
|
|
63
|
+
saveView,
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const canSave = filters.length > 0 || selectedFields.length > 0;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
|
70
|
+
<DialogTrigger asChild>
|
|
71
|
+
<Button variant="outline" size="sm" disabled={!canSave}>
|
|
72
|
+
<Save className="mr-1 size-3" />
|
|
73
|
+
ビュー保存
|
|
74
|
+
</Button>
|
|
75
|
+
</DialogTrigger>
|
|
76
|
+
<DialogContent>
|
|
77
|
+
<DialogHeader>
|
|
78
|
+
<DialogTitle>ビューを保存</DialogTitle>
|
|
79
|
+
<DialogDescription>
|
|
80
|
+
現在の検索条件とカラム選択に名前を付けて保存します。
|
|
81
|
+
</DialogDescription>
|
|
82
|
+
</DialogHeader>
|
|
83
|
+
<div className="space-y-4 py-4">
|
|
84
|
+
<Input
|
|
85
|
+
placeholder="ビューの名前"
|
|
86
|
+
value={viewName}
|
|
87
|
+
onChange={(e) => setViewName(e.target.value)}
|
|
88
|
+
/>
|
|
89
|
+
<div className="text-muted-foreground space-y-1 text-sm">
|
|
90
|
+
<div>テーブル: {tableName}</div>
|
|
91
|
+
<div className="flex items-center gap-1">
|
|
92
|
+
<Filter className="size-3" />
|
|
93
|
+
フィルター数: {filters.length}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="flex items-center gap-1">
|
|
96
|
+
<Columns className="size-3" />
|
|
97
|
+
選択カラム数: {selectedFields.length}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<DialogFooter>
|
|
102
|
+
<Button variant="outline" onClick={() => setSaveDialogOpen(false)}>
|
|
103
|
+
キャンセル
|
|
104
|
+
</Button>
|
|
105
|
+
<Button onClick={handleSaveView} disabled={!viewName.trim()}>
|
|
106
|
+
保存
|
|
107
|
+
</Button>
|
|
108
|
+
</DialogFooter>
|
|
109
|
+
</DialogContent>
|
|
110
|
+
</Dialog>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field type mapping for Data View
|
|
3
|
+
*/
|
|
4
|
+
export type FieldType =
|
|
5
|
+
| "string"
|
|
6
|
+
| "number"
|
|
7
|
+
| "boolean"
|
|
8
|
+
| "uuid"
|
|
9
|
+
| "datetime"
|
|
10
|
+
| "date"
|
|
11
|
+
| "time"
|
|
12
|
+
| "enum"
|
|
13
|
+
| "array"
|
|
14
|
+
| "nested";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Metadata for a single field
|
|
18
|
+
*/
|
|
19
|
+
export interface FieldMetadata {
|
|
20
|
+
name: string;
|
|
21
|
+
type: FieldType;
|
|
22
|
+
required: boolean;
|
|
23
|
+
enumValues?: readonly string[];
|
|
24
|
+
arrayItemType?: FieldType;
|
|
25
|
+
description?: string;
|
|
26
|
+
/** manyToOne relation info (if this field is a foreign key) */
|
|
27
|
+
relation?: {
|
|
28
|
+
/** GraphQL field name for the related object (e.g., "task") */
|
|
29
|
+
fieldName: string;
|
|
30
|
+
/** Target table name in camelCase (e.g., "task") */
|
|
31
|
+
targetTable: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Metadata for a relation
|
|
37
|
+
*/
|
|
38
|
+
export interface RelationMetadata {
|
|
39
|
+
/** GraphQL field name (e.g., "task" for manyToOne, "taskAssignments" for oneToMany) */
|
|
40
|
+
fieldName: string;
|
|
41
|
+
/** Target table name in camelCase (e.g., "task", "taskAssignment") */
|
|
42
|
+
targetTable: string;
|
|
43
|
+
/** Relation type */
|
|
44
|
+
relationType: "manyToOne" | "oneToMany";
|
|
45
|
+
/** For manyToOne: the FK field name (e.g., "taskId"). For oneToMany: the FK field on the child table */
|
|
46
|
+
foreignKeyField: string;
|
|
47
|
+
/** For manyToOne: the backward field name on the target type (used to generate oneToMany relation) */
|
|
48
|
+
backwardFieldName?: string;
|
|
49
|
+
/** True if this is a oneToOne relation (no inverse oneToMany should be generated) */
|
|
50
|
+
isOneToOne?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Metadata for a single table
|
|
55
|
+
*/
|
|
56
|
+
export interface TableMetadata {
|
|
57
|
+
name: string;
|
|
58
|
+
pluralForm: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
readAllowedRoles: string[];
|
|
61
|
+
fields: FieldMetadata[];
|
|
62
|
+
/** Relations (manyToOne and oneToMany) */
|
|
63
|
+
relations?: RelationMetadata[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Map of all tables
|
|
68
|
+
*/
|
|
69
|
+
export type TableMetadataMap = Record<string, TableMetadata>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Intermediate type for processed table data
|
|
73
|
+
*/
|
|
74
|
+
interface ProcessedTable {
|
|
75
|
+
name: string;
|
|
76
|
+
pluralForm: string;
|
|
77
|
+
originalName: string; // PascalCase name for relation lookup
|
|
78
|
+
description?: string;
|
|
79
|
+
readAllowedRoles: string[];
|
|
80
|
+
fields: FieldMetadata[];
|
|
81
|
+
relations: RelationMetadata[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Map TailorDB field type to FieldType
|
|
86
|
+
*/
|
|
87
|
+
function mapFieldType(
|
|
88
|
+
tailorType: string,
|
|
89
|
+
isArray?: boolean,
|
|
90
|
+
): { type: FieldType; arrayItemType?: FieldType } {
|
|
91
|
+
const typeMap: Record<string, FieldType> = {
|
|
92
|
+
string: "string",
|
|
93
|
+
integer: "number",
|
|
94
|
+
float: "number",
|
|
95
|
+
boolean: "boolean",
|
|
96
|
+
uuid: "uuid",
|
|
97
|
+
datetime: "datetime",
|
|
98
|
+
date: "date",
|
|
99
|
+
time: "time",
|
|
100
|
+
enum: "enum",
|
|
101
|
+
nested: "nested",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const mappedType = typeMap[tailorType] ?? "string";
|
|
105
|
+
|
|
106
|
+
if (isArray) {
|
|
107
|
+
return { type: "array", arrayItemType: mappedType };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { type: mappedType };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Convert PascalCase to camelCase
|
|
115
|
+
*/
|
|
116
|
+
function toCamelCase(str: string): string {
|
|
117
|
+
return str.charAt(0).toLowerCase() + str.slice(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Extract allowed roles from gql permission policies
|
|
122
|
+
* Only extracts roles from 'read' action policies with 'allow' permit
|
|
123
|
+
*/
|
|
124
|
+
function extractReadAllowedRoles(
|
|
125
|
+
gqlPermission?: readonly {
|
|
126
|
+
conditions: readonly unknown[];
|
|
127
|
+
actions: readonly ["all"] | readonly string[];
|
|
128
|
+
permit: "allow" | "deny";
|
|
129
|
+
description?: string;
|
|
130
|
+
}[],
|
|
131
|
+
): string[] {
|
|
132
|
+
if (!gqlPermission) return [];
|
|
133
|
+
|
|
134
|
+
const roles = new Set<string>();
|
|
135
|
+
|
|
136
|
+
for (const policy of gqlPermission) {
|
|
137
|
+
// Only process 'allow' policies that include 'read' action
|
|
138
|
+
if (policy.permit !== "allow") continue;
|
|
139
|
+
const actions = policy.actions as readonly string[];
|
|
140
|
+
if (!actions.includes("all") && !actions.includes("read")) continue;
|
|
141
|
+
|
|
142
|
+
// Extract roles from conditions
|
|
143
|
+
for (const condition of policy.conditions) {
|
|
144
|
+
if (!Array.isArray(condition) || condition.length < 3) continue;
|
|
145
|
+
|
|
146
|
+
const [left, operator, right] = condition;
|
|
147
|
+
|
|
148
|
+
// Check for pattern: ["ROLE_NAME", "in", { user: "roles" }]
|
|
149
|
+
if (
|
|
150
|
+
typeof left === "string" &&
|
|
151
|
+
operator === "in" &&
|
|
152
|
+
typeof right === "object" &&
|
|
153
|
+
right !== null &&
|
|
154
|
+
"user" in right &&
|
|
155
|
+
(right as { user: string }).user === "roles"
|
|
156
|
+
) {
|
|
157
|
+
roles.add(left);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check for pattern: [{ user: "roles" }, "in", "ROLE_NAME"] (reversed)
|
|
161
|
+
if (
|
|
162
|
+
typeof right === "string" &&
|
|
163
|
+
operator === "in" &&
|
|
164
|
+
typeof left === "object" &&
|
|
165
|
+
left !== null &&
|
|
166
|
+
"user" in left &&
|
|
167
|
+
(left as { user: string }).user === "roles"
|
|
168
|
+
) {
|
|
169
|
+
roles.add(right);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return Array.from(roles);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parsed field type from TailorDB
|
|
179
|
+
*/
|
|
180
|
+
interface ParsedFieldConfig {
|
|
181
|
+
type: string;
|
|
182
|
+
required?: boolean;
|
|
183
|
+
description?: string;
|
|
184
|
+
allowedValues?: ({ value: string } | string)[];
|
|
185
|
+
array?: boolean;
|
|
186
|
+
/** Raw relation configuration from TailorDB SDK */
|
|
187
|
+
rawRelation?: {
|
|
188
|
+
type: "manyToOne" | "n-1" | "oneToOne" | "1-1" | "keyOnly";
|
|
189
|
+
toward: {
|
|
190
|
+
/** Target type name (PascalCase) */
|
|
191
|
+
type: string;
|
|
192
|
+
/** GraphQL field alias for the relation */
|
|
193
|
+
as?: string;
|
|
194
|
+
};
|
|
195
|
+
/** Backward relation field name on the target type */
|
|
196
|
+
backward?: string;
|
|
197
|
+
};
|
|
198
|
+
/** Foreign key info */
|
|
199
|
+
foreignKey?: boolean;
|
|
200
|
+
foreignKeyType?: string;
|
|
201
|
+
foreignKeyField?: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Parsed field from TailorDB
|
|
206
|
+
*/
|
|
207
|
+
interface ParsedField {
|
|
208
|
+
name: string;
|
|
209
|
+
config: ParsedFieldConfig;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Parsed TailorDB type
|
|
214
|
+
*/
|
|
215
|
+
interface ParsedTailorDBType {
|
|
216
|
+
name: string;
|
|
217
|
+
pluralForm: string;
|
|
218
|
+
description?: string;
|
|
219
|
+
fields: Record<string, ParsedField>;
|
|
220
|
+
permissions: {
|
|
221
|
+
gql?: readonly GqlPermissionPolicy[];
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* GQL permission policy
|
|
227
|
+
*/
|
|
228
|
+
interface GqlPermissionPolicy {
|
|
229
|
+
conditions: readonly unknown[];
|
|
230
|
+
actions: readonly ["all"] | readonly string[];
|
|
231
|
+
permit: "allow" | "deny";
|
|
232
|
+
description?: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Generator input structure
|
|
237
|
+
*/
|
|
238
|
+
interface GeneratorInput {
|
|
239
|
+
tailordb: {
|
|
240
|
+
types: ProcessedTable[];
|
|
241
|
+
}[];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Generator result file
|
|
246
|
+
*/
|
|
247
|
+
interface GeneratorResultFile {
|
|
248
|
+
path: string;
|
|
249
|
+
content: string;
|
|
250
|
+
skipIfExists?: boolean;
|
|
251
|
+
executable?: boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Generator result
|
|
256
|
+
*/
|
|
257
|
+
interface GeneratorResult {
|
|
258
|
+
files: GeneratorResultFile[];
|
|
259
|
+
errors?: string[];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Custom generator that extracts table metadata for Data View
|
|
264
|
+
*/
|
|
265
|
+
export const tableMetadataGenerator = {
|
|
266
|
+
id: "table-metadata",
|
|
267
|
+
description:
|
|
268
|
+
"Generates table metadata for Data View including field definitions and read permissions",
|
|
269
|
+
dependencies: ["tailordb"] as ("tailordb" | "resolver" | "executor")[],
|
|
270
|
+
|
|
271
|
+
processType({ type }: { type: ParsedTailorDBType }): ProcessedTable {
|
|
272
|
+
const fields: FieldMetadata[] = [];
|
|
273
|
+
const relations: RelationMetadata[] = [];
|
|
274
|
+
|
|
275
|
+
// Process each field
|
|
276
|
+
for (const [fieldName, field] of Object.entries(type.fields)) {
|
|
277
|
+
const config = field.config;
|
|
278
|
+
|
|
279
|
+
const { type: fieldType, arrayItemType } = mapFieldType(
|
|
280
|
+
config.type,
|
|
281
|
+
config.array,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const fieldMetadata: FieldMetadata = {
|
|
285
|
+
name: fieldName,
|
|
286
|
+
type: fieldType,
|
|
287
|
+
required: config.required ?? false,
|
|
288
|
+
description: config.description,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Add enum values if present
|
|
292
|
+
if (config.allowedValues && config.allowedValues.length > 0) {
|
|
293
|
+
fieldMetadata.enumValues = config.allowedValues.map((v) =>
|
|
294
|
+
typeof v === "string" ? v : v.value,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Add array item type if it's an array field
|
|
299
|
+
if (arrayItemType) {
|
|
300
|
+
fieldMetadata.arrayItemType = arrayItemType;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Extract manyToOne relation info from rawRelation
|
|
304
|
+
// Include: manyToOne, n-1, oneToOne, 1-1 (all are treated as manyToOne for data view purposes)
|
|
305
|
+
const rawRelation = config.rawRelation;
|
|
306
|
+
const isManyToOneRelation =
|
|
307
|
+
rawRelation &&
|
|
308
|
+
(rawRelation.type === "manyToOne" ||
|
|
309
|
+
rawRelation.type === "n-1" ||
|
|
310
|
+
rawRelation.type === "oneToOne" ||
|
|
311
|
+
rawRelation.type === "1-1") &&
|
|
312
|
+
rawRelation.toward;
|
|
313
|
+
|
|
314
|
+
// Check if this is a oneToOne relation (no inverse oneToMany should be generated)
|
|
315
|
+
const isOneToOne =
|
|
316
|
+
rawRelation?.type === "oneToOne" || rawRelation?.type === "1-1";
|
|
317
|
+
|
|
318
|
+
if (isManyToOneRelation && rawRelation.toward) {
|
|
319
|
+
const targetTableName = toCamelCase(rawRelation.toward.type);
|
|
320
|
+
const relationFieldName =
|
|
321
|
+
rawRelation.toward.as ?? toCamelCase(rawRelation.toward.type);
|
|
322
|
+
|
|
323
|
+
// Add relation info to the field
|
|
324
|
+
fieldMetadata.relation = {
|
|
325
|
+
fieldName: relationFieldName,
|
|
326
|
+
targetTable: targetTableName,
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// Add to relations array (include backward field name for oneToMany generation)
|
|
330
|
+
relations.push({
|
|
331
|
+
fieldName: relationFieldName,
|
|
332
|
+
targetTable: targetTableName,
|
|
333
|
+
relationType: "manyToOne",
|
|
334
|
+
foreignKeyField: fieldName,
|
|
335
|
+
backwardFieldName: rawRelation.backward,
|
|
336
|
+
isOneToOne,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
fields.push(fieldMetadata);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Extract read allowed roles from gql permission
|
|
344
|
+
const readAllowedRoles = extractReadAllowedRoles(type.permissions.gql);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
name: toCamelCase(type.name),
|
|
348
|
+
pluralForm: toCamelCase(type.pluralForm),
|
|
349
|
+
originalName: type.name,
|
|
350
|
+
description: type.description,
|
|
351
|
+
readAllowedRoles,
|
|
352
|
+
fields,
|
|
353
|
+
relations,
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
processResolver() {
|
|
358
|
+
return null;
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
processExecutor() {
|
|
362
|
+
return null;
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
processTailorDBNamespace({
|
|
366
|
+
types,
|
|
367
|
+
}: {
|
|
368
|
+
types: Record<string, ProcessedTable>;
|
|
369
|
+
}): ProcessedTable[] {
|
|
370
|
+
return Object.values(types);
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
aggregate({ input }: { input: GeneratorInput }): GeneratorResult {
|
|
374
|
+
// Collect all tables from all namespaces
|
|
375
|
+
const allTables = input.tailordb.flatMap((ns) => ns.types);
|
|
376
|
+
|
|
377
|
+
// Build a map of originalName -> table for relation lookup
|
|
378
|
+
const tableByOriginalName = new Map<string, ProcessedTable>();
|
|
379
|
+
for (const table of allTables) {
|
|
380
|
+
tableByOriginalName.set(table.originalName, table);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Second pass: Add oneToMany relations by inverting manyToOne relations
|
|
384
|
+
for (const table of allTables) {
|
|
385
|
+
for (const relation of table.relations) {
|
|
386
|
+
if (relation.relationType === "manyToOne") {
|
|
387
|
+
// Skip oneToOne relations - they don't have a oneToMany inverse
|
|
388
|
+
// (GraphQL generates a single object field, not a connection)
|
|
389
|
+
if (relation.isOneToOne) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Find the target table and add the inverse oneToMany relation
|
|
394
|
+
const targetTable = tableByOriginalName.get(
|
|
395
|
+
// Convert camelCase back to PascalCase for lookup
|
|
396
|
+
relation.targetTable.charAt(0).toUpperCase() +
|
|
397
|
+
relation.targetTable.slice(1),
|
|
398
|
+
);
|
|
399
|
+
if (targetTable) {
|
|
400
|
+
// Use backward field name if specified, otherwise fall back to plural form
|
|
401
|
+
const oneToManyFieldName =
|
|
402
|
+
relation.backwardFieldName ?? table.pluralForm;
|
|
403
|
+
const alreadyExists = targetTable.relations.some(
|
|
404
|
+
(r) =>
|
|
405
|
+
r.relationType === "oneToMany" &&
|
|
406
|
+
r.fieldName === oneToManyFieldName,
|
|
407
|
+
);
|
|
408
|
+
if (!alreadyExists) {
|
|
409
|
+
targetTable.relations.push({
|
|
410
|
+
fieldName: oneToManyFieldName,
|
|
411
|
+
targetTable: table.name,
|
|
412
|
+
relationType: "oneToMany",
|
|
413
|
+
foreignKeyField: relation.foreignKeyField,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Build the metadata map (excluding originalName and backwardFieldName from output)
|
|
422
|
+
const metadataMap: TableMetadataMap = {};
|
|
423
|
+
for (const table of allTables) {
|
|
424
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
425
|
+
const { originalName, ...tableWithoutOriginalName } = table;
|
|
426
|
+
metadataMap[table.name] = tableWithoutOriginalName;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Generate the TypeScript content
|
|
430
|
+
const content = `// This file is auto-generated by table-metadata-generator
|
|
431
|
+
// Do not edit manually
|
|
432
|
+
|
|
433
|
+
import type {
|
|
434
|
+
FieldType,
|
|
435
|
+
FieldMetadata,
|
|
436
|
+
RelationMetadata,
|
|
437
|
+
TableMetadata,
|
|
438
|
+
TableMetadataMap,
|
|
439
|
+
} from "../../generator/table-metadata-generator";
|
|
440
|
+
|
|
441
|
+
export type { FieldType, FieldMetadata, RelationMetadata, TableMetadata, TableMetadataMap };
|
|
442
|
+
|
|
443
|
+
export const tableMetadata: TableMetadataMap = ${JSON.stringify(metadataMap, null, 2)} as const;
|
|
444
|
+
|
|
445
|
+
export const tableNames = ${JSON.stringify(Object.keys(metadataMap), null, 2)} as const;
|
|
446
|
+
|
|
447
|
+
export type TableName = (typeof tableNames)[number];
|
|
448
|
+
`;
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
files: [
|
|
452
|
+
{
|
|
453
|
+
path: "src/generated/table-metadata.ts",
|
|
454
|
+
content,
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
export default tableMetadataGenerator;
|