@an5/orm 1.3.2 → 1.3.3

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.
Files changed (62) hide show
  1. package/README.md +74 -11
  2. package/dist/cleanup.d.ts +1 -0
  3. package/dist/cleanup.js +177 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +147 -0
  6. package/dist/generator/src/attributes.d.ts +57 -0
  7. package/dist/generator/src/attributes.js +225 -0
  8. package/dist/generator/src/code-generator.js +49 -16
  9. package/dist/generator/src/config.d.ts +2 -2
  10. package/dist/generator/src/config.js +4 -3
  11. package/dist/generator/src/dialect.d.ts +52 -1
  12. package/dist/generator/src/dialect.js +136 -3
  13. package/dist/generator/src/dotnet-generator.js +67 -23
  14. package/dist/generator/src/golang-generator.d.ts +8 -0
  15. package/dist/generator/src/golang-generator.js +86 -21
  16. package/dist/generator/src/java-generator.d.ts +8 -0
  17. package/dist/generator/src/java-generator.js +108 -3
  18. package/dist/generator/src/kotlin-generator.js +51 -0
  19. package/dist/generator/src/parser.d.ts +1 -0
  20. package/dist/generator/src/parser.js +90 -35
  21. package/dist/generator/src/pull-schema.d.ts +54 -0
  22. package/dist/generator/src/pull-schema.js +116 -0
  23. package/dist/generator/src/push-apply.d.ts +4 -2
  24. package/dist/generator/src/push-apply.js +53 -4
  25. package/dist/generator/src/push-schema.d.ts +21 -9
  26. package/dist/generator/src/push-schema.js +132 -32
  27. package/dist/generator/src/python-generator.js +35 -22
  28. package/dist/generator/src/rust-generator.js +79 -74
  29. package/dist/generator/src/swift-generator.js +54 -3
  30. package/dist/generator/src/types.d.ts +10 -0
  31. package/dist/generator/src/view-order.d.ts +33 -0
  32. package/dist/generator/src/view-order.js +123 -0
  33. package/dist/migrate.d.ts +12 -0
  34. package/dist/migrate.js +442 -0
  35. package/dist/migration-core.d.ts +32 -3
  36. package/dist/migration-core.js +213 -43
  37. package/dist/provider-support.d.ts +0 -1
  38. package/dist/provider-support.js +0 -1
  39. package/dist/pull.d.ts +1 -0
  40. package/dist/pull.js +425 -0
  41. package/dist/push.d.ts +1 -0
  42. package/dist/push.js +93 -0
  43. package/dist/seed.d.ts +1 -0
  44. package/dist/seed.js +33 -0
  45. package/generator/src/attributes.ts +231 -0
  46. package/generator/src/code-generator.ts +47 -16
  47. package/generator/src/config.ts +5 -3
  48. package/generator/src/dialect.ts +188 -5
  49. package/generator/src/dotnet-generator.ts +68 -24
  50. package/generator/src/golang-generator.ts +89 -22
  51. package/generator/src/java-generator.ts +109 -3
  52. package/generator/src/kotlin-generator.ts +51 -0
  53. package/generator/src/parser.ts +102 -30
  54. package/generator/src/pull-schema.ts +127 -0
  55. package/generator/src/push-apply.ts +62 -5
  56. package/generator/src/push-schema.ts +160 -32
  57. package/generator/src/python-generator.ts +33 -20
  58. package/generator/src/rust-generator.ts +79 -73
  59. package/generator/src/swift-generator.ts +55 -4
  60. package/generator/src/types.ts +10 -0
  61. package/generator/src/view-order.ts +133 -0
  62. package/package.json +19 -14
@@ -7,6 +7,14 @@
7
7
  * lands in. It is kept here rather than inline in `push.ts` so the mapping from
8
8
  * schema text to a column can be tested without a database.
9
9
  */
