sveltekit-admin 0.2.0 → 0.5.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 +80 -2
- package/dist/index.d.ts +2 -7
- package/dist/index.js +2 -13
- package/dist/server/auth.d.ts +7 -0
- package/dist/server/auth.js +19 -0
- package/dist/server/data.d.ts +38 -0
- package/dist/server/data.js +116 -0
- package/dist/server/handler.d.ts +123 -4
- package/dist/server/handler.js +590 -819
- package/dist/server/introspection/parser.d.ts +19 -12
- package/dist/server/introspection/parser.js +71 -61
- package/dist/server/introspection/relations.d.ts +49 -0
- package/dist/server/introspection/relations.js +128 -0
- package/dist/server/query/filterDetection.d.ts +71 -0
- package/dist/server/query/filterDetection.js +153 -0
- package/dist/server/query/listQuery.d.ts +89 -0
- package/dist/server/query/listQuery.js +428 -0
- package/dist/server/query/urls.d.ts +33 -0
- package/dist/server/query/urls.js +58 -0
- package/dist/server/router.d.ts +6 -0
- package/dist/server/router.js +27 -0
- package/dist/server/views/Dashboard.svelte +29 -0
- package/dist/server/views/Dashboard.svelte.d.ts +15 -0
- package/dist/server/views/FieldInput.svelte +63 -0
- package/dist/server/views/FieldInput.svelte.d.ts +9 -0
- package/dist/server/views/Form.svelte +127 -0
- package/dist/server/views/Form.svelte.d.ts +12 -0
- package/dist/server/views/Layout.svelte +78 -0
- package/dist/server/views/Layout.svelte.d.ts +13 -0
- package/dist/server/views/List.svelte +240 -0
- package/dist/server/views/List.svelte.d.ts +27 -0
- package/dist/server/views/ListFilters.svelte +257 -0
- package/dist/server/views/ListFilters.svelte.d.ts +18 -0
- package/dist/server/views/ModelCard.svelte +11 -0
- package/dist/server/views/ModelCard.svelte.d.ts +8 -0
- package/dist/server/views/NotFound.svelte +7 -0
- package/dist/server/views/NotFound.svelte.d.ts +7 -0
- package/dist/server/views/RelatedBlock.svelte +74 -0
- package/dist/server/views/RelatedBlock.svelte.d.ts +11 -0
- package/dist/server/views/RelationCheckboxes.svelte +53 -0
- package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
- package/dist/server/views/RelationSelect.svelte +53 -0
- package/dist/server/views/RelationSelect.svelte.d.ts +12 -0
- package/dist/server/views/StatCard.svelte +18 -0
- package/dist/server/views/StatCard.svelte.d.ts +8 -0
- package/dist/server/views/html.d.ts +5 -0
- package/dist/server/views/html.js +41 -0
- package/dist/server/views/theme.d.ts +1 -0
- package/dist/server/views/theme.js +484 -0
- package/dist/server/views/types.d.ts +57 -0
- package/dist/server/views/types.js +1 -0
- package/package.json +24 -26
- package/dist/admin.d.ts +0 -227
- package/dist/admin.js +0 -369
- package/dist/components/AdminForm.svelte +0 -423
- package/dist/components/AdminForm.svelte.d.ts +0 -30
- package/dist/components/AdminLayout.svelte +0 -328
- package/dist/components/AdminLayout.svelte.d.ts +0 -20
- package/dist/components/DataTable.svelte +0 -573
- package/dist/components/DataTable.svelte.d.ts +0 -25
- package/dist/components/index.d.ts +0 -3
- package/dist/components/index.js +0 -3
- package/dist/server/auth/guard.d.ts +0 -36
- package/dist/server/auth/guard.js +0 -38
- package/dist/server/auth/index.d.ts +0 -1
- package/dist/server/auth/index.js +0 -1
- package/dist/server/crud/index.d.ts +0 -1
- package/dist/server/crud/index.js +0 -1
- package/dist/server/crud/operations.d.ts +0 -87
- package/dist/server/crud/operations.js +0 -276
- package/dist/server/introspection/index.d.ts +0 -1
- package/dist/server/introspection/index.js +0 -1
|
@@ -13,6 +13,8 @@ export interface PrismaField {
|
|
|
13
13
|
isCreatedAt: boolean;
|
|
14
14
|
hasDefault: boolean;
|
|
15
15
|
defaultValue?: string;
|
|
16
|
+
/** true si `type` correspond à un `enum` déclaré dans le même schéma. */
|
|
17
|
+
isEnum?: boolean;
|
|
16
18
|
relation?: {
|
|
17
19
|
name?: string;
|
|
18
20
|
model: string;
|
|
@@ -26,26 +28,31 @@ export interface PrismaModel {
|
|
|
26
28
|
fields: PrismaField[];
|
|
27
29
|
documentation?: string;
|
|
28
30
|
primaryKey?: string;
|
|
31
|
+
isPivotTable?: boolean;
|
|
29
32
|
}
|
|
30
33
|
export interface PrismaSchema {
|
|
31
34
|
models: PrismaModel[];
|
|
32
35
|
enums: Map<string, string[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Provider du bloc `datasource` (ex. "postgresql", "sqlite"), tel qu'écrit
|
|
38
|
+
* littéralement dans le schéma. `undefined` si absent ou si la valeur est
|
|
39
|
+
* une expression (`env("...")`, un provider non littéral) — dans ce cas
|
|
40
|
+
* le code appelant doit dégrader vers le comportement le plus prudent
|
|
41
|
+
* (voir `caseInsensitiveSearch` dans query/listQuery.ts).
|
|
42
|
+
*/
|
|
43
|
+
provider?: string;
|
|
33
44
|
}
|
|
34
45
|
export declare function parsePrismaSchema(schemaPath: string): PrismaSchema;
|
|
35
46
|
export declare function parseSchemaContent(content: string): PrismaSchema;
|
|
36
47
|
/**
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
48
|
+
* Un champ est-il sensible par son nom ? Prédicat UNIQUE, partagé par
|
|
49
|
+
* `getDisplayFields` (liste) et par le module de recherche/filtre
|
|
50
|
+
* (`query/listQuery.ts`) — sans ce partage, les deux heuristiques
|
|
51
|
+
* divergeraient tôt ou tard et un champ masqué de la liste redeviendrait
|
|
52
|
+
* cherchable/filtrable par URL forgée.
|
|
42
53
|
*/
|
|
43
|
-
export declare function
|
|
54
|
+
export declare function isSensitiveFieldName(name: string): boolean;
|
|
44
55
|
/**
|
|
45
|
-
* Get a
|
|
46
|
-
*/
|
|
47
|
-
export declare function fieldToLabel(fieldName: string): string;
|
|
48
|
-
/**
|
|
49
|
-
* Determine the input type for a Prisma field
|
|
56
|
+
* Get display fields for a model (fields suitable for list view)
|
|
50
57
|
*/
|
|
51
|
-
export declare function
|
|
58
|
+
export declare function getDisplayFields(model: Pick<PrismaModel, 'fields'>): PrismaField[];
|
|
@@ -4,6 +4,36 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync } from 'fs';
|
|
6
6
|
const SCALAR_TYPES = ['String', 'Int', 'Float', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Decimal', 'BigInt'];
|
|
7
|
+
/**
|
|
8
|
+
* Detect if a model is a pivot/junction table for many-to-many relations.
|
|
9
|
+
* A pivot table typically:
|
|
10
|
+
* - Has a name starting with _ (Prisma implicit)
|
|
11
|
+
* - Has mostly foreign key fields (relations)
|
|
12
|
+
* - Has few or no "business" fields beyond IDs and timestamps
|
|
13
|
+
*/
|
|
14
|
+
function detectPivotTable(modelName, fields) {
|
|
15
|
+
// Prisma implicit many-to-many tables start with _
|
|
16
|
+
if (modelName.startsWith('_')) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
// Count different field types
|
|
20
|
+
const relationFields = fields.filter(f => f.relation);
|
|
21
|
+
const idFields = fields.filter(f => f.isId);
|
|
22
|
+
const timestampFields = fields.filter(f => f.isCreatedAt || f.isUpdatedAt);
|
|
23
|
+
const fkFields = fields.filter(f => f.name.toLowerCase().endsWith('id') &&
|
|
24
|
+
!f.isId &&
|
|
25
|
+
SCALAR_TYPES.includes(f.type));
|
|
26
|
+
// Total "structural" fields (not business data)
|
|
27
|
+
const structuralFields = idFields.length + timestampFields.length + fkFields.length + relationFields.length;
|
|
28
|
+
// If almost all fields are structural (IDs, FKs, relations, timestamps)
|
|
29
|
+
// and we have at least 2 FK/relation fields, it's likely a pivot table
|
|
30
|
+
const totalFields = fields.length;
|
|
31
|
+
const businessFields = totalFields - structuralFields;
|
|
32
|
+
// Pivot table: has 2+ relations/FKs and 0-1 business fields
|
|
33
|
+
const hasMultipleRelations = (relationFields.length + fkFields.length) >= 2;
|
|
34
|
+
const hasMinimalBusinessFields = businessFields <= 1;
|
|
35
|
+
return hasMultipleRelations && hasMinimalBusinessFields;
|
|
36
|
+
}
|
|
7
37
|
export function parsePrismaSchema(schemaPath) {
|
|
8
38
|
const content = readFileSync(schemaPath, 'utf-8');
|
|
9
39
|
return parseSchemaContent(content);
|
|
@@ -22,6 +52,12 @@ export function parseSchemaContent(content) {
|
|
|
22
52
|
.filter(line => line && !line.startsWith('//'));
|
|
23
53
|
enums.set(enumName, enumValues);
|
|
24
54
|
}
|
|
55
|
+
// Parse provider du bloc datasource. On ne capture qu'une valeur littérale
|
|
56
|
+
// entre guillemets — `provider = env("DB_PROVIDER")` ou toute expression
|
|
57
|
+
// laisse `provider` à `undefined`, volontairement : le code appelant doit
|
|
58
|
+
// alors dégrader vers le comportement le plus prudent plutôt que deviner.
|
|
59
|
+
const providerMatch = content.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*"([^"]+)"/);
|
|
60
|
+
const provider = providerMatch?.[1];
|
|
25
61
|
// Parse models
|
|
26
62
|
const modelRegex = /(?:\/\/\/\s*(.+)\n)?model\s+(\w+)\s*\{([^}]+)\}/g;
|
|
27
63
|
let modelMatch;
|
|
@@ -31,14 +67,16 @@ export function parseSchemaContent(content) {
|
|
|
31
67
|
const modelBody = modelMatch[3];
|
|
32
68
|
const fields = parseModelFields(modelBody, enums);
|
|
33
69
|
const primaryKey = fields.find(f => f.isId)?.name || 'id';
|
|
70
|
+
const isPivotTable = detectPivotTable(modelName, fields);
|
|
34
71
|
models.push({
|
|
35
72
|
name: modelName,
|
|
36
73
|
fields,
|
|
37
74
|
documentation,
|
|
38
|
-
primaryKey
|
|
75
|
+
primaryKey,
|
|
76
|
+
isPivotTable
|
|
39
77
|
});
|
|
40
78
|
}
|
|
41
|
-
return { models, enums };
|
|
79
|
+
return { models, enums, provider };
|
|
42
80
|
}
|
|
43
81
|
function parseModelFields(modelBody, enums) {
|
|
44
82
|
const fields = [];
|
|
@@ -85,21 +123,29 @@ function parseFieldLine(line, enums, documentation) {
|
|
|
85
123
|
(type === 'DateTime' && /@default\s*\(\s*now\s*\(\s*\)\s*\)/.test(attributes));
|
|
86
124
|
// Parse default value
|
|
87
125
|
let defaultValue;
|
|
88
|
-
|
|
126
|
+
// Le motif accepte un niveau d'imbrication : sans lui, `[^)]+` s'arrêtait à la
|
|
127
|
+
// première parenthèse fermante et tronquait tout défaut sous forme d'appel
|
|
128
|
+
// (`autoincrement()` → 'autoincrement('). Couvre autoincrement/now/cuid/uuid et
|
|
129
|
+
// dbgenerated("…") sans appel imbriqué dans la chaîne.
|
|
130
|
+
const defaultMatch = attributes.match(/@default\s*\(((?:[^()]|\([^)]*\))*)\)/);
|
|
89
131
|
if (defaultMatch) {
|
|
90
132
|
defaultValue = defaultMatch[1].trim();
|
|
91
133
|
}
|
|
92
|
-
// Parse relation
|
|
134
|
+
// Parse relation. Le motif accepte un niveau d'imbrication pour les
|
|
135
|
+
// arguments comme `fields: [authorId]` — sans lui, `[^)]+` s'arrêtait à la
|
|
136
|
+
// première parenthèse fermante et tronquait `name: "..."` placé après.
|
|
93
137
|
let relation;
|
|
94
|
-
const relationMatch = attributes.match(/@relation\s*\(([^)]*)\)/);
|
|
138
|
+
const relationMatch = attributes.match(/@relation\s*\(((?:[^()]|\([^)]*\))*)\)/);
|
|
95
139
|
if (relationMatch || (!SCALAR_TYPES.includes(type) && !enums.has(type))) {
|
|
96
140
|
relation = {
|
|
97
141
|
model: type,
|
|
98
142
|
};
|
|
99
143
|
if (relationMatch) {
|
|
100
144
|
const relContent = relationMatch[1];
|
|
101
|
-
// Parse relation name
|
|
102
|
-
|
|
145
|
+
// Parse relation name : `name: "X"` (nommé) ou `"X"` en première
|
|
146
|
+
// position (chaîne positionnelle, forme utilisée côté back-reference).
|
|
147
|
+
const nameMatch = relContent.match(/name:\s*"([^"]+)"/) ??
|
|
148
|
+
relContent.match(/^\s*"([^"]+)"/);
|
|
103
149
|
if (nameMatch)
|
|
104
150
|
relation.name = nameMatch[1];
|
|
105
151
|
// Parse fields
|
|
@@ -125,69 +171,33 @@ function parseFieldLine(line, enums, documentation) {
|
|
|
125
171
|
isCreatedAt,
|
|
126
172
|
hasDefault,
|
|
127
173
|
defaultValue,
|
|
174
|
+
isEnum: enums.has(type),
|
|
128
175
|
relation,
|
|
129
176
|
documentation
|
|
130
177
|
};
|
|
131
178
|
}
|
|
132
179
|
/**
|
|
133
|
-
*
|
|
180
|
+
* Nom de champ considéré comme sensible : la comparaison est une inclusion en
|
|
181
|
+
* minuscules, donc 'password' couvre `hashedPassword`, 'hash' couvre
|
|
182
|
+
* `passwordHash` et 'token' couvre `apiToken`/`accessToken`/`refreshToken`.
|
|
134
183
|
*/
|
|
135
|
-
|
|
136
|
-
return model.fields.filter(f => !f.relation?.fields && // Skip relation foreign keys shown separately
|
|
137
|
-
!f.isList && // Skip array fields
|
|
138
|
-
!['password', 'hashedPassword', 'hash', 'secret'].some(hidden => f.name.toLowerCase().includes(hidden)));
|
|
139
|
-
}
|
|
184
|
+
const SENSITIVE_FIELD_NAMES = ['password', 'hash', 'secret', 'token'];
|
|
140
185
|
/**
|
|
141
|
-
*
|
|
186
|
+
* Un champ est-il sensible par son nom ? Prédicat UNIQUE, partagé par
|
|
187
|
+
* `getDisplayFields` (liste) et par le module de recherche/filtre
|
|
188
|
+
* (`query/listQuery.ts`) — sans ce partage, les deux heuristiques
|
|
189
|
+
* divergeraient tôt ou tard et un champ masqué de la liste redeviendrait
|
|
190
|
+
* cherchable/filtrable par URL forgée.
|
|
142
191
|
*/
|
|
143
|
-
export function
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
!f.isUpdatedAt &&
|
|
147
|
-
!f.isList &&
|
|
148
|
-
!f.relation?.references // Skip the "other side" of relations
|
|
149
|
-
);
|
|
192
|
+
export function isSensitiveFieldName(name) {
|
|
193
|
+
const lower = name.toLowerCase();
|
|
194
|
+
return SENSITIVE_FIELD_NAMES.some((hidden) => lower.includes(hidden));
|
|
150
195
|
}
|
|
151
196
|
/**
|
|
152
|
-
* Get a
|
|
153
|
-
*/
|
|
154
|
-
export function fieldToLabel(fieldName) {
|
|
155
|
-
return fieldName
|
|
156
|
-
.replace(/([A-Z])/g, ' $1')
|
|
157
|
-
.replace(/^./, str => str.toUpperCase())
|
|
158
|
-
.trim();
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Determine the input type for a Prisma field
|
|
197
|
+
* Get display fields for a model (fields suitable for list view)
|
|
162
198
|
*/
|
|
163
|
-
export function
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
case 'String':
|
|
168
|
-
if (field.name.toLowerCase().includes('email'))
|
|
169
|
-
return 'email';
|
|
170
|
-
if (field.name.toLowerCase().includes('password'))
|
|
171
|
-
return 'password';
|
|
172
|
-
if (field.name.toLowerCase().includes('url'))
|
|
173
|
-
return 'url';
|
|
174
|
-
if (field.name.toLowerCase().includes('description') ||
|
|
175
|
-
field.name.toLowerCase().includes('content') ||
|
|
176
|
-
field.name.toLowerCase().includes('bio'))
|
|
177
|
-
return 'textarea';
|
|
178
|
-
return 'text';
|
|
179
|
-
case 'Int':
|
|
180
|
-
case 'Float':
|
|
181
|
-
case 'Decimal':
|
|
182
|
-
case 'BigInt':
|
|
183
|
-
return 'number';
|
|
184
|
-
case 'Boolean':
|
|
185
|
-
return 'checkbox';
|
|
186
|
-
case 'DateTime':
|
|
187
|
-
return 'datetime';
|
|
188
|
-
case 'Json':
|
|
189
|
-
return 'json';
|
|
190
|
-
default:
|
|
191
|
-
return 'text';
|
|
192
|
-
}
|
|
199
|
+
export function getDisplayFields(model) {
|
|
200
|
+
return model.fields.filter(f => !f.relation?.fields && // Skip relation foreign keys shown separately
|
|
201
|
+
!f.isList && // Skip array fields
|
|
202
|
+
!isSensitiveFieldName(f.name));
|
|
193
203
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation graph builder.
|
|
3
|
+
*
|
|
4
|
+
* Passe de post-traitement sur l'AST brut du parser : apparie les champs
|
|
5
|
+
* relation par clé (modelA, modelB, relationName), classifie chaque arête,
|
|
6
|
+
* et produit les maps de lien entre scalaires FK et relations.
|
|
7
|
+
*
|
|
8
|
+
* Référence design : docs/design/relations.md §1.
|
|
9
|
+
*
|
|
10
|
+
* Règle d'or : ne JAMAIS apparier deux champs relation par « ils pointent
|
|
11
|
+
* vers le même modèle » — `Post { author User, reviewer User }` produirait
|
|
12
|
+
* un appariement aléatoire et silencieusement faux. L'appariement se fait
|
|
13
|
+
* par nom de relation, que Prisma garantit unique pour un couple de modèles.
|
|
14
|
+
*/
|
|
15
|
+
import type { PrismaModel, PrismaSchema } from './parser.js';
|
|
16
|
+
export type RelationKind = 'to-one-owning' | 'to-one-inverse' | 'to-many-inverse' | 'm2m-implicit';
|
|
17
|
+
export type UnsupportedReason = 'composite-fk' | 'ambiguous';
|
|
18
|
+
export interface RelationEdge {
|
|
19
|
+
/** Modèle porteur du champ */
|
|
20
|
+
model: string;
|
|
21
|
+
/** Nom du champ relation */
|
|
22
|
+
field: string;
|
|
23
|
+
kind: RelationKind;
|
|
24
|
+
/** Modèle cible */
|
|
25
|
+
target: string;
|
|
26
|
+
/** Nom de relation (@relation("...")), chaîne vide si absent */
|
|
27
|
+
relationName: string;
|
|
28
|
+
isRequired: boolean;
|
|
29
|
+
isList: boolean;
|
|
30
|
+
/** Noms des scalaires FK portés par ce champ (vide si non owning) */
|
|
31
|
+
scalarFields: string[];
|
|
32
|
+
selfReferential: boolean;
|
|
33
|
+
/** false quand la back-reference n'existe pas dans le schéma */
|
|
34
|
+
hasBackReference: boolean;
|
|
35
|
+
unsupported?: UnsupportedReason;
|
|
36
|
+
}
|
|
37
|
+
export interface RelationGraph {
|
|
38
|
+
/** Arêtes indexées par "Model.field" */
|
|
39
|
+
edges: Map<string, RelationEdge>;
|
|
40
|
+
/** "authorId" → "author" (nom du champ relation owning) */
|
|
41
|
+
scalarToRelation: Map<string, string>;
|
|
42
|
+
/** "Post.author" → ["authorId"] */
|
|
43
|
+
relationToScalars: Map<string, string[]>;
|
|
44
|
+
/** Diagnostics non bloquants (groupes ambigus, etc.) */
|
|
45
|
+
diagnostics: string[];
|
|
46
|
+
}
|
|
47
|
+
export declare function buildRelationGraph(schema: PrismaSchema): RelationGraph;
|
|
48
|
+
/** Raccourci : arêtes d'un modèle donné, dans l'ordre déclaré du schéma. */
|
|
49
|
+
export declare function relationsOf(model: PrismaModel, graph: RelationGraph): RelationEdge[];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation graph builder.
|
|
3
|
+
*
|
|
4
|
+
* Passe de post-traitement sur l'AST brut du parser : apparie les champs
|
|
5
|
+
* relation par clé (modelA, modelB, relationName), classifie chaque arête,
|
|
6
|
+
* et produit les maps de lien entre scalaires FK et relations.
|
|
7
|
+
*
|
|
8
|
+
* Référence design : docs/design/relations.md §1.
|
|
9
|
+
*
|
|
10
|
+
* Règle d'or : ne JAMAIS apparier deux champs relation par « ils pointent
|
|
11
|
+
* vers le même modèle » — `Post { author User, reviewer User }` produirait
|
|
12
|
+
* un appariement aléatoire et silencieusement faux. L'appariement se fait
|
|
13
|
+
* par nom de relation, que Prisma garantit unique pour un couple de modèles.
|
|
14
|
+
*/
|
|
15
|
+
const key = (model, field) => `${model}.${field}`;
|
|
16
|
+
/**
|
|
17
|
+
* Clé d'appariement normalisée : les deux noms de modèle triés + le nom de
|
|
18
|
+
* relation. Triés parce que l'arête owning et l'arête inverse déclarent le
|
|
19
|
+
* même couple dans l'ordre inverse. Les relations self-referential ont
|
|
20
|
+
* naturellement (A, A, name).
|
|
21
|
+
*/
|
|
22
|
+
function pairKey(modelA, modelB, relationName) {
|
|
23
|
+
const [lo, hi] = [modelA, modelB].sort();
|
|
24
|
+
return `${lo}|${hi}|${relationName}`;
|
|
25
|
+
}
|
|
26
|
+
export function buildRelationGraph(schema) {
|
|
27
|
+
const modelNames = new Set(schema.models.map((m) => m.name));
|
|
28
|
+
const edges = new Map();
|
|
29
|
+
const scalarToRelation = new Map();
|
|
30
|
+
const relationToScalars = new Map();
|
|
31
|
+
const diagnostics = [];
|
|
32
|
+
// 1. Collecter les champs dont le type est un nom de modèle.
|
|
33
|
+
const candidates = [];
|
|
34
|
+
for (const model of schema.models) {
|
|
35
|
+
for (const field of model.fields) {
|
|
36
|
+
if (!field.relation || !modelNames.has(field.relation.model))
|
|
37
|
+
continue;
|
|
38
|
+
candidates.push({
|
|
39
|
+
model: model.name,
|
|
40
|
+
field,
|
|
41
|
+
relationName: field.relation.name ?? ''
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// 2. Grouper par clé d'appariement.
|
|
46
|
+
const groups = new Map();
|
|
47
|
+
for (const c of candidates) {
|
|
48
|
+
const pk = pairKey(c.model, c.field.relation.model, c.relationName);
|
|
49
|
+
const group = groups.get(pk) ?? [];
|
|
50
|
+
group.push(c);
|
|
51
|
+
groups.set(pk, group);
|
|
52
|
+
}
|
|
53
|
+
// 3. Classifier chaque groupe.
|
|
54
|
+
for (const [, group] of groups) {
|
|
55
|
+
if (group.length > 2) {
|
|
56
|
+
// Schéma invalide ou bug d'appariement : ne pas deviner. On marque
|
|
57
|
+
// toutes les arêtes du groupe comme ambiguës et on passe à la suite.
|
|
58
|
+
diagnostics.push(`Ambiguous relation group (${group.map((c) => key(c.model, c.field.name)).join(', ')}) — editing disabled`);
|
|
59
|
+
for (const c of group) {
|
|
60
|
+
edges.set(key(c.model, c.field.name), makeEdge(c, {
|
|
61
|
+
kind: 'to-one-owning',
|
|
62
|
+
hasBackReference: true,
|
|
63
|
+
unsupported: 'ambiguous'
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const owning = group.filter((c) => !c.field.isList && (c.field.relation.fields?.length ?? 0) > 0);
|
|
69
|
+
// N-N implicite : groupe de 2, list des deux côtés, aucun fields.
|
|
70
|
+
const [a, b] = group;
|
|
71
|
+
const isImplicitM2M = group.length === 2 && a.field.isList && b.field.isList
|
|
72
|
+
? !a.field.relation.fields?.length && !b.field.relation.fields?.length
|
|
73
|
+
: false;
|
|
74
|
+
for (const c of group) {
|
|
75
|
+
let kind;
|
|
76
|
+
if (isImplicitM2M) {
|
|
77
|
+
kind = 'm2m-implicit';
|
|
78
|
+
}
|
|
79
|
+
else if (c.field.isList) {
|
|
80
|
+
kind = 'to-many-inverse';
|
|
81
|
+
}
|
|
82
|
+
else if (owning.includes(c)) {
|
|
83
|
+
kind = 'to-one-owning';
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
kind = 'to-one-inverse';
|
|
87
|
+
}
|
|
88
|
+
const hasBackReference = group.length === 2;
|
|
89
|
+
const owningFields = c.field.relation.fields;
|
|
90
|
+
const edge = makeEdge(c, { kind, hasBackReference });
|
|
91
|
+
// FK composite : un <option value> ne peut pas porter un tuple.
|
|
92
|
+
if (kind === 'to-one-owning' && owningFields && owningFields.length > 1) {
|
|
93
|
+
edge.unsupported = 'composite-fk';
|
|
94
|
+
diagnostics.push(`Composite FK on ${key(c.model, c.field.name)} — editing disabled, use raw-id`);
|
|
95
|
+
}
|
|
96
|
+
edges.set(key(c.model, c.field.name), edge);
|
|
97
|
+
// 4. Lien scalaires FK ↔ relation owning.
|
|
98
|
+
if (kind === 'to-one-owning' && owningFields) {
|
|
99
|
+
relationToScalars.set(key(c.model, c.field.name), owningFields);
|
|
100
|
+
for (const s of owningFields) {
|
|
101
|
+
scalarToRelation.set(s, c.field.name);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { edges, scalarToRelation, relationToScalars, diagnostics };
|
|
107
|
+
}
|
|
108
|
+
function makeEdge(c, opts) {
|
|
109
|
+
return {
|
|
110
|
+
model: c.model,
|
|
111
|
+
field: c.field.name,
|
|
112
|
+
kind: opts.kind,
|
|
113
|
+
target: c.field.relation.model,
|
|
114
|
+
relationName: c.relationName,
|
|
115
|
+
isRequired: c.field.isRequired,
|
|
116
|
+
isList: c.field.isList,
|
|
117
|
+
scalarFields: c.field.relation.fields ?? [],
|
|
118
|
+
selfReferential: c.model === c.field.relation.model,
|
|
119
|
+
hasBackReference: opts.hasBackReference,
|
|
120
|
+
unsupported: opts.unsupported
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Raccourci : arêtes d'un modèle donné, dans l'ordre déclaré du schéma. */
|
|
124
|
+
export function relationsOf(model, graph) {
|
|
125
|
+
return model.fields
|
|
126
|
+
.filter((f) => graph.edges.has(`${model.name}.${f.name}`))
|
|
127
|
+
.map((f) => graph.edges.get(`${model.name}.${f.name}`));
|
|
128
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter sidebar detection and configuration resolution.
|
|
3
|
+
*
|
|
4
|
+
* Design reference: docs/design/list-search-filters.md §3.5, §5.5, §6, §8.
|
|
5
|
+
*
|
|
6
|
+
* Auto-detection is intentionally narrow — Boolean and enum only — because
|
|
7
|
+
* their value domain is known STATICALLY from the schema (zero extra query
|
|
8
|
+
* to render the sidebar). DateTime, numeric ranges, and FK all require
|
|
9
|
+
* explicit `listFilter` config (DateTime presets are an editorial choice,
|
|
10
|
+
* ranges need two inputs not a fixed set, FK needs a query to load options
|
|
11
|
+
* — see §3.5, §6.2). This is a deliberate perf guard: an auto-detect
|
|
12
|
+
* heuristic that fires `groupBy`/`findMany` on every list render is a trap
|
|
13
|
+
* that can't be removed later without a breaking change.
|
|
14
|
+
*/
|
|
15
|
+
import type { PrismaModel } from '../introspection/parser.js';
|
|
16
|
+
import type { RelationGraph } from '../introspection/relations.js';
|
|
17
|
+
export declare const DATETIME_PRESETS: readonly ["today", "7d", "month", "year"];
|
|
18
|
+
export type DateTimePreset = (typeof DATETIME_PRESETS)[number];
|
|
19
|
+
export type ListFilterConfigEntry = string | {
|
|
20
|
+
field: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
/** DateTime only: which shortcuts to offer as sidebar links (default: all four, §5.5). */
|
|
23
|
+
presets?: DateTimePreset[];
|
|
24
|
+
/** Numeric field only: render two `gte`/`lte` inputs in a GET form instead of a fixed link set. */
|
|
25
|
+
range?: boolean;
|
|
26
|
+
};
|
|
27
|
+
export interface ResolvedFilterField {
|
|
28
|
+
field: string;
|
|
29
|
+
label: string;
|
|
30
|
+
kind: 'boolean' | 'enum' | 'datetime' | 'range' | 'fk';
|
|
31
|
+
/** Only present for kind 'enum'. */
|
|
32
|
+
enumValues?: string[];
|
|
33
|
+
/** Only present for kind 'datetime'. */
|
|
34
|
+
presets?: DateTimePreset[];
|
|
35
|
+
}
|
|
36
|
+
/** A filter entry that was configured as an FK scalar (e.g. `authorId` on Post), with its target relation resolved. */
|
|
37
|
+
export interface FkFilterSpec {
|
|
38
|
+
/** Scalar FK field name, e.g. `authorId`. */
|
|
39
|
+
field: string;
|
|
40
|
+
label: string;
|
|
41
|
+
/** Owning relation field name on this model, e.g. `author`. */
|
|
42
|
+
relationField: string;
|
|
43
|
+
/** Target model name, e.g. `User`. */
|
|
44
|
+
targetModel: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Validate a `listFilter` config entry against the schema at boot time.
|
|
48
|
+
* Invalid config throws immediately — a developer typo (unknown field,
|
|
49
|
+
* sensitive field, relation, Json/Bytes) should fail loud at startup, not
|
|
50
|
+
* silently produce a sidebar entry that does nothing. This is a DIFFERENT
|
|
51
|
+
* failure mode than a forged URL (§5.4 of the design doc): bad config is a
|
|
52
|
+
* developer error, a bad URL is untrusted input that must degrade quietly.
|
|
53
|
+
*/
|
|
54
|
+
export declare function validateListFilterConfig(modelName: string, entries: ListFilterConfigEntry[], model: PrismaModel, relationGraph?: RelationGraph, hidden?: Set<string>): void;
|
|
55
|
+
/**
|
|
56
|
+
* Trouve l'arête to-one-owning qui porte ce scalaire FK sur ce modèle, si
|
|
57
|
+
* elle existe. Lookup direct sur `edges` avec la clé `"Model.field"` — on ne
|
|
58
|
+
* passe PAS par `scalarToRelation` (indexée par nom de champ seul, donc
|
|
59
|
+
* ambiguë si deux modèles ont une FK du même nom, ex: `Post.authorId` et
|
|
60
|
+
* `Comment.authorId`).
|
|
61
|
+
*/
|
|
62
|
+
export declare function findFkEdge(relationGraph: RelationGraph, modelName: string, scalarFieldName: string): import("../introspection/relations.js").RelationEdge | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the filter sidebar entries for a model: explicit `listFilter`
|
|
65
|
+
* config wins (already validated at boot by `validateListFilterConfig`),
|
|
66
|
+
* otherwise the auto-detect heuristic (Boolean + enum fields) — unless
|
|
67
|
+
* `autoDetect` is explicitly disabled, in which case a model with no
|
|
68
|
+
* explicit `listFilter` gets no sidebar at all rather than a heuristic
|
|
69
|
+
* one it didn't ask for.
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveListFilters(model: PrismaModel, enums: Map<string, string[]>, configured: ListFilterConfigEntry[] | undefined, toLabel: (name: string) => string, relationGraph?: RelationGraph, hidden?: Set<string>, autoDetect?: boolean): ResolvedFilterField[];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter sidebar detection and configuration resolution.
|
|
3
|
+
*
|
|
4
|
+
* Design reference: docs/design/list-search-filters.md §3.5, §5.5, §6, §8.
|
|
5
|
+
*
|
|
6
|
+
* Auto-detection is intentionally narrow — Boolean and enum only — because
|
|
7
|
+
* their value domain is known STATICALLY from the schema (zero extra query
|
|
8
|
+
* to render the sidebar). DateTime, numeric ranges, and FK all require
|
|
9
|
+
* explicit `listFilter` config (DateTime presets are an editorial choice,
|
|
10
|
+
* ranges need two inputs not a fixed set, FK needs a query to load options
|
|
11
|
+
* — see §3.5, §6.2). This is a deliberate perf guard: an auto-detect
|
|
12
|
+
* heuristic that fires `groupBy`/`findMany` on every list render is a trap
|
|
13
|
+
* that can't be removed later without a breaking change.
|
|
14
|
+
*/
|
|
15
|
+
import { isSensitiveFieldName } from '../introspection/parser.js';
|
|
16
|
+
const NUMERIC_TYPES = ['Int', 'Float', 'Decimal', 'BigInt'];
|
|
17
|
+
export const DATETIME_PRESETS = ['today', '7d', 'month', 'year'];
|
|
18
|
+
/** Whether a field is eligible for auto-detection: Boolean or enum, not sensitive/hidden/list/relation. */
|
|
19
|
+
function isAutoDetectable(field, hidden) {
|
|
20
|
+
if (field.relation || field.isList)
|
|
21
|
+
return false;
|
|
22
|
+
if (isSensitiveFieldName(field.name))
|
|
23
|
+
return false;
|
|
24
|
+
if (hidden.has(field.name))
|
|
25
|
+
return false;
|
|
26
|
+
return field.type === 'Boolean' || Boolean(field.isEnum);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Validate a `listFilter` config entry against the schema at boot time.
|
|
30
|
+
* Invalid config throws immediately — a developer typo (unknown field,
|
|
31
|
+
* sensitive field, relation, Json/Bytes) should fail loud at startup, not
|
|
32
|
+
* silently produce a sidebar entry that does nothing. This is a DIFFERENT
|
|
33
|
+
* failure mode than a forged URL (§5.4 of the design doc): bad config is a
|
|
34
|
+
* developer error, a bad URL is untrusted input that must degrade quietly.
|
|
35
|
+
*/
|
|
36
|
+
export function validateListFilterConfig(modelName, entries, model, relationGraph, hidden = new Set()) {
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const fieldName = typeof entry === 'string' ? entry : entry.field;
|
|
39
|
+
const field = model.fields.find((f) => f.name === fieldName);
|
|
40
|
+
if (!field) {
|
|
41
|
+
throw new Error(`[sveltekit-admin] listFilter: model "${modelName}" has no field "${fieldName}"`);
|
|
42
|
+
}
|
|
43
|
+
if (field.relation || field.isList) {
|
|
44
|
+
throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" is a relation/list field, not filterable`);
|
|
45
|
+
}
|
|
46
|
+
if (['Json', 'Bytes'].includes(field.type)) {
|
|
47
|
+
throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" is a ${field.type} field, not filterable`);
|
|
48
|
+
}
|
|
49
|
+
if (isSensitiveFieldName(fieldName)) {
|
|
50
|
+
throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" looks sensitive by name, refusing to expose it as a filter`);
|
|
51
|
+
}
|
|
52
|
+
if (hidden.has(fieldName)) {
|
|
53
|
+
throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" is listed in \`hidden\`, refusing to expose it as a filter`);
|
|
54
|
+
}
|
|
55
|
+
const range = typeof entry !== 'string' && entry.range;
|
|
56
|
+
const presets = typeof entry !== 'string' ? entry.presets : undefined;
|
|
57
|
+
if (range && !NUMERIC_TYPES.includes(field.type)) {
|
|
58
|
+
throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" has range:true but type ${field.type} is not numeric`);
|
|
59
|
+
}
|
|
60
|
+
if (presets && field.type !== 'DateTime') {
|
|
61
|
+
throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" has presets but is not a DateTime field`);
|
|
62
|
+
}
|
|
63
|
+
if (presets) {
|
|
64
|
+
const invalid = presets.filter((p) => !DATETIME_PRESETS.includes(p));
|
|
65
|
+
if (invalid.length > 0) {
|
|
66
|
+
throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" has unknown preset(s) ${invalid.join(', ')}, ` +
|
|
67
|
+
`expected one of ${DATETIME_PRESETS.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Un scalaire FK (to-one owning) est un cas légitime de config explicite
|
|
71
|
+
// — les options seront chargées et scopées au moment du rendu, jamais
|
|
72
|
+
// auto-détectées (docs/design §3.5, §6).
|
|
73
|
+
const isFk = Boolean(relationGraph && findFkEdge(relationGraph, modelName, fieldName));
|
|
74
|
+
// Champ finalement supporté par la sidebar si : Boolean, enum, DateTime
|
|
75
|
+
// (avec ou sans presets), numérique avec range:true, ou scalaire FK.
|
|
76
|
+
// Tout le reste (String libre, Int/Float sans range, Json déjà rejeté
|
|
77
|
+
// plus haut) est refusé — pas de filtre silencieusement mort.
|
|
78
|
+
const supported = field.type === 'Boolean' ||
|
|
79
|
+
field.isEnum ||
|
|
80
|
+
field.type === 'DateTime' ||
|
|
81
|
+
(range && NUMERIC_TYPES.includes(field.type)) ||
|
|
82
|
+
isFk;
|
|
83
|
+
if (!supported) {
|
|
84
|
+
throw new Error(`[sveltekit-admin] listFilter: "${modelName}.${fieldName}" has type ${field.type}, ` +
|
|
85
|
+
`only Boolean, enum, DateTime, range:true numeric, and FK scalar fields are supported by the sidebar filter`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Trouve l'arête to-one-owning qui porte ce scalaire FK sur ce modèle, si
|
|
91
|
+
* elle existe. Lookup direct sur `edges` avec la clé `"Model.field"` — on ne
|
|
92
|
+
* passe PAS par `scalarToRelation` (indexée par nom de champ seul, donc
|
|
93
|
+
* ambiguë si deux modèles ont une FK du même nom, ex: `Post.authorId` et
|
|
94
|
+
* `Comment.authorId`).
|
|
95
|
+
*/
|
|
96
|
+
export function findFkEdge(relationGraph, modelName, scalarFieldName) {
|
|
97
|
+
for (const edge of relationGraph.edges.values()) {
|
|
98
|
+
if (edge.model === modelName &&
|
|
99
|
+
edge.kind === 'to-one-owning' &&
|
|
100
|
+
!edge.unsupported &&
|
|
101
|
+
edge.scalarFields.length === 1 &&
|
|
102
|
+
edge.scalarFields[0] === scalarFieldName) {
|
|
103
|
+
return edge;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the filter sidebar entries for a model: explicit `listFilter`
|
|
110
|
+
* config wins (already validated at boot by `validateListFilterConfig`),
|
|
111
|
+
* otherwise the auto-detect heuristic (Boolean + enum fields) — unless
|
|
112
|
+
* `autoDetect` is explicitly disabled, in which case a model with no
|
|
113
|
+
* explicit `listFilter` gets no sidebar at all rather than a heuristic
|
|
114
|
+
* one it didn't ask for.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveListFilters(model, enums, configured, toLabel, relationGraph, hidden = new Set(), autoDetect = true) {
|
|
117
|
+
const fieldNames = configured
|
|
118
|
+
? configured.map((e) => (typeof e === 'string' ? e : e.field))
|
|
119
|
+
: autoDetect
|
|
120
|
+
? model.fields.filter((f) => isAutoDetectable(f, hidden)).map((f) => f.name)
|
|
121
|
+
: [];
|
|
122
|
+
const entryOf = (fieldName) => configured?.find((e) => (typeof e === 'string' ? e : e.field) === fieldName);
|
|
123
|
+
const labelOf = (fieldName) => {
|
|
124
|
+
const entry = entryOf(fieldName);
|
|
125
|
+
return entry && typeof entry !== 'string' ? entry.label : undefined;
|
|
126
|
+
};
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const fieldName of fieldNames) {
|
|
129
|
+
const field = model.fields.find((f) => f.name === fieldName);
|
|
130
|
+
if (!field)
|
|
131
|
+
continue; // Already validated at boot for explicit config; defensive no-op otherwise.
|
|
132
|
+
const entry = entryOf(fieldName);
|
|
133
|
+
const range = Boolean(entry && typeof entry !== 'string' && entry.range);
|
|
134
|
+
const label = labelOf(fieldName) ?? toLabel(fieldName);
|
|
135
|
+
if (range && NUMERIC_TYPES.includes(field.type)) {
|
|
136
|
+
out.push({ field: fieldName, label, kind: 'range' });
|
|
137
|
+
}
|
|
138
|
+
else if (field.type === 'DateTime') {
|
|
139
|
+
const presets = (entry && typeof entry !== 'string' ? entry.presets : undefined) ?? [...DATETIME_PRESETS];
|
|
140
|
+
out.push({ field: fieldName, label, kind: 'datetime', presets });
|
|
141
|
+
}
|
|
142
|
+
else if (field.type === 'Boolean') {
|
|
143
|
+
out.push({ field: fieldName, label, kind: 'boolean' });
|
|
144
|
+
}
|
|
145
|
+
else if (field.isEnum) {
|
|
146
|
+
out.push({ field: fieldName, label, kind: 'enum', enumValues: enums.get(field.type) ?? [] });
|
|
147
|
+
}
|
|
148
|
+
else if (relationGraph && findFkEdge(relationGraph, model.name, fieldName)) {
|
|
149
|
+
out.push({ field: fieldName, label, kind: 'fk' });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|