@an5/orm 1.0.9 → 1.1.1

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 (57) hide show
  1. package/README.md +10 -3
  2. package/dist/config.d.ts +9 -1
  3. package/dist/config.js +13 -18
  4. package/dist/generator/src/api.d.ts +35 -0
  5. package/dist/generator/src/api.js +58 -0
  6. package/dist/generator/src/config.d.ts +162 -0
  7. package/dist/generator/src/config.js +354 -0
  8. package/dist/generator/src/dialect.d.ts +88 -0
  9. package/dist/generator/src/dialect.js +318 -0
  10. package/dist/generator/src/dotnet-generator.d.ts +8 -0
  11. package/dist/generator/src/dotnet-generator.js +282 -101
  12. package/dist/generator/src/field-types.d.ts +96 -0
  13. package/dist/generator/src/field-types.js +433 -0
  14. package/dist/generator/src/golang-generator.d.ts +36 -0
  15. package/dist/generator/src/golang-generator.js +1230 -0
  16. package/dist/generator/src/index.js +69 -36
  17. package/dist/generator/src/metadata-generator.js +2 -1
  18. package/dist/generator/src/parser.d.ts +28 -2
  19. package/dist/generator/src/parser.js +92 -90
  20. package/dist/generator/src/push-apply.d.ts +37 -0
  21. package/dist/generator/src/push-apply.js +109 -0
  22. package/dist/generator/src/push-schema.d.ts +93 -0
  23. package/dist/generator/src/push-schema.js +189 -0
  24. package/dist/generator/src/python-generator.d.ts +19 -1
  25. package/dist/generator/src/python-generator.js +147 -64
  26. package/dist/generator/src/rust-generator.d.ts +38 -0
  27. package/dist/generator/src/rust-generator.js +971 -0
  28. package/dist/generator/src/suggest.d.ts +17 -0
  29. package/dist/generator/src/suggest.js +45 -0
  30. package/dist/generator/src/type-kinds.d.ts +27 -0
  31. package/dist/generator/src/type-kinds.js +72 -0
  32. package/dist/generator/src/types.d.ts +33 -0
  33. package/dist/generator/src/types.js +39 -0
  34. package/dist/migration-core.d.ts +16 -1
  35. package/dist/migration-core.js +60 -21
  36. package/dist/orm.d.ts +147 -0
  37. package/dist/orm.js +1654 -0
  38. package/dist/provider-support.d.ts +27 -0
  39. package/dist/provider-support.js +50 -0
  40. package/dist/sql-utils.js +8 -3
  41. package/generator/src/api.ts +45 -0
  42. package/generator/src/config.ts +496 -0
  43. package/generator/src/dialect.ts +415 -0
  44. package/generator/src/dotnet-generator.ts +277 -88
  45. package/generator/src/field-types.ts +473 -0
  46. package/generator/src/golang-generator.ts +94 -68
  47. package/generator/src/index.ts +71 -36
  48. package/generator/src/metadata-generator.ts +2 -2
  49. package/generator/src/parser.ts +122 -90
  50. package/generator/src/push-apply.ts +148 -0
  51. package/generator/src/push-schema.ts +250 -0
  52. package/generator/src/python-generator.ts +118 -62
  53. package/generator/src/rust-generator.ts +974 -0
  54. package/generator/src/suggest.ts +41 -0
  55. package/generator/src/type-kinds.ts +78 -0
  56. package/generator/src/types.ts +48 -0
  57. package/package.json +26 -6
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Reading `.an5` files for `db:push`.
3
+ *
4
+ * `SchemaParser` and `parseSchemaText` cover the generators and `db:migrate`;
5
+ * this is the third reader, and it exists because push needs a shape of its own:
6
+ * raw `@default` expressions rather than rendered SQL, and the schema a table
7
+ * lands in. It is kept here rather than inline in `push.ts` so the mapping from
8
+ * schema text to a column can be tested without a database.
9
+ */
10
+ import { FieldTypeIssue, Provider } from './field-types';
11
+ /** A column, as push needs it: the type as written plus what the dialect renders. */
12
+ export interface PushField {
13
+ name: string;
14
+ sqlType: string;
15
+ isOptional: boolean;
16
+ isId: boolean;
17
+ isUnique: boolean;
18
+ /** The `@default(...)` expression, not SQL: only the dialect knows the spelling. */
19
+ defaultExpr?: string | undefined;
20
+ }
21
+ export interface PushModel {
22
+ name: string;
23
+ tableName: string;
24
+ /** The schema to create the table in; `''` when the provider has none. */
25
+ schema: string;
26
+ /** Set only when the schema file named one, so a wrong one can be reported. */
27
+ declaredSchema?: string | undefined;
28
+ fields: PushField[];
29
+ /** `@@unique([a, b])`, as field lists. */
30
+ compoundUniques: PushIndex[];
31
+ /** `@@index([a, b])`, as field lists. */
32
+ indexes: PushIndex[];
33
+ }
34
+ /**
35
+ * The schema a model lands in when the schema file does not say: none, for every
36
+ * provider, so the name goes wherever the connection resolves.
37
+ *
38
+ * The parser used to default this to `dbo` and push followed it, which failed two
39
+ * ways: SQLite said "unknown database [dbo]", and a SQL Server login whose default
40
+ * schema was something else had push look for `[dbo].[widgets]`, miss the table it
41
+ * had created, and then fail with "there is already an object named ...".
42
+ */
43
+ export declare function defaultSchemaFor(_provider: Provider): string;
44
+ /**
45
+ * One `@@unique` or `@@index`, with the options `db:migrate` already understands.
46
+ *
47
+ * `name` matters as much as the fields: `map:` is how a schema asks for a specific
48
+ * artifact name, and if push invented its own the two commands would create two
49
+ * different constraints for the same directive — and the next migration would keep
50
+ * trying to add the one push never made.
51
+ */
52
+ export interface PushIndex {
53
+ fields: string[];
54
+ name?: string | undefined;
55
+ includeFields?: string[] | undefined;
56
+ filter?: string | undefined;
57
+ options?: string | undefined;
58
+ }
59
+ export interface PushSchema {
60
+ models: PushModel[];
61
+ /** Field types the provider does not have; empty when the schema is valid. */
62
+ issues: FieldTypeIssue[];
63
+ }
64
+ /**
65
+ * The name a `@@unique` or `@@index` artifact gets.
66
+ *
67
+ * `map:` wins; otherwise the name is derived from the table and fields, which is what
68
+ * both commands have always done for an unnamed directive.
69
+ */
70
+ export declare function directiveName(directive: PushIndex, table: string, kind: 'compound' | 'index', position: number): string;
71
+ /**
72
+ * Reads schema text for push, validating every field type against the provider.
73
+ *
74
+ * All bad types come back in `issues` rather than as an exception, so the caller
75
+ * can print them as a group. A field whose type matches no table is only a
76
+ * mistake once the whole schema has been read: it may be a relation to a model
77
+ * declared further down.
78
+ */
79
+ export declare function parsePushSchema(text: string, provider: Provider): PushSchema;
80
+ /**
81
+ * The table name to create, `schema.table` unless the model cleared the schema.
82
+ *
83
+ * An empty schema has to stay empty: `[].[widgets]` is not valid SQL, which is the
84
+ * whole reason `@@schema("")` exists.
85
+ */
86
+ /**
87
+ * An identifier for a generated artifact name: letters, digits and underscores only.
88
+ *
89
+ * The name goes into DDL as an identifier, so anything else is replaced rather than
90
+ * quoted — `@@map("catalog entries")` must not produce a name the provider rejects.
91
+ */
92
+ export declare function safeIdentifierName(raw: string): string;
93
+ export declare function qualifiedTableName(model: PushModel): string;
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultSchemaFor = defaultSchemaFor;
4
+ exports.directiveName = directiveName;
5
+ exports.parsePushSchema = parsePushSchema;
6
+ exports.safeIdentifierName = safeIdentifierName;
7
+ exports.qualifiedTableName = qualifiedTableName;
8
+ /**
9
+ * Reading `.an5` files for `db:push`.
10
+ *
11
+ * `SchemaParser` and `parseSchemaText` cover the generators and `db:migrate`;
12
+ * this is the third reader, and it exists because push needs a shape of its own:
13
+ * raw `@default` expressions rather than rendered SQL, and the schema a table
14
+ * lands in. It is kept here rather than inline in `push.ts` so the mapping from
15
+ * schema text to a column can be tested without a database.
16
+ */
17
+ const field_types_1 = require("./field-types");
18
+ /**
19
+ * The schema a model lands in when the schema file does not say: none, for every
20
+ * provider, so the name goes wherever the connection resolves.
21
+ *
22
+ * The parser used to default this to `dbo` and push followed it, which failed two
23
+ * ways: SQLite said "unknown database [dbo]", and a SQL Server login whose default
24
+ * schema was something else had push look for `[dbo].[widgets]`, miss the table it
25
+ * had created, and then fail with "there is already an object named ...".
26
+ */
27
+ function defaultSchemaFor(_provider) {
28
+ return '';
29
+ }
30
+ /** The field list of `@@unique([a, b], …)`, plus whatever options follow it. */
31
+ const DIRECTIVE = /^@@(?:unique|index)\(\s*\[([\w\s,]+)\]([^)]*)\)/;
32
+ function parseDirective(line, fields) {
33
+ const options = line.match(DIRECTIVE)?.[2] ?? '';
34
+ const name = options.match(/\bmap\s*:\s*"([^"]+)"/)?.[1];
35
+ const include = options.match(/\binclude\s*:\s*\[([\w,\s]+)\]/)?.[1];
36
+ const filter = options.match(/\bfilter\s*:\s*"([^"]+)"/)?.[1];
37
+ const opts = options.match(/\boptions\s*:\s*"([^"]+)"/)?.[1];
38
+ return {
39
+ fields: fields.split(',').map((field) => field.trim()).filter(Boolean),
40
+ ...(name ? { name } : {}),
41
+ ...(include ? { includeFields: include.split(',').map((field) => field.trim()).filter(Boolean) } : {}),
42
+ ...(filter ? { filter } : {}),
43
+ ...(opts ? { options: opts } : {}),
44
+ };
45
+ }
46
+ /**
47
+ * The name a `@@unique` or `@@index` artifact gets.
48
+ *
49
+ * `map:` wins; otherwise the name is derived from the table and fields, which is what
50
+ * both commands have always done for an unnamed directive.
51
+ */
52
+ function directiveName(directive, table, kind, position) {
53
+ if (directive.name)
54
+ return safeIdentifierName(directive.name);
55
+ const base = safeIdentifierName(table);
56
+ return kind === 'compound'
57
+ ? `UQ_${base}_compound_${position}`
58
+ : `IX_${base}_${directive.fields.join('_')}`;
59
+ }
60
+ /**
61
+ * Reads schema text for push, validating every field type against the provider.
62
+ *
63
+ * All bad types come back in `issues` rather than as an exception, so the caller
64
+ * can print them as a group. A field whose type matches no table is only a
65
+ * mistake once the whole schema has been read: it may be a relation to a model
66
+ * declared further down.
67
+ */
68
+ function parsePushSchema(text, provider) {
69
+ const models = [];
70
+ const issues = [];
71
+ const modelRefs = [];
72
+ let current = null;
73
+ for (const raw of text.split('\n')) {
74
+ const line = raw.trim();
75
+ if (!line || line.startsWith('//'))
76
+ continue;
77
+ const header = line.match(/^model\s+(\w+)\s*\{/);
78
+ if (header) {
79
+ current = {
80
+ name: header[1],
81
+ tableName: header[1].toLowerCase() + 's',
82
+ schema: defaultSchemaFor(provider),
83
+ fields: [],
84
+ compoundUniques: [],
85
+ indexes: [],
86
+ };
87
+ models.push(current);
88
+ continue;
89
+ }
90
+ if (line === '}') {
91
+ current = null;
92
+ continue;
93
+ }
94
+ if (!current)
95
+ continue;
96
+ if (line.startsWith('@@map')) {
97
+ const match = line.match(/@@map\("(.+)"\)/);
98
+ if (match)
99
+ current.tableName = match[1];
100
+ continue;
101
+ }
102
+ if (line.startsWith('@@schema')) {
103
+ // `(.*)` rather than `(.+)`, because `@@schema("")` is how a model says "no
104
+ // schema" and has to survive as an empty string rather than being dropped as
105
+ // no match — which is how SQLite and MySQL schemas are written.
106
+ const match = line.match(/@@schema\("(.*)"\)/);
107
+ if (match) {
108
+ current.schema = match[1].trim();
109
+ current.declaredSchema = current.schema;
110
+ }
111
+ continue;
112
+ }
113
+ if (line.startsWith('@@unique') || line.startsWith('@@index')) {
114
+ const match = line.match(DIRECTIVE);
115
+ if (match) {
116
+ const definition = parseDirective(line, match[1] ?? '');
117
+ if (line.startsWith('@@unique'))
118
+ current.compoundUniques.push(definition);
119
+ else
120
+ current.indexes.push(definition);
121
+ }
122
+ continue;
123
+ }
124
+ if (line.startsWith('@@'))
125
+ continue;
126
+ const head = (0, field_types_1.readFieldLineHead)(line);
127
+ if (!head)
128
+ continue;
129
+ if (!(0, field_types_1.resolveFieldType)(head.type, provider)) {
130
+ modelRefs.push({ model: current.name, field: head.name, type: head.type });
131
+ continue;
132
+ }
133
+ // The type is kept as written (upper-cased) because that is what the database
134
+ // is told; which types exist is what `resolveFieldType` just checked.
135
+ current.fields.push({
136
+ name: head.name,
137
+ sqlType: head.type.toUpperCase(),
138
+ isOptional: head.isOptional,
139
+ isId: line.includes('@id'),
140
+ isUnique: line.includes('@unique'),
141
+ // The expression rather than SQL: `uuid()` and `autoincrement()` are spelled
142
+ // differently per provider, and only the dialect knows how. `@updatedAt` is
143
+ // `now()` under another name.
144
+ defaultExpr: line.match(/@default\((.*)\)/)?.[1]
145
+ ?? (line.includes('@updatedAt') ? 'now()' : undefined),
146
+ });
147
+ }
148
+ // A declared `dbo` on a provider without schemas would push to a database that
149
+ // does not exist there; SQLite answers "unknown database [dbo]", which says
150
+ // nothing about the schema file. Only an explicit declaration is worth reporting:
151
+ // an absent one now means "whatever the connection resolves", on every provider.
152
+ for (const model of models) {
153
+ if (model.declaredSchema === 'dbo' && provider !== 'mssql') {
154
+ issues.push({
155
+ path: model.name,
156
+ message: `schema "dbo" does not exist in ${provider}; remove the @@schema directive or set it ` +
157
+ 'to the schema this database uses',
158
+ });
159
+ }
160
+ }
161
+ const modelNames = models.map((model) => model.name);
162
+ for (const ref of modelRefs) {
163
+ if (!modelNames.includes(ref.type)) {
164
+ issues.push({
165
+ path: `${ref.model}.${ref.field}`,
166
+ message: (0, field_types_1.unknownFieldTypeMessage)(ref.type, provider, modelNames),
167
+ });
168
+ }
169
+ }
170
+ return { models, issues };
171
+ }
172
+ /**
173
+ * The table name to create, `schema.table` unless the model cleared the schema.
174
+ *
175
+ * An empty schema has to stay empty: `[].[widgets]` is not valid SQL, which is the
176
+ * whole reason `@@schema("")` exists.
177
+ */
178
+ /**
179
+ * An identifier for a generated artifact name: letters, digits and underscores only.
180
+ *
181
+ * The name goes into DDL as an identifier, so anything else is replaced rather than
182
+ * quoted — `@@map("catalog entries")` must not produce a name the provider rejects.
183
+ */
184
+ function safeIdentifierName(raw) {
185
+ return raw.replace(/[^A-Za-z0-9_]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
186
+ }
187
+ function qualifiedTableName(model) {
188
+ return model.schema ? `${model.schema}.${model.tableName}` : model.tableName;
189
+ }
@@ -2,12 +2,30 @@ import { Model } from './types';
2
2
  export declare class PythonGenerator {
3
3
  private outputPath;
4
4
  constructor(outputPath: string);
5
+ /**
6
+ * The metadata module name, taken from the configured file name itself.
7
+ *
8
+ * The imports used to hardcode `an5_metadata` while `outputPath` was
9
+ * configurable, so setting `python.metadataFile` to another name (say
10
+ * `an5Metadata.py`) still generated `from an5_metadata import ...` and the
11
+ * client broke on import — a failure that only showed up at runtime.
12
+ */
13
+ private metadataModule;
14
+ /** The database being generated for; decides types like `TIMESTAMP`. */
15
+ private provider;
5
16
  generate(models: Model[]): void;
6
17
  private getPyFilterType;
7
18
  private generateOrmTypes;
8
19
  private generateMetadata;
20
+ /**
21
+ * The Python type for a field.
22
+ *
23
+ * From the declared type, so `DECIMAL` is a `float` and `BIGINT` stays an `int`:
24
+ * both arrived as `number` before and took the integer branch.
25
+ */
9
26
  private mapPyType;
10
- private generateModels;
27
+ private generateModelFile;
28
+ private generateModelsIndex;
11
29
  private generateClient;
12
30
  private generateInit;
13
31
  private getAllPropertyVariations;
@@ -6,37 +6,53 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.PythonGenerator = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ const types_1 = require("./types");
10
+ const type_kinds_1 = require("./type-kinds");
9
11
  class PythonGenerator {
10
12
  constructor(outputPath) {
11
13
  this.outputPath = outputPath;
12
14
  }
15
+ /**
16
+ * The metadata module name, taken from the configured file name itself.
17
+ *
18
+ * The imports used to hardcode `an5_metadata` while `outputPath` was
19
+ * configurable, so setting `python.metadataFile` to another name (say
20
+ * `an5Metadata.py`) still generated `from an5_metadata import ...` and the
21
+ * client broke on import — a failure that only showed up at runtime.
22
+ */
23
+ metadataModule() {
24
+ return path_1.default.basename(this.outputPath).replace(/\.py$/i, '');
25
+ }
13
26
  generate(models) {
27
+ this.provider = models[0]?.provider;
14
28
  const outputDir = path_1.default.dirname(this.outputPath);
15
29
  if (!fs_1.default.existsSync(outputDir)) {
16
30
  fs_1.default.mkdirSync(outputDir, { recursive: true });
17
31
  }
18
32
  // 1. Generate an5_metadata.py
19
33
  this.generateMetadata(models);
20
- // 2. Generate an5_models.py
21
- this.generateModels(models, outputDir);
22
- // 3. Generate an5_orm_types.py - type-safe ORM filter/args dataclasses
34
+ // 2. Generate per-model entity files like dotnet (<Model>.py)
35
+ for (const model of models) {
36
+ this.generateModelFile(model, outputDir);
37
+ }
38
+ // 3. Generate an5_models.py as backward-compat aggregator
39
+ this.generateModelsIndex(models, outputDir);
40
+ // 4. Generate an5_orm_types.py - type-safe ORM filter/args dataclasses
23
41
  this.generateOrmTypes(models, outputDir);
24
- // 4. Generate an5_client.py
42
+ // 5. Generate an5_client.py
25
43
  this.generateClient(models, outputDir);
26
- // 5. Generate __init__.py
44
+ // 6. Generate __init__.py
27
45
  this.generateInit(models, outputDir);
28
46
  }
29
- getPyFilterType(fieldType) {
30
- const lower = fieldType.toLowerCase();
31
- if (['datetime', 'datetime2', 'date', 'smalldatetime', 'datetimeoffset', 'timestamp'].includes(lower))
32
- return 'DateTimeFilter';
33
- if (['bool', 'boolean', 'bit'].includes(lower))
34
- return 'BoolFilter';
35
- if (['int', 'integer', 'smallint', 'tinyint', 'bigint', 'number'].includes(lower))
36
- return 'IntFilter';
37
- if (['float', 'real', 'double', 'decimal', 'numeric', 'money'].includes(lower))
38
- return 'NumberFilter';
39
- return 'StringFilter';
47
+ getPyFilterType(field) {
48
+ switch ((0, type_kinds_1.fieldKind)(field, this.provider)) {
49
+ case 'date': return 'DateTimeFilter';
50
+ case 'bool': return 'BoolFilter';
51
+ case 'int':
52
+ case 'bigint': return 'IntFilter';
53
+ case 'float': return 'NumberFilter';
54
+ default: return 'StringFilter';
55
+ }
40
56
  }
41
57
  generateOrmTypes(models, outputDir) {
42
58
  let content = '# This file is auto-generated. Do not edit directly.\n';
@@ -62,7 +78,7 @@ class PythonGenerator {
62
78
  content += ` OR: Optional[List['${name}WhereInput']] = None\n`;
63
79
  content += ` NOT: Optional['${name}WhereInput'] = None\n`;
64
80
  for (const f of model.fields) {
65
- const ft = this.getPyFilterType(f.type);
81
+ const ft = this.getPyFilterType(f);
66
82
  content += ` ${this.toSnakeCase(f.name)}: Optional[${ft}] = None\n`;
67
83
  }
68
84
  content += '\n';
@@ -77,10 +93,10 @@ class PythonGenerator {
77
93
  const optionalFields = model.fields.filter(f => !f.isId && (f.isOptional || f.hasDefault));
78
94
  content += `@dataclass\nclass ${name}CreateInput:\n """Typed data for creating a new ${name} record."""\n`;
79
95
  for (const f of requiredFields) {
80
- content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f.type)}\n`;
96
+ content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f)}\n`;
81
97
  }
82
98
  for (const f of optionalFields) {
83
- content += ` ${this.toSnakeCase(f.name)}: Optional[${this.mapPyType(f.type)}] = None\n`;
99
+ content += ` ${this.toSnakeCase(f.name)}: Optional[${this.mapPyType(f)}] = None\n`;
84
100
  }
85
101
  if (requiredFields.length === 0 && optionalFields.length === 0)
86
102
  content += ' pass\n';
@@ -89,7 +105,7 @@ class PythonGenerator {
89
105
  const updateFields = model.fields.filter(f => !f.isId);
90
106
  content += `@dataclass\nclass ${name}UpdateInput:\n """Typed data for updating an existing ${name} record."""\n`;
91
107
  for (const f of updateFields) {
92
- content += ` ${this.toSnakeCase(f.name)}: Optional[${this.mapPyType(f.type)}] = None\n`;
108
+ content += ` ${this.toSnakeCase(f.name)}: Optional[${this.mapPyType(f)}] = None\n`;
93
109
  }
94
110
  if (updateFields.length === 0)
95
111
  content += ' pass\n';
@@ -115,7 +131,7 @@ class PythonGenerator {
115
131
  pyContent += 'MODEL_TO_TABLE = {\n';
116
132
  for (const model of models) {
117
133
  const props = this.getAllPropertyVariations(model.name);
118
- const fullTableName = `[${model.schemaName}].[${model.tableName}]`;
134
+ const fullTableName = (0, types_1.bracketedTableName)(model);
119
135
  for (const prop of props) {
120
136
  pyContent += ` "${prop}": "${fullTableName}",\n`;
121
137
  }
@@ -161,49 +177,50 @@ class PythonGenerator {
161
177
  pyContent += '}\n';
162
178
  fs_1.default.writeFileSync(this.outputPath, pyContent);
163
179
  }
164
- mapPyType(fieldType) {
165
- const lower = fieldType.toLowerCase();
166
- if (['int', 'integer', 'smallint', 'tinyint', 'bigint', 'number'].includes(lower)) {
167
- return 'int';
168
- }
169
- if (['float', 'real', 'double', 'decimal', 'numeric', 'money'].includes(lower)) {
170
- return 'float';
180
+ /**
181
+ * The Python type for a field.
182
+ *
183
+ * From the declared type, so `DECIMAL` is a `float` and `BIGINT` stays an `int`:
184
+ * both arrived as `number` before and took the integer branch.
185
+ */
186
+ mapPyType(field) {
187
+ switch ((0, type_kinds_1.fieldKind)(field, this.provider)) {
188
+ case 'int':
189
+ case 'bigint': return 'int';
190
+ case 'float': return 'float';
191
+ case 'bool': return 'bool';
192
+ case 'date': return 'datetime';
193
+ case 'bytes': return 'bytes';
194
+ case 'json': return 'Any';
195
+ case 'vector': return 'List[float]';
196
+ default: return 'str';
171
197
  }
172
- if (['bool', 'boolean', 'bit'].includes(lower)) {
173
- return 'bool';
174
- }
175
- if (['datetime', 'datetime2', 'date', 'smalldatetime', 'datetimeoffset', 'timestamp'].includes(lower)) {
176
- return 'datetime';
177
- }
178
- if (['bytes', 'binary', 'varbinary'].includes(lower)) {
179
- return 'bytes';
180
- }
181
- return 'str';
182
198
  }
183
- generateModels(models, outputDir) {
199
+ generateModelFile(model, outputDir) {
200
+ // Single-entity file like dotnet `${model.name}.cs` / typescript `${model.name}.ts`.
201
+ // Contains the @dataclass entity + TypedDict row shapes for that model only.
184
202
  let content = '# This file is auto-generated. Do not edit directly.\n';
185
203
  content += 'from dataclasses import dataclass, field\n';
186
- content += 'from typing import Optional, List, Any\n';
204
+ content += 'from typing import Optional, List, Any, TypedDict\n';
187
205
  content += 'from datetime import datetime\n\n';
188
- for (const model of models) {
189
- if (model.description) {
190
- content += `"""${model.description}"""\n`;
191
- }
192
- content += `@dataclass\nclass ${model.name}:\n`;
193
- if (model.fields.length === 0 && model.relations.length === 0) {
194
- content += ' pass\n\n';
195
- continue;
196
- }
206
+ if (model.description) {
207
+ content += `"""${model.description}"""\n`;
208
+ }
209
+ content += `@dataclass\nclass ${model.name}:\n`;
210
+ if (model.fields.length === 0 && model.relations.length === 0) {
211
+ content += ' pass\n\n';
212
+ }
213
+ else {
197
214
  // Required fields first
198
215
  const required = model.fields.filter(f => !f.isOptional && !f.hasDefault);
199
216
  const optionals = model.fields.filter(f => f.isOptional || f.hasDefault);
200
217
  for (const f of required) {
201
- const pyType = this.mapPyType(f.type);
218
+ const pyType = this.mapPyType(f);
202
219
  const snakeName = this.toSnakeCase(f.name);
203
220
  content += ` ${snakeName}: ${pyType}\n`;
204
221
  }
205
222
  for (const f of optionals) {
206
- const pyType = this.mapPyType(f.type);
223
+ const pyType = this.mapPyType(f);
207
224
  const snakeName = this.toSnakeCase(f.name);
208
225
  content += ` ${snakeName}: Optional[${pyType}] = None\n`;
209
226
  }
@@ -218,21 +235,87 @@ class PythonGenerator {
218
235
  }
219
236
  }
220
237
  content += '\n';
238
+ // TypedDict row shape: mirrors what the runtime actually returns
239
+ // (plain dicts), so `AdapterTableClient["<Model>Row"]` delegates
240
+ // type-check. Required keys stay required via a total=True base;
241
+ // everything else is optional (total=False), keeping partial
242
+ // selects valid without extra dependencies.
243
+ const rowRequired = model.fields.filter(f => !f.isOptional && !f.hasDefault);
244
+ const rowOptional = model.fields.filter(f => f.isOptional || f.hasDefault);
245
+ content += `class _${model.name}Required(TypedDict):\n`;
246
+ content += ` """Required keys of a ${model.name} row."""\n`;
247
+ if (rowRequired.length === 0) {
248
+ content += ' pass\n';
249
+ }
250
+ for (const f of rowRequired) {
251
+ content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f)}\n`;
252
+ }
253
+ content += `\nclass ${model.name}Row(_${model.name}Required, total=False):\n`;
254
+ content += ` """Row shape returned for ${model.name} queries."""\n`;
255
+ if (rowOptional.length === 0 && model.relations.length === 0) {
256
+ content += ' pass\n';
257
+ }
258
+ for (const f of rowOptional) {
259
+ content += ` ${this.toSnakeCase(f.name)}: ${this.mapPyType(f)}\n`;
260
+ }
261
+ for (const rel of model.relations) {
262
+ const relName = this.toSnakeCase(rel.name);
263
+ content += rel.isArray ? ` ${relName}: List[Any]\n` : ` ${relName}: Any\n`;
264
+ }
265
+ content += '\n';
266
+ }
267
+ fs_1.default.writeFileSync(path_1.default.join(outputDir, `${model.name}.py`), content);
268
+ }
269
+ generateModelsIndex(models, outputDir) {
270
+ // Backward-compat aggregator: `from an5_models import User` keeps working.
271
+ // New code can also import directly: `from .User import User, UserRow`.
272
+ // Supports both package-relative (`python -m`) and top-level (`sys.path`
273
+ // pointing at the output dir) imports via try/except fallback.
274
+ let content = '# This file is auto-generated. Do not edit directly.\n';
275
+ content += '"""Backward-compat aggregator re-exporting per-model entity files."""\n';
276
+ for (const model of models) {
277
+ content += `try:\n`;
278
+ content += ` from .${model.name} import ${model.name}, ${model.name}Row, _${model.name}Required\n`;
279
+ content += `except ImportError:\n`;
280
+ content += ` from ${model.name} import ${model.name}, ${model.name}Row, _${model.name}Required\n`;
281
+ }
282
+ if (models.length > 0) {
283
+ content += '\n__all__ = [\n';
284
+ for (const model of models) {
285
+ content += ` "${model.name}", "${model.name}Row", "_${model.name}Required",\n`;
286
+ }
287
+ content += ']\n';
288
+ }
289
+ else {
290
+ content += '\n__all__ = []\n';
221
291
  }
222
292
  fs_1.default.writeFileSync(path_1.default.join(outputDir, 'an5_models.py'), content);
223
293
  }
224
294
  generateClient(models, outputDir) {
225
295
  let content = '# This file is auto-generated. Do not edit directly.\n';
226
296
  content += 'import os\n';
227
- content += 'from typing import Dict, List, Optional, Any, Callable\n\n';
228
- content += 'try:\n';
229
- content += ' from an5_adapter import An5Adapter, AdapterTableClient, create_an5_adapter, set_adapter_metadata\n';
230
- content += 'except ImportError:\n';
231
- content += ' from .an5_adapter import An5Adapter, AdapterTableClient, create_an5_adapter, set_adapter_metadata\n\n';
297
+ content += 'from typing import Dict, List, Optional, Any, Callable, TYPE_CHECKING\n\n';
298
+ // Runtime adapter comes from the `an5-adapters` PyPI package
299
+ // (module `an5_adapter`). There is no local adapter file in the
300
+ // generated output dir, so no relative-import fallback here — a
301
+ // relative fallback would be unresolvable and trip Pylance
302
+ // `reportMissingImports` on every generated client.
303
+ content += 'from an5_adapter import An5Adapter, AdapterTableClient, create_an5_adapter, set_adapter_metadata\n\n';
304
+ const metadataModule = this.metadataModule();
232
305
  content += 'try:\n';
233
- content += ' from .an5_metadata import MODEL_TO_TABLE, MODEL_FIELDS\n';
306
+ content += ` from .${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS\n`;
234
307
  content += 'except ImportError:\n';
235
- content += ' from an5_metadata import MODEL_TO_TABLE, MODEL_FIELDS\n\n';
308
+ content += ` from ${metadataModule} import MODEL_TO_TABLE, MODEL_FIELDS\n\n`;
309
+ if (models.length > 0) {
310
+ content += 'if TYPE_CHECKING:\n';
311
+ for (const m of models) {
312
+ content += ' try:\n';
313
+ content += ` from .${m.name} import ${m.name}Row\n`;
314
+ content += ' except ImportError:\n';
315
+ content += ` from .an5_models import ${m.name}Row # fallback aggregator\n`;
316
+ }
317
+ content += '\n';
318
+ }
236
319
  content += 'class An5Client:\n';
237
320
  content += ' """AN5 Python ORM Client - type-safe database access.\n\n';
238
321
  content += ' Usage:\n';
@@ -251,13 +334,13 @@ class PythonGenerator {
251
334
  const propName = this.toSnakeCase(model.name) + 's';
252
335
  const singleName = this.toSnakeCase(model.name);
253
336
  content += ` client = AdapterTableClient(self.adapter, "${model.name}")\n`;
254
- content += ` self.${model.name}: AdapterTableClient = client\n`;
255
- content += ` self.${model.name}s: AdapterTableClient = client\n`;
256
- content += ` self.${singleName}: AdapterTableClient = client\n`;
257
- content += ` self.${propName}: AdapterTableClient = client\n`;
337
+ content += ` self.${model.name}: AdapterTableClient["${model.name}Row"] = client\n`;
338
+ content += ` self.${model.name}s: AdapterTableClient["${model.name}Row"] = client\n`;
339
+ content += ` self.${singleName}: AdapterTableClient["${model.name}Row"] = client\n`;
340
+ content += ` self.${propName}: AdapterTableClient["${model.name}Row"] = client\n`;
258
341
  }
259
342
  content += '\n';
260
- content += ' def __getattr__(self, name: str) -> AdapterTableClient:\n';
343
+ content += ' def __getattr__(self, name: str) -> AdapterTableClient[Any]:\n';
261
344
  content += ' return self.adapter.table(name)\n\n';
262
345
  content += ' def query_raw(self, sql: str, *params) -> List[Dict]:\n';
263
346
  content += ' return self.adapter.query_raw(sql, *params)\n\n';
@@ -269,7 +352,7 @@ class PythonGenerator {
269
352
  }
270
353
  generateInit(models, outputDir) {
271
354
  let content = '# This file is auto-generated. Do not edit directly.\n';
272
- content += 'from .an5_metadata import MODEL_TO_TABLE, MODEL_DESCRIPTIONS, MODEL_FIELDS, RELATION_MAP\n';
355
+ content += `from .${this.metadataModule()} import MODEL_TO_TABLE, MODEL_DESCRIPTIONS, MODEL_FIELDS, RELATION_MAP\n`;
273
356
  content += 'from .an5_models import *\n';
274
357
  content += 'from .an5_orm_types import (\n';
275
358
  content += ' StringFilter, IntFilter, NumberFilter, BoolFilter, DateTimeFilter,\n';
@@ -0,0 +1,38 @@
1
+ import { Model } from './types';
2
+ export declare class RustGenerator {
3
+ private outputDir;
4
+ /** The database being generated for; decides types like `TIMESTAMP`. */
5
+ private provider;
6
+ constructor(outputDir: string);
7
+ generate(models: Model[]): void;
8
+ private generateCargoToml;
9
+ private generateLibRs;
10
+ private generateFiltersRs;
11
+ private generateModelsRs;
12
+ private buildModelsBody;
13
+ private generateMetadataRs;
14
+ private generateConfigRs;
15
+ private generateClientRs;
16
+ /**
17
+ * Typed, executing accessors for a model.
18
+ *
19
+ * Rust has no dynamic property access, so `db.user` cannot exist the way it
20
+ * does in TypeScript or Python. Following the Go generator (which emits a
21
+ * real `ctx.User` field), each model gets a generated method returning a
22
+ * typed handle whose queries run through the an5-adapters runtime.
23
+ */
24
+ private buildModelRuntimeHandle;
25
+ private isBoolField;
26
+ /**
27
+ * The Rust type for a field.
28
+ *
29
+ * From the declared type, so `DECIMAL` is an `f64` and `BIGINT` an `i64`: both
30
+ * arrived as `number` and were written as `i64`.
31
+ */
32
+ private mapRustType;
33
+ private getRustFilterType;
34
+ private getAllPropertyVariations;
35
+ private capitalize;
36
+ private toCamelCase;
37
+ private toSnakeCase;
38
+ }