10
+ import {
11
+ attributeArgs,
12
+ hasAttribute,
13
+ parseIndexDirective,
14
+ splitSchemaLines,
15
+ stringOption,
16
+ unquote,
17
+ } from './attributes';
10
18
  import {
11
19
  FieldTypeIssue,
12
20
  Provider,
@@ -22,6 +30,8 @@ export interface PushField {
22
30
  isOptional: boolean;
23
31
  isId: boolean;
24
32
  isUnique: boolean;
33
+ /** `@unique(map: "…")`, so push and migrate create the same artifact name. */
34
+ uniqueName?: string | undefined;
25
35
  /** The `@default(...)` expression, not SQL: only the dialect knows the spelling. */
26
36
  defaultExpr?: string | undefined;
27
37
  }
@@ -40,6 +50,22 @@ export interface PushModel {
40
50
  indexes: PushIndex[];
41
51
  }
42
52
 
53
+ /**
54
+ * A `view` block, as push needs it.
55
+ *
56
+ * `fields` describe the view's result columns for validation and generated
57
+ * clients; the DDL itself is `sql`, the `@@sql("...")` body.
58
+ */
59
+ export interface PushView {
60
+ name: string;
61
+ /** The database view name; `@@map` may rename it independently. */
62
+ viewName: string;
63
+ schema: string;
64
+ declaredSchema?: string | undefined;
65
+ fields: PushField[];
66
+ sql: string;
67
+ }
68
+
43
69
  /**
44
70
  * The schema a model lands in when the schema file does not say: none, for every
45
71
  * provider, so the name goes wherever the connection resolves.
@@ -71,25 +97,22 @@ export interface PushIndex {
71
97
 
72
98
  export interface PushSchema {
73
99
  models: PushModel[];
100
+ /** `view` blocks, applied after the tables they read from. */
101
+ views: PushView[];
74
102
  /** Field types the provider does not have; empty when the schema is valid. */
75
103
  issues: FieldTypeIssue[];
76
104
  }
77
105
 
78
- /** The field list of `@@unique([a, b], …)`, plus whatever options follow it. */
79
- const DIRECTIVE = /^@@(?:unique|index)\(\s*\[([\w\s,]+)\]([^)]*)\)/;
80
-
81
- function parseDirective(line: string, fields: string): PushIndex {
82
- const options = line.match(DIRECTIVE)?.[2] ?? '';
83
- const name = options.match(/\bmap\s*:\s*"([^"]+)"/)?.[1];
84
- const include = options.match(/\binclude\s*:\s*\[([\w,\s]+)\]/)?.[1];
85
- const filter = options.match(/\bfilter\s*:\s*"([^"]+)"/)?.[1];
86
- const opts = options.match(/\boptions\s*:\s*"([^"]+)"/)?.[1];
106
+ /** Reads `@@unique(…)`/`@@index(…)` into the shape push and migrate share. */
107
+ function parseDirective(line: string, name: '@@unique' | '@@index'): PushIndex | null {
108
+ const parsed = parseIndexDirective(attributeArgs(line, name));
109
+ if (!parsed) return null;
87
110
  return {
88
- fields: fields.split(',').map((field) => field.trim()).filter(Boolean),
89
- ...(name ? { name } : {}),
90
- ...(include ? { includeFields: include.split(',').map((field) => field.trim()).filter(Boolean) } : {}),
91
- ...(filter ? { filter } : {}),
92
- ...(opts ? { options: opts } : {}),
111
+ fields: parsed.fields,
112
+ ...(parsed.name ? { name: parsed.name } : {}),
113
+ ...(parsed.includeFields ? { includeFields: parsed.includeFields } : {}),
114
+ ...(parsed.filter !== undefined ? { filter: parsed.filter } : {}),
115
+ ...(parsed.options !== undefined ? { options: parsed.options } : {}),
93
116
  };
94
117
  }
95
118
 
@@ -122,11 +145,14 @@ export function directiveName(
122
145
  */
123
146
  export function parsePushSchema(text: string, provider: Provider): PushSchema {
124
147
  const models: PushModel[] = [];
148
+ const views: PushView[] = [];
125
149
  const issues: FieldTypeIssue[] = [];
126
150
  const modelRefs: Array<{ model: string; field: string; type: string }> = [];
151
+ const viewRefs: Array<{ view: string; field: string; type: string }> = [];
127
152
  let current: PushModel | null = null;
153
+ let currentView: PushView | null = null;
128
154
 
129
- for (const raw of text.split('\n')) {
155
+ for (const raw of splitSchemaLines(text)) {
130
156
  const line = raw.trim();
131
157
  if (!line || line.startsWith('//')) continue;
132
158
 
@@ -140,37 +166,92 @@ export function parsePushSchema(text: string, provider: Provider): PushSchema {
140
166
  compoundUniques: [],
141
167
  indexes: [],
142
168
  };
169
+ currentView = null;
143
170
  models.push(current);
144
171
  continue;
145
172
  }
146
173
 
174
+ const viewHeader = line.match(/^view\s+(\w+)\s*\{/);
175
+ if (viewHeader) {
176
+ currentView = {
177
+ name: viewHeader[1],
178
+ viewName: viewHeader[1].toLowerCase() + 's',
179
+ schema: defaultSchemaFor(provider),
180
+ fields: [],
181
+ sql: '',
182
+ };
183
+ current = null;
184
+ views.push(currentView);
185
+ continue;
186
+ }
187
+
147
188
  if (line === '}') {
148
189
  current = null;
190
+ currentView = null;
149
191
  continue;
150
192
  }
193
+
194
+ if (currentView) {
195
+ if (line.startsWith('@@sql') || line.startsWith('@@definition')) {
196
+ const attribute = line.startsWith('@@sql') ? '@@sql' : '@@definition';
197
+ const sql = unquote(attributeArgs(line, attribute));
198
+ if (sql !== undefined) currentView.sql = sql.trim();
199
+ continue;
200
+ }
201
+ if (line.startsWith('@@map')) {
202
+ const mapped = unquote(attributeArgs(line, '@@map'));
203
+ if (mapped) currentView.viewName = mapped;
204
+ continue;
205
+ }
206
+ if (line.startsWith('@@schema')) {
207
+ const declared = unquote(attributeArgs(line, '@@schema'));
208
+ if (declared !== undefined) {
209
+ currentView.schema = declared.trim();
210
+ currentView.declaredSchema = currentView.schema;
211
+ }
212
+ continue;
213
+ }
214
+ if (line.startsWith('@@')) continue;
215
+
216
+ const head = readFieldLineHead(line);
217
+ if (!head) continue;
218
+ if (!resolveFieldType(head.type, provider)) {
219
+ // A view's fields are columns of a result set, never relations.
220
+ viewRefs.push({ view: currentView.name, field: head.name, type: head.type });
221
+ continue;
222
+ }
223
+ currentView.fields.push({
224
+ name: head.name,
225
+ sqlType: head.type.toUpperCase(),
226
+ isOptional: head.isOptional,
227
+ isId: false,
228
+ isUnique: false,
229
+ });
230
+ continue;
231
+ }
232
+
151
233
  if (!current) continue;
152
234
 
153
235
  if (line.startsWith('@@map')) {
154
- const match = line.match(/@@map\("(.+)"\)/);
155
- if (match) current.tableName = match[1];
236
+ const mapped = unquote(attributeArgs(line, '@@map'));
237
+ if (mapped) current.tableName = mapped;
156
238
  continue;
157
239
  }
158
240
  if (line.startsWith('@@schema')) {
159
- // `(.*)` rather than `(.+)`, because `@@schema("")` is how a model says "no
160
- // schema" and has to survive as an empty string rather than being dropped as
161
- // no match — which is how SQLite and MySQL schemas are written.
162
- const match = line.match(/@@schema\("(.*)"\)/);
163
- if (match) {
164
- current.schema = match[1].trim();
241
+ // An empty string is meaningful — `@@schema("")` says "no schema" for databases
242
+ // that have none — so it has to survive as `''` rather than being dropped.
243
+ const declared = unquote(attributeArgs(line, '@@schema'));
244
+ if (declared !== undefined) {
245
+ current.schema = declared.trim();
165
246
  current.declaredSchema = current.schema;
166
247
  }
167
248
  continue;
168
249
  }
169
250
  if (line.startsWith('@@unique') || line.startsWith('@@index')) {
170
- const match = line.match(DIRECTIVE);
171
- if (match) {
172
- const definition = parseDirective(line, match[1] ?? '');
173
- if (line.startsWith('@@unique')) current.compoundUniques.push(definition);
251
+ const isUnique = line.startsWith('@@unique');
252
+ const definition = parseDirective(line, isUnique ? '@@unique' : '@@index');
253
+ if (definition) {
254
+ if (isUnique) current.compoundUniques.push(definition);
174
255
  else current.indexes.push(definition);
175
256
  }
176
257
  continue;
@@ -187,17 +268,19 @@ export function parsePushSchema(text: string, provider: Provider): PushSchema {
187
268
 
188
269
  // The type is kept as written (upper-cased) because that is what the database
189
270
  // is told; which types exist is what `resolveFieldType` just checked.
271
+ const uniqueArgs = attributeArgs(line, '@unique');
190
272
  current.fields.push({
191
273
  name: head.name,
192
274
  sqlType: head.type.toUpperCase(),
193
275
  isOptional: head.isOptional,
194
- isId: line.includes('@id'),
195
- isUnique: line.includes('@unique'),
276
+ isId: hasAttribute(line, '@id'),
277
+ isUnique: hasAttribute(line, '@unique'),
278
+ ...(uniqueArgs ? { uniqueName: stringOption(uniqueArgs, 'map') } : {}),
196
279
  // The expression rather than SQL: `uuid()` and `autoincrement()` are spelled
197
280
  // differently per provider, and only the dialect knows how. `@updatedAt` is
198
281
  // `now()` under another name.
199
- defaultExpr: line.match(/@default\((.*)\)/)?.[1]
200
- ?? (line.includes('@updatedAt') ? 'now()' : undefined),
282
+ defaultExpr: attributeArgs(line, '@default')
283
+ ?? (hasAttribute(line, '@updatedAt') ? 'now()' : undefined),
201
284
  });
202
285
  }
203
286
 
@@ -216,8 +299,42 @@ export function parsePushSchema(text: string, provider: Provider): PushSchema {
216
299
  }
217
300
  }
218
301
 
302
+ for (const view of views) {
303
+ if (view.declaredSchema === 'dbo' && provider !== 'mssql') {
304
+ issues.push({
305
+ path: view.name,
306
+ message:
307
+ `schema "dbo" does not exist in ${provider}; remove the @@schema directive or set it ` +
308
+ 'to the schema this database uses',
309
+ });
310
+ }
311
+ if (!view.sql.trim()) {
312
+ issues.push({ path: view.name, message: 'a view needs @@sql("SELECT ...")' });
313
+ }
314
+ }
315
+
316
+ // A `@unique` without `map:` must still get the deterministic name `db:migrate`
317
+ // expects, or push leaves a database-named constraint that the next diff cannot
318
+ // recognise and adds a second one for the same column.
319
+ for (const model of models) {
320
+ const table = safeIdentifierName(model.tableName);
321
+ for (const field of model.fields) {
322
+ if (field.isUnique && !field.isId && !field.uniqueName) {
323
+ field.uniqueName = `UQ_${table}_${safeIdentifierName(field.name)}`;
324
+ }
325
+ }
326
+ }
327
+
219
328
  const modelNames = models.map((model) => model.name);
329
+ const viewNames = new Set(views.map((view) => view.name));
220
330
  for (const ref of modelRefs) {
331
+ if (viewNames.has(ref.type)) {
332
+ issues.push({
333
+ path: `${ref.model}.${ref.field}`,
334
+ message: `"${ref.type}" is a view and cannot be the target of a relation; select from it directly instead`,
335
+ });
336
+ continue;
337
+ }
221
338
  if (!modelNames.includes(ref.type)) {
222
339
  issues.push({
223
340
  path: `${ref.model}.${ref.field}`,
@@ -225,8 +342,14 @@ export function parsePushSchema(text: string, provider: Provider): PushSchema {
225
342
  });
226
343
  }
227
344
  }
345
+ for (const ref of viewRefs) {
346
+ issues.push({
347
+ path: `${ref.view}.${ref.field}`,
348
+ message: unknownFieldTypeMessage(ref.type, provider, modelNames),
349
+ });
350
+ }
228
351
 
229
- return { models, issues };
352
+ return { models, views, issues };
230
353
  }
231
354
 
232
355
  /**
@@ -247,4 +370,9 @@ export function safeIdentifierName(raw: string): string {
247
370
 
248
371
  export function qualifiedTableName(model: PushModel): string {
249
372
  return model.schema ? `${model.schema}.${model.tableName}` : model.tableName;
373
+ }
374
+
375
+ /** The view name to create, qualified only when the view declared a schema. */
376
+ export function qualifiedViewName(view: PushView): string {
377
+ return view.schema ? `${view.schema}.${view.viewName}` : view.viewName;
250
378
  }
@@ -105,27 +105,28 @@ export class PythonGenerator {
105
105
  }
106
106
  content += '\n';
107
107
 
108
- // CreateInput
109
- const requiredFields = model.fields.filter(f => !f.isId && !f.isOptional && !f.hasDefault);
110
- const optionalFields = model.fields.filter(f => !f.isId && (f.isOptional || f.hasDefault));
111
- content += `@dataclass\nclass ${name}CreateInput:\n """Typed data for creating a new ${name} record."""\n`;
112
- for (const f of requiredFields) {
113
- content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f)}\n`;
114
- }
115
- for (const f of optionalFields) {
116
- content += ` ${this.toSnakeCase(f.name)}: Optional[${this.mapPyType(f)}] = None\n`;
117
- }
118
- if (requiredFields.length === 0 && optionalFields.length === 0) content += ' pass\n';
119
- content += '\n';
108
+ // CreateInput / UpdateInput describe writes; a view has none.
109
+ if (!model.isView) {
110
+ const requiredFields = model.fields.filter(f => !f.isId && !f.isOptional && !f.hasDefault);
111
+ const optionalFields = model.fields.filter(f => !f.isId && (f.isOptional || f.hasDefault));
112
+ content += `@dataclass\nclass ${name}CreateInput:\n """Typed data for creating a new ${name} record."""\n`;
113
+ for (const f of requiredFields) {
114
+ content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f)}\n`;
115
+ }
116
+ for (const f of optionalFields) {
117
+ content += ` ${this.toSnakeCase(f.name)}: Optional[${this.mapPyType(f)}] = None\n`;
118
+ }
119
+ if (requiredFields.length === 0 && optionalFields.length === 0) content += ' pass\n';
120
+ content += '\n';
120
121
 
121
- // UpdateInput
122
- const updateFields = model.fields.filter(f => !f.isId);
123
- content += `@dataclass\nclass ${name}UpdateInput:\n """Typed data for updating an existing ${name} record."""\n`;
124
- for (const f of updateFields) {
125
- content += ` ${this.toSnakeCase(f.name)}: Optional[${this.mapPyType(f)}] = None\n`;
122
+ const updateFields = model.fields.filter(f => !f.isId);
123
+ content += `@dataclass\nclass ${name}UpdateInput:\n """Typed data for updating an existing ${name} record."""\n`;
124
+ for (const f of updateFields) {
125
+ content += ` ${this.toSnakeCase(f.name)}: Optional[${this.mapPyType(f)}] = None\n`;
126
+ }
127
+ if (updateFields.length === 0) content += ' pass\n';
128
+ content += '\n';
126
129
  }
127
- if (updateFields.length === 0) content += ' pass\n';
128
- content += '\n';
129
130
 
130
131
  // FindManyArgs
131
132
  content += `@dataclass\nclass ${name}FindManyArgs:\n """ORM-style args for ${name}.find_many()."""\n`;
@@ -328,6 +329,7 @@ export class PythonGenerator {
328
329
  }
329
330
 
330
331
  private generateClient(models: Model[], outputDir: string) {
332
+ const hasViews = models.some((model) => model.isView);
331
333
  let content = '# This file is auto-generated. Do not edit directly.\n';
332
334
  content += 'import os\n';
333
335
  content += 'from typing import Dict, List, Optional, Any, Callable, TYPE_CHECKING\n\n';
@@ -337,7 +339,7 @@ export class PythonGenerator {
337
339
  // generated output dir, so no relative-import fallback here — a
338
340
  // relative fallback would be unresolvable and trip Pylance
339
341
  // `reportMissingImports` on every generated client.
340
- content += 'from an5_adapter import An5Adapter, AdapterTableClient, create_an5_adapter, set_adapter_metadata\n\n';
342
+ content += `from an5_adapter import An5Adapter, AdapterTableClient${hasViews ? ', ViewClient' : ''}, create_an5_adapter, set_adapter_metadata\n\n`;
341
343
 
342
344
  const metadataModule = this.metadataModule();
343
345
  content += 'try:\n';
@@ -376,6 +378,17 @@ export class PythonGenerator {
376
378
  for (const model of models) {
377
379
  const propName = this.toSnakeCase(model.name) + 's';
378
380
  const singleName = this.toSnakeCase(model.name);
381
+ if (model.isView) {
382
+ // The adapter's ViewClient refuses writes; assigning it here is what makes
383
+ // `db.activeUsers.update(...)` fail with the read-only message instead of
384
+ // reaching the database as a table write.
385
+ content += ` client = self.adapter.view("${model.name}")\n`;
386
+ content += ` self.${model.name}: ViewClient = client\n`;
387
+ content += ` self.${model.name}s: ViewClient = client\n`;
388
+ content += ` self.${singleName}: ViewClient = client\n`;
389
+ content += ` self.${propName}: ViewClient = client\n`;
390
+ continue;
391
+ }
379
392
  content += ` client = AdapterTableClient(self.adapter, "${model.name}")\n`;
380
393
  content += ` self.${model.name}: AdapterTableClient["${model.name}Row"] = client\n`;
381
394
  content += ` self.${model.name}s: AdapterTableClient["${model.name}Row"] = client\n`;
@@ -441,34 +441,37 @@ pub struct DateTimeFilter {
441
441
  }
442
442
  s.push(`}\n`);
443
443
 
444
- // CreateInput (skip @id auto fields from required set via hasDefault)
445
- s.push(`/// Typed data for creating a new ${name} record.`);
446
- s.push(`#[derive(Debug, Clone, Serialize, Deserialize)]`);
447
- s.push(`pub struct ${name}CreateInput {`);
448
- for (const f of model.fields) {
449
- if (f.isId && f.hasDefault) continue;
450
- const snake = this.toSnakeCase(f.name);
451
- const isOpt = f.isOptional || f.hasDefault;
452
- const rsType = this.mapRustType(f, isOpt);
453
- if (isOpt) {
444
+ // CreateInput / UpdateInput describe writes; a view has none.
445
+ if (!model.isView) {
446
+ // CreateInput (skip @id auto fields from required set via hasDefault)
447
+ s.push(`/// Typed data for creating a new ${name} record.`);
448
+ s.push(`#[derive(Debug, Clone, Serialize, Deserialize)]`);
449
+ s.push(`pub struct ${name}CreateInput {`);
450
+ for (const f of model.fields) {
451
+ if (f.isId && f.hasDefault) continue;
452
+ const snake = this.toSnakeCase(f.name);
453
+ const isOpt = f.isOptional || f.hasDefault;
454
+ const rsType = this.mapRustType(f, isOpt);
455
+ if (isOpt) {
456
+ s.push(` #[serde(default, skip_serializing_if = "Option::is_none")]`);
457
+ }
458
+ s.push(` pub ${snake}: ${rsType},`);
459
+ }
460
+ s.push(`}\n`);
461
+
462
+ // UpdateInput (all non-id optional)
463
+ s.push(`/// Typed data for updating an existing ${name} record.`);
464
+ s.push(`#[derive(Debug, Clone, Default, Serialize, Deserialize)]`);
465
+ s.push(`pub struct ${name}UpdateInput {`);
466
+ for (const f of model.fields) {
467
+ if (f.isId) continue;
468
+ const snake = this.toSnakeCase(f.name);
469
+ const rsType = this.mapRustType(f, true);
454
470
  s.push(` #[serde(default, skip_serializing_if = "Option::is_none")]`);
471
+ s.push(` pub ${snake}: ${rsType},`);
455
472
  }
456
- s.push(` pub ${snake}: ${rsType},`);
457
- }
458
- s.push(`}\n`);
459
-
460
- // UpdateInput (all non-id optional)
461
- s.push(`/// Typed data for updating an existing ${name} record.`);
462
- s.push(`#[derive(Debug, Clone, Default, Serialize, Deserialize)]`);
463
- s.push(`pub struct ${name}UpdateInput {`);
464
- for (const f of model.fields) {
465
- if (f.isId) continue;
466
- const snake = this.toSnakeCase(f.name);
467
- const rsType = this.mapRustType(f, true);
468
- s.push(` #[serde(default, skip_serializing_if = "Option::is_none")]`);
469
- s.push(` pub ${snake}: ${rsType},`);
473
+ s.push(`}\n`);
470
474
  }
471
- s.push(`}\n`);
472
475
 
473
476
  // FindManyArgs / FindFirstArgs / FindUniqueArgs / CountArgs
474
477
  s.push(`/// ORM-style args for ${name}.find_many().`);
@@ -518,14 +521,16 @@ pub struct DateTimeFilter {
518
521
  s.push(`}\n`);
519
522
 
520
523
  // UpdateArgs carries the typed input alongside the where clause.
521
- s.push(`/// ORM-style args for ${name}.update().`);
522
- s.push(`#[derive(Debug, Clone, Default, Serialize, Deserialize)]`);
523
- s.push(`pub struct ${name}UpdateArgs {`);
524
- s.push(` #[serde(default, skip_serializing_if = "Option::is_none")]`);
525
- s.push(` pub where_: Option<${name}WhereInput>,`);
526
- s.push(` #[serde(default)]`);
527
- s.push(` pub data: ${name}UpdateInput,`);
528
- s.push(`}\n`);
524
+ if (!model.isView) {
525
+ s.push(`/// ORM-style args for ${name}.update().`);
526
+ s.push(`#[derive(Debug, Clone, Default, Serialize, Deserialize)]`);
527
+ s.push(`pub struct ${name}UpdateArgs {`);
528
+ s.push(` #[serde(default, skip_serializing_if = "Option::is_none")]`);
529
+ s.push(` pub where_: Option<${name}WhereInput>,`);
530
+ s.push(` #[serde(default)]`);
531
+ s.push(` pub data: ${name}UpdateInput,`);
532
+ s.push(`}\n`);
533
+ }
529
534
  }
530
535
  return s.join('\n') + '\n';
531
536
  }
@@ -617,7 +622,7 @@ pub struct DateTimeFilter {
617
622
 
618
623
  use an5_adapters::{
619
624
  AdapterMetadata, An5Adapter, CountArgs, DeleteManyArgs, FindManyArgs, RowMap, TableClient,
620
- UpdateArgs, VectorSearchArgs,
625
+ UpdateArgs, VectorSearchArgs${models.some((model) => model.isView) ? ', ViewClient' : ''},
621
626
  };
622
627
  use serde::de::DeserializeOwned;
623
628
 
@@ -805,6 +810,11 @@ impl SqlBuilder {
805
810
  private buildModelRuntimeHandle(model: Model): string {
806
811
  const name = this.capitalize(model.name);
807
812
  const snake = this.toSnakeCase(model.name);
813
+ // A view is read through the runtime's read-only ViewClient, so the handle
814
+ // for a view cannot expose write methods at all.
815
+ const reader = model.isView
816
+ ? `self.db.adapter().view("${model.name}")`
817
+ : `self.db.table("${model.name}")`;
808
818
  const lines: string[] = [];
809
819
 
810
820
  lines.push(`/// Typed queries for ${name}. Obtained via \`An5Client::${snake}()\`.`);
@@ -825,9 +835,7 @@ impl SqlBuilder {
825
835
  lines.push(` pub fn adapter(&self) -> &An5Adapter { self.db.adapter() }`);
826
836
  lines.push(``);
827
837
  lines.push(` pub async fn find_many(&self, args: &${name}FindManyArgs) -> Result<Vec<${name}>> {`);
828
- lines.push(` let rows = self`);
829
- lines.push(` .db`);
830
- lines.push(` .table("${model.name}")`);
838
+ lines.push(` let rows = ${reader}`);
831
839
  lines.push(` .find_many(&FindManyArgs {`);
832
840
  lines.push(` r#where: to_value(&args.where_)?,`);
833
841
  lines.push(` order_by: to_value(&args.order_by)?,`);
@@ -842,9 +850,7 @@ impl SqlBuilder {
842
850
  lines.push(``);
843
851
  lines.push(` /// The rows nearest \`vector\`, each carrying a \`distance\`.`);
844
852
  lines.push(` pub async fn vector_search(&self, args: &${name}VectorSearchArgs) -> Result<Vec<(${name}, f64)>> {`);
845
- lines.push(` let rows = self`);
846
- lines.push(` .db`);
847
- lines.push(` .table("${model.name}")`);
853
+ lines.push(` let rows = ${reader}`);
848
854
  lines.push(` .vector_search(&VectorSearchArgs {`);
849
855
  lines.push(` vector: args.vector.iter().map(|v| *v as f64).collect(),`);
850
856
  lines.push(` take: args.take.unwrap_or(0),`);
@@ -862,9 +868,7 @@ impl SqlBuilder {
862
868
  lines.push(` }`);
863
869
  lines.push(``);
864
870
  lines.push(` pub async fn find_first(&self, args: &${name}FindFirstArgs) -> Result<Option<${name}>> {`);
865
- lines.push(` let row = self`);
866
- lines.push(` .db`);
867
- lines.push(` .table("${model.name}")`);
871
+ lines.push(` let row = ${reader}`);
868
872
  lines.push(` .find_first(&FindManyArgs {`);
869
873
  lines.push(` r#where: to_value(&args.where_)?,`);
870
874
  lines.push(` order_by: to_value(&args.order_by)?,`);
@@ -878,9 +882,7 @@ impl SqlBuilder {
878
882
  lines.push(` }`);
879
883
  lines.push(``);
880
884
  lines.push(` pub async fn find_unique(&self, args: &${name}FindUniqueArgs) -> Result<Option<${name}>> {`);
881
- lines.push(` let row = self`);
882
- lines.push(` .db`);
883
- lines.push(` .table("${model.name}")`);
885
+ lines.push(` let row = ${reader}`);
884
886
  lines.push(` .find_unique(&FindManyArgs {`);
885
887
  lines.push(` r#where: to_value(&args.where_)?,`);
886
888
  lines.push(` ..Default::default()`);
@@ -893,37 +895,41 @@ impl SqlBuilder {
893
895
  lines.push(` }`);
894
896
  lines.push(``);
895
897
  lines.push(` pub async fn count(&self, args: &${name}FindUniqueArgs) -> Result<i64> {`);
896
- lines.push(` self.db`);
897
- lines.push(` .table("${model.name}")`);
898
+ lines.push(` ${reader}`);
898
899
  lines.push(` .count(&CountArgs { r#where: to_value(&args.where_)? })`);
899
900
  lines.push(` .await`);
900
901
  lines.push(` }`);
901
902
  lines.push(``);
902
- lines.push(` pub async fn create(&self, data: &${name}CreateInput) -> Result<${name}> {`);
903
- lines.push(` let row = self`);
904
- lines.push(` .db`);
905
- lines.push(` .table("${model.name}")`);
906
- lines.push(` .create(&to_map(data)?)`);
907
- lines.push(` .await?;`);
908
- lines.push(` deserialize_row(row)`);
909
- lines.push(` }`);
910
- lines.push(``);
911
- lines.push(` pub async fn update(&self, args: &${name}UpdateArgs) -> Result<u64> {`);
912
- lines.push(` self.db`);
913
- lines.push(` .table("${model.name}")`);
914
- lines.push(` .update(&UpdateArgs {`);
915
- lines.push(` r#where: to_value(&args.where_)?,`);
916
- lines.push(` data: serde_json::to_value(&args.data)?,`);
917
- lines.push(` })`);
918
- lines.push(` .await`);
919
- lines.push(` }`);
920
- lines.push(``);
921
- lines.push(` pub async fn delete(&self, args: &${name}FindUniqueArgs) -> Result<u64> {`);
922
- lines.push(` self.db`);
923
- lines.push(` .table("${model.name}")`);
924
- lines.push(` .delete_many(&DeleteManyArgs { r#where: to_value(&args.where_)? })`);
925
- lines.push(` .await`);
926
- lines.push(` }`);
903
+
904
+ // Writes are generated for tables only: a view has no CreateInput or
905
+ // UpdateInput, so these methods could not be called even if emitted.
906
+ if (!model.isView) {
907
+ lines.push(` pub async fn create(&self, data: &${name}CreateInput) -> Result<${name}> {`);
908
+ lines.push(` let row = self`);
909
+ lines.push(` .db`);
910
+ lines.push(` .table("${model.name}")`);
911
+ lines.push(` .create(&to_map(data)?)`);
912
+ lines.push(` .await?;`);
913
+ lines.push(` deserialize_row(row)`);
914
+ lines.push(` }`);
915
+ lines.push(``);
916
+ lines.push(` pub async fn update(&self, args: &${name}UpdateArgs) -> Result<u64> {`);
917
+ lines.push(` self.db`);
918
+ lines.push(` .table("${model.name}")`);
919
+ lines.push(` .update(&UpdateArgs {`);
920
+ lines.push(` r#where: to_value(&args.where_)?,`);
921
+ lines.push(` data: serde_json::to_value(&args.data)?,`);
922
+ lines.push(` })`);
923
+ lines.push(` .await`);
924
+ lines.push(` }`);
925
+ lines.push(``);
926
+ lines.push(` pub async fn delete(&self, args: &${name}FindUniqueArgs) -> Result<u64> {`);
927
+ lines.push(` self.db`);
928
+ lines.push(` .table("${model.name}")`);
929
+ lines.push(` .delete_many(&DeleteManyArgs { r#where: to_value(&args.where_)? })`);
930
+ lines.push(` .await`);
931
+ lines.push(` }`);
932
+ }
927
933
  lines.push(`}`);
928
934
  lines.push(``);
929
935
  return lines.join('\n') + '\n';