@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.
- package/README.md +10 -3
- package/dist/config.d.ts +9 -1
- package/dist/config.js +13 -18
- package/dist/generator/src/api.d.ts +35 -0
- package/dist/generator/src/api.js +58 -0
- package/dist/generator/src/config.d.ts +162 -0
- package/dist/generator/src/config.js +354 -0
- package/dist/generator/src/dialect.d.ts +88 -0
- package/dist/generator/src/dialect.js +318 -0
- package/dist/generator/src/dotnet-generator.d.ts +8 -0
- package/dist/generator/src/dotnet-generator.js +282 -101
- package/dist/generator/src/field-types.d.ts +96 -0
- package/dist/generator/src/field-types.js +433 -0
- package/dist/generator/src/golang-generator.d.ts +36 -0
- package/dist/generator/src/golang-generator.js +1230 -0
- package/dist/generator/src/index.js +69 -36
- package/dist/generator/src/metadata-generator.js +2 -1
- package/dist/generator/src/parser.d.ts +28 -2
- package/dist/generator/src/parser.js +92 -90
- package/dist/generator/src/push-apply.d.ts +37 -0
- package/dist/generator/src/push-apply.js +109 -0
- package/dist/generator/src/push-schema.d.ts +93 -0
- package/dist/generator/src/push-schema.js +189 -0
- package/dist/generator/src/python-generator.d.ts +19 -1
- package/dist/generator/src/python-generator.js +147 -64
- package/dist/generator/src/rust-generator.d.ts +38 -0
- package/dist/generator/src/rust-generator.js +971 -0
- package/dist/generator/src/suggest.d.ts +17 -0
- package/dist/generator/src/suggest.js +45 -0
- package/dist/generator/src/type-kinds.d.ts +27 -0
- package/dist/generator/src/type-kinds.js +72 -0
- package/dist/generator/src/types.d.ts +33 -0
- package/dist/generator/src/types.js +39 -0
- package/dist/migration-core.d.ts +16 -1
- package/dist/migration-core.js +60 -21
- package/dist/orm.d.ts +147 -0
- package/dist/orm.js +1654 -0
- package/dist/provider-support.d.ts +27 -0
- package/dist/provider-support.js +50 -0
- package/dist/sql-utils.js +8 -3
- package/generator/src/api.ts +45 -0
- package/generator/src/config.ts +496 -0
- package/generator/src/dialect.ts +415 -0
- package/generator/src/dotnet-generator.ts +277 -88
- package/generator/src/field-types.ts +473 -0
- package/generator/src/golang-generator.ts +94 -68
- package/generator/src/index.ts +71 -36
- package/generator/src/metadata-generator.ts +2 -2
- package/generator/src/parser.ts +122 -90
- package/generator/src/push-apply.ts +148 -0
- package/generator/src/push-schema.ts +250 -0
- package/generator/src/python-generator.ts +118 -62
- package/generator/src/rust-generator.ts +974 -0
- package/generator/src/suggest.ts +41 -0
- package/generator/src/type-kinds.ts +78 -0
- package/generator/src/types.ts +48 -0
- package/package.json +26 -6
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Provider } from './generator/src/field-types';
|
|
2
|
+
/**
|
|
3
|
+
* Which commands can only run against SQL Server.
|
|
4
|
+
*
|
|
5
|
+
* `db:push` is not among them: it goes through the dialect layer in
|
|
6
|
+
* `./generator/src/dialect`, so it writes the DDL each provider understands. These
|
|
7
|
+
* three do not — they read `sys.tables`/`sys.columns`/`sys.foreign_keys`, quote
|
|
8
|
+
* identifiers with brackets, and emit T-SQL. Running them against another database
|
|
9
|
+
* fails on the first catalog query with a driver error that says nothing about the
|
|
10
|
+
* real problem, or worse, half-applies a change before it does. So the provider is
|
|
11
|
+
* checked up front, where the message can name it.
|
|
12
|
+
*
|
|
13
|
+
* Not a reason to narrow the type tables though: the tables describe what each
|
|
14
|
+
* provider accepts, which is what the generated client and the schema files must
|
|
15
|
+
* stay within, whoever runs the commands.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SQL_SERVER_ONLY_COMMANDS: Record<string, string>;
|
|
18
|
+
/**
|
|
19
|
+
* Why a command cannot run against this provider, or null when it can.
|
|
20
|
+
*
|
|
21
|
+
* Returns the message instead of printing it and exiting, so this stays a plain
|
|
22
|
+
* function: it is shared by four command files, and a module that calls
|
|
23
|
+
* `process.exit` cannot be imported outside a Node CLI — nor unit tested for the
|
|
24
|
+
* thing it exists to say.
|
|
25
|
+
*/
|
|
26
|
+
export declare function sqlServerOnlyError(provider: Provider, command: string): string | null;
|
|
27
|
+
//# sourceMappingURL=provider-support.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SQL_SERVER_ONLY_COMMANDS = void 0;
|
|
4
|
+
exports.sqlServerOnlyError = sqlServerOnlyError;
|
|
5
|
+
const field_types_1 = require("./generator/src/field-types");
|
|
6
|
+
/**
|
|
7
|
+
* Which commands can only run against SQL Server.
|
|
8
|
+
*
|
|
9
|
+
* `db:push` is not among them: it goes through the dialect layer in
|
|
10
|
+
* `./generator/src/dialect`, so it writes the DDL each provider understands. These
|
|
11
|
+
* three do not — they read `sys.tables`/`sys.columns`/`sys.foreign_keys`, quote
|
|
12
|
+
* identifiers with brackets, and emit T-SQL. Running them against another database
|
|
13
|
+
* fails on the first catalog query with a driver error that says nothing about the
|
|
14
|
+
* real problem, or worse, half-applies a change before it does. So the provider is
|
|
15
|
+
* checked up front, where the message can name it.
|
|
16
|
+
*
|
|
17
|
+
* Not a reason to narrow the type tables though: the tables describe what each
|
|
18
|
+
* provider accepts, which is what the generated client and the schema files must
|
|
19
|
+
* stay within, whoever runs the commands.
|
|
20
|
+
*/
|
|
21
|
+
exports.SQL_SERVER_ONLY_COMMANDS = {
|
|
22
|
+
'db:pull': 'read the catalog and write schema files',
|
|
23
|
+
'db:migrate': 'diff the schema against the database and apply migrations',
|
|
24
|
+
'db:migrate:diff': 'diff the schema against the database',
|
|
25
|
+
'db:migrate:generate': 'write a migration file from the schema files',
|
|
26
|
+
'db:migrate:apply': 'apply pending migrations',
|
|
27
|
+
'db:migrate:rollback': 'roll a migration back',
|
|
28
|
+
'db:migrate:status': 'list schema models, database tables and migration state',
|
|
29
|
+
'db:cleanup': 'drop tables and foreign keys that are no longer in the schema',
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Why a command cannot run against this provider, or null when it can.
|
|
33
|
+
*
|
|
34
|
+
* Returns the message instead of printing it and exiting, so this stays a plain
|
|
35
|
+
* function: it is shared by four command files, and a module that calls
|
|
36
|
+
* `process.exit` cannot be imported outside a Node CLI — nor unit tested for the
|
|
37
|
+
* thing it exists to say.
|
|
38
|
+
*/
|
|
39
|
+
function sqlServerOnlyError(provider, command) {
|
|
40
|
+
if (provider === 'mssql')
|
|
41
|
+
return null;
|
|
42
|
+
const what = exports.SQL_SERVER_ONLY_COMMANDS[command] ?? 'run';
|
|
43
|
+
return (`❌ ${command} only supports SQL Server, but the connection string selects ` +
|
|
44
|
+
`${field_types_1.PROVIDER_LABELS[provider]}.\n` +
|
|
45
|
+
` ${command} ${what} using sys.* catalog views and T-SQL, so pointing it at ` +
|
|
46
|
+
`${field_types_1.PROVIDER_LABELS[provider]} would run the wrong SQL.\n` +
|
|
47
|
+
' Use a SQL Server connection string, or generate and apply the schema with ' +
|
|
48
|
+
`${field_types_1.PROVIDER_LABELS[provider]}'s own tooling.`);
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=provider-support.js.map
|
package/dist/sql-utils.js
CHANGED
|
@@ -119,12 +119,17 @@ function parseWhere(modelName, where, params, prefix = "", ctx = {}) {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
for (const [key, value] of Object.entries(cleanWhere)) {
|
|
122
|
+
if (value === undefined)
|
|
123
|
+
continue;
|
|
122
124
|
if (key === "OR" && Array.isArray(value)) {
|
|
123
|
-
const orConditions = value.map((subWhere, idx) => parseWhere(modelName, subWhere, params, `${prefix}or_${idx}_`, ctx));
|
|
125
|
+
const orConditions = value.map((subWhere, idx) => parseWhere(modelName, subWhere, params, `${prefix}or_${idx}_`, ctx) || "1=1");
|
|
124
126
|
const filtered = orConditions.filter(Boolean);
|
|
125
127
|
if (filtered.length > 0) {
|
|
126
128
|
conditions.push(`(${filtered.join(" OR ")})`);
|
|
127
129
|
}
|
|
130
|
+
else {
|
|
131
|
+
conditions.push("1=0");
|
|
132
|
+
}
|
|
128
133
|
}
|
|
129
134
|
else if (key === "AND") {
|
|
130
135
|
const andItems = Array.isArray(value) ? value : [value];
|
|
@@ -136,10 +141,10 @@ function parseWhere(modelName, where, params, prefix = "", ctx = {}) {
|
|
|
136
141
|
}
|
|
137
142
|
else if (key === "NOT") {
|
|
138
143
|
const notItems = Array.isArray(value) ? value : [value];
|
|
139
|
-
const notConditions = notItems.map((subWhere, idx) => parseWhere(modelName, subWhere, params, `${prefix}not_${idx}_`, ctx));
|
|
144
|
+
const notConditions = notItems.map((subWhere, idx) => parseWhere(modelName, subWhere, params, `${prefix}not_${idx}_`, ctx) || "1=1");
|
|
140
145
|
const filtered = notConditions.filter(Boolean);
|
|
141
146
|
if (filtered.length > 0) {
|
|
142
|
-
conditions.push(`NOT (${filtered.join("
|
|
147
|
+
conditions.push(`NOT (${filtered.join(" OR ")})`);
|
|
143
148
|
}
|
|
144
149
|
}
|
|
145
150
|
else {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-effect-free entrypoint for programmatic use of the code generator.
|
|
3
|
+
*
|
|
4
|
+
* Importing `./index` runs the CLI (`main()`), so tooling that wants to drive
|
|
5
|
+
* the generator in-process — such as the an5Agent `generateClientCode` tool —
|
|
6
|
+
* imports this module instead.
|
|
7
|
+
*
|
|
8
|
+
* The provider is optional and defaults to SQL Server. Pass one derived from
|
|
9
|
+
* the connection string when the schema targets another database, so field
|
|
10
|
+
* types are checked against that database's types — `providerForProject` reads
|
|
11
|
+
* it from the config file for callers that only know the project directory:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { SchemaParser, RustGenerator, providerForProject } from '@an5/orm/generator';
|
|
15
|
+
*
|
|
16
|
+
* const provider = providerForProject(__dirname);
|
|
17
|
+
* const models = await new SchemaParser('an5Schema', provider).parse();
|
|
18
|
+
* new RustGenerator('an5Client/rust').generate(models);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export { SchemaParser, sqlTypeToTs } from './parser';
|
|
22
|
+
export { CodeGenerator } from './code-generator';
|
|
23
|
+
export { MetadataGenerator } from './metadata-generator';
|
|
24
|
+
export { PythonGenerator } from './python-generator';
|
|
25
|
+
export { DotnetGenerator } from './dotnet-generator';
|
|
26
|
+
export { GolangGenerator } from './golang-generator';
|
|
27
|
+
export { RustGenerator } from './rust-generator';
|
|
28
|
+
export { detectProvider, providerFromConfig, providerForProject } from './config';
|
|
29
|
+
export { dialectFor, defaultClause, sqlLiteral } from './dialect';
|
|
30
|
+
export { fieldKind } from './type-kinds';
|
|
31
|
+
export type { FieldKind } from './type-kinds';
|
|
32
|
+
export type { ColumnSpec, PushDialect } from './dialect';
|
|
33
|
+
export {
|
|
34
|
+
DEFAULT_PROVIDER,
|
|
35
|
+
FieldTypeError,
|
|
36
|
+
PROVIDERS,
|
|
37
|
+
PROVIDER_FIELD_TYPES,
|
|
38
|
+
PROVIDER_LABELS,
|
|
39
|
+
defaultSqlTypeForTs,
|
|
40
|
+
fieldTypesFor,
|
|
41
|
+
resolveFieldType,
|
|
42
|
+
unknownFieldTypeMessage,
|
|
43
|
+
} from './field-types';
|
|
44
|
+
export type { FieldTypeIssue, Provider, TsType } from './field-types';
|
|
45
|
+
export type { Model, Field, Relation } from './types';
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading and validation for an5Orm.config.js
|
|
3
|
+
*
|
|
4
|
+
* The config used to be read as `any` and every field fell back through
|
|
5
|
+
* `config.outputs?.typescript?.outputDir || 'default'`. That made a typo
|
|
6
|
+
* invisible: `outputDirs` was not an error, it just quietly wrote to the
|
|
7
|
+
* default directory while the config file said otherwise.
|
|
8
|
+
*
|
|
9
|
+
* So the file is described once, here, and validated before anything uses it.
|
|
10
|
+
* A wrong type or an unknown key stops generation with a message naming the
|
|
11
|
+
* offending path, rather than being discovered later as output in the wrong
|
|
12
|
+
* place.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { DEFAULT_PROVIDER, Provider } from './field-types';
|
|
17
|
+
import { suggest } from './suggest';
|
|
18
|
+
|
|
19
|
+
/** TypeScript client output. */
|
|
20
|
+
export interface TypeScriptOutput {
|
|
21
|
+
/** Directory for the generated .ts files. */
|
|
22
|
+
outputDir: string;
|
|
23
|
+
/** Path of the generated metadata module. */
|
|
24
|
+
metadataFile: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PythonOutput {
|
|
28
|
+
/** Path of the generated metadata module. */
|
|
29
|
+
metadataFile: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DotnetOutput {
|
|
33
|
+
outputDir: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GolangOutput {
|
|
37
|
+
outputDir: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RustOutput {
|
|
41
|
+
outputDir: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface An5OrmConfig {
|
|
45
|
+
/**
|
|
46
|
+
* Database connection for the commands that need one — db:push, db:pull,
|
|
47
|
+
* db:migrate:*, db:cleanup.
|
|
48
|
+
*
|
|
49
|
+
* Optional on purpose: DATABASE_URL overrides it, so a checked-in config can
|
|
50
|
+
* hold a development database while CI supplies its own. Leave it unset
|
|
51
|
+
* rather than committing a password, and keep secrets in the environment.
|
|
52
|
+
*
|
|
53
|
+
* `| undefined` is explicit because the project compiles with
|
|
54
|
+
* exactOptionalPropertyTypes, which otherwise rejects an omitted value.
|
|
55
|
+
*/
|
|
56
|
+
connectionString?: string | undefined;
|
|
57
|
+
/** Schema directory, relative to the config file. */
|
|
58
|
+
schemaDir: string;
|
|
59
|
+
outputs: {
|
|
60
|
+
typescript: TypeScriptOutput;
|
|
61
|
+
python: PythonOutput;
|
|
62
|
+
dotnet: DotnetOutput;
|
|
63
|
+
golang: GolangOutput;
|
|
64
|
+
rust: RustOutput;
|
|
65
|
+
};
|
|
66
|
+
pull: {
|
|
67
|
+
/** Regex patterns; tables matching any of them are skipped by db:pull. */
|
|
68
|
+
exclude: string[];
|
|
69
|
+
/** Keep relations that already exist in the schema. */
|
|
70
|
+
preserveRelations: boolean;
|
|
71
|
+
};
|
|
72
|
+
generation: {
|
|
73
|
+
/** Write the metadata module alongside the client. */
|
|
74
|
+
generateMetadata: boolean;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const DEFAULT_CONFIG: An5OrmConfig = {
|
|
79
|
+
schemaDir: 'an5Schema',
|
|
80
|
+
outputs: {
|
|
81
|
+
typescript: {
|
|
82
|
+
outputDir: 'an5Client/typescript',
|
|
83
|
+
metadataFile: 'an5Client/typescript/an5Metadata.ts',
|
|
84
|
+
},
|
|
85
|
+
python: { metadataFile: 'an5Client/python/an5_metadata.py' },
|
|
86
|
+
dotnet: { outputDir: 'an5Client/dotnet' },
|
|
87
|
+
golang: { outputDir: 'an5Client/golang' },
|
|
88
|
+
rust: { outputDir: 'an5Client/rust' },
|
|
89
|
+
},
|
|
90
|
+
pull: {
|
|
91
|
+
exclude: ['^__', '^sys\\.', '^igrations'],
|
|
92
|
+
preserveRelations: true,
|
|
93
|
+
},
|
|
94
|
+
generation: {
|
|
95
|
+
generateMetadata: true,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Output paths resolved against the config file's directory. */
|
|
100
|
+
export interface ResolvedOutputs {
|
|
101
|
+
schemaDir: string;
|
|
102
|
+
typescriptDir: string;
|
|
103
|
+
typescriptMetadataFile: string;
|
|
104
|
+
pythonMetadataFile: string;
|
|
105
|
+
dotnetDir: string;
|
|
106
|
+
golangDir: string;
|
|
107
|
+
rustDir: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function resolveOutputs(config: An5OrmConfig, rootDir: string): ResolvedOutputs {
|
|
111
|
+
const resolve = (relative: string) => path.resolve(rootDir, relative);
|
|
112
|
+
return {
|
|
113
|
+
schemaDir: resolve(config.schemaDir),
|
|
114
|
+
typescriptDir: resolve(config.outputs.typescript.outputDir),
|
|
115
|
+
typescriptMetadataFile: resolve(config.outputs.typescript.metadataFile),
|
|
116
|
+
pythonMetadataFile: resolve(config.outputs.python.metadataFile),
|
|
117
|
+
dotnetDir: resolve(config.outputs.dotnet.outputDir),
|
|
118
|
+
golangDir: resolve(config.outputs.golang.outputDir),
|
|
119
|
+
rustDir: resolve(config.outputs.rust.outputDir),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The environment variables a caller can pass in.
|
|
125
|
+
*
|
|
126
|
+
* Spelled structurally rather than as `NodeJS.ProcessEnv`: that type is a Node
|
|
127
|
+
* global, and a parameter type ends up in the emitted `.d.ts`, so any consumer
|
|
128
|
+
* without `@types/node` would fail to compile against this package. `process.env` is
|
|
129
|
+
* assignable to it, and so is a plain `{}` in a test.
|
|
130
|
+
*/
|
|
131
|
+
export type EnvLike = Record<string, string | undefined>;
|
|
132
|
+
|
|
133
|
+
/** One problem found in the config file. */
|
|
134
|
+
export interface ConfigIssue {
|
|
135
|
+
/** Dotted path, e.g. `outputs.typescript.outputDir`. */
|
|
136
|
+
path: string;
|
|
137
|
+
message: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class ConfigError extends Error {
|
|
141
|
+
readonly issues: ConfigIssue[];
|
|
142
|
+
|
|
143
|
+
constructor(issues: ConfigIssue[]) {
|
|
144
|
+
super('Invalid an5Orm.config.js');
|
|
145
|
+
this.issues = issues;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function typeName(value: unknown): string {
|
|
150
|
+
if (value === null) return 'null';
|
|
151
|
+
if (Array.isArray(value)) return 'an array';
|
|
152
|
+
return typeof value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Validates one level of an object: every key is known, every value has the
|
|
157
|
+
* expected primitive shape. Returns the accepted value.
|
|
158
|
+
*/
|
|
159
|
+
type FieldSpec = 'string' | 'string[]' | 'boolean' | 'object';
|
|
160
|
+
|
|
161
|
+
function checkObject(
|
|
162
|
+
raw: unknown,
|
|
163
|
+
spec: Record<string, FieldSpec>,
|
|
164
|
+
at: string,
|
|
165
|
+
issues: ConfigIssue[],
|
|
166
|
+
): Record<string, unknown> {
|
|
167
|
+
if (raw === undefined) return {};
|
|
168
|
+
if (typeName(raw) !== 'object' || Array.isArray(raw)) {
|
|
169
|
+
issues.push({ path: at, message: `expected an object, received ${typeName(raw)}` });
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const source = raw as Record<string, unknown>;
|
|
174
|
+
const accepted: Record<string, unknown> = {};
|
|
175
|
+
|
|
176
|
+
for (const key of Object.keys(source)) {
|
|
177
|
+
if (!(key in spec)) {
|
|
178
|
+
const hint = suggest(key, Object.keys(spec));
|
|
179
|
+
issues.push({
|
|
180
|
+
path: at ? `${at}.${key}` : key,
|
|
181
|
+
message: hint
|
|
182
|
+
? `unknown option; did you mean "${hint}"?`
|
|
183
|
+
: `unknown option; expected one of ${Object.keys(spec).map((k) => `"${k}"`).join(', ')}`,
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const expected = spec[key]!;
|
|
189
|
+
const value = source[key];
|
|
190
|
+
const here = at ? `${at}.${key}` : key;
|
|
191
|
+
|
|
192
|
+
if (expected === 'object') {
|
|
193
|
+
// Only the shape is checked here. A nested section is validated by the
|
|
194
|
+
// caller with its own key list, so this must not look at the keys — it
|
|
195
|
+
// has no spec for them and would reject all of them as unknown.
|
|
196
|
+
if (value !== undefined && (typeName(value) !== 'object' || Array.isArray(value))) {
|
|
197
|
+
issues.push({ path: here, message: `expected an object, received ${typeName(value)}` });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
accepted[key] = value;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (value === undefined) continue;
|
|
205
|
+
|
|
206
|
+
if (expected === 'string[]') {
|
|
207
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
208
|
+
issues.push({ path: here, message: `expected an array of strings, received ${typeName(value)}` });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
accepted[key] = value;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (typeof value !== expected) {
|
|
216
|
+
issues.push({
|
|
217
|
+
path: here,
|
|
218
|
+
message: `expected a${expected === 'string' ? ' string' : ` ${expected}`}, received ${typeName(value)}`,
|
|
219
|
+
});
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
accepted[key] = value;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return accepted;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const OUTPUT_SPEC: Record<string, FieldSpec> = {
|
|
230
|
+
typescript: 'object',
|
|
231
|
+
python: 'object',
|
|
232
|
+
dotnet: 'object',
|
|
233
|
+
golang: 'object',
|
|
234
|
+
rust: 'object',
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const OUTPUT_SECTION_SPEC: Record<string, Record<string, FieldSpec>> = {
|
|
238
|
+
typescript: { outputDir: 'string', metadataFile: 'string' },
|
|
239
|
+
python: { metadataFile: 'string' },
|
|
240
|
+
dotnet: { outputDir: 'string' },
|
|
241
|
+
golang: { outputDir: 'string' },
|
|
242
|
+
rust: { outputDir: 'string' },
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const TOP_LEVEL_SPEC: Record<string, FieldSpec> = {
|
|
246
|
+
connectionString: 'string',
|
|
247
|
+
schemaDir: 'string',
|
|
248
|
+
outputs: 'object',
|
|
249
|
+
pull: 'object',
|
|
250
|
+
generation: 'object',
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const PULL_SPEC: Record<string, FieldSpec> = {
|
|
254
|
+
exclude: 'string[]',
|
|
255
|
+
preserveRelations: 'boolean',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const GENERATION_SPEC: Record<string, FieldSpec> = {
|
|
259
|
+
generateMetadata: 'boolean',
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Checks a loaded config object and returns it with defaults applied.
|
|
264
|
+
* Throws ConfigError listing every problem found, not just the first.
|
|
265
|
+
*/
|
|
266
|
+
export function validateConfig(raw: unknown): An5OrmConfig {
|
|
267
|
+
const issues: ConfigIssue[] = [];
|
|
268
|
+
|
|
269
|
+
const top = checkObject(raw, TOP_LEVEL_SPEC, '', issues);
|
|
270
|
+
const rawOutputs = checkObject(top.outputs, OUTPUT_SPEC, 'outputs', issues);
|
|
271
|
+
// Each output section has its own allowed keys, so a typo in one is caught
|
|
272
|
+
// where it was written rather than as a missing value much later.
|
|
273
|
+
const outputs: Record<string, Record<string, unknown>> = {};
|
|
274
|
+
for (const section of Object.keys(OUTPUT_SECTION_SPEC)) {
|
|
275
|
+
outputs[section] = checkObject(
|
|
276
|
+
rawOutputs[section],
|
|
277
|
+
OUTPUT_SECTION_SPEC[section]!,
|
|
278
|
+
`outputs.${section}`,
|
|
279
|
+
issues,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const pull = checkObject(top.pull, PULL_SPEC, 'pull', issues);
|
|
283
|
+
const generation = checkObject(top.generation, GENERATION_SPEC, 'generation', issues);
|
|
284
|
+
|
|
285
|
+
if (issues.length > 0) throw new ConfigError(issues);
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
// Absent rather than defaulted: there is no sensible fallback connection.
|
|
289
|
+
connectionString: top.connectionString as string | undefined,
|
|
290
|
+
schemaDir: (top.schemaDir as string) ?? DEFAULT_CONFIG.schemaDir,
|
|
291
|
+
outputs: {
|
|
292
|
+
typescript: {
|
|
293
|
+
outputDir:
|
|
294
|
+
(outputs.typescript as Record<string, unknown> | undefined)?.outputDir as string ??
|
|
295
|
+
DEFAULT_CONFIG.outputs.typescript.outputDir,
|
|
296
|
+
metadataFile:
|
|
297
|
+
((outputs.typescript as Record<string, unknown> | undefined)?.metadataFile as string) ??
|
|
298
|
+
DEFAULT_CONFIG.outputs.typescript.metadataFile,
|
|
299
|
+
},
|
|
300
|
+
python: {
|
|
301
|
+
metadataFile:
|
|
302
|
+
((outputs.python as Record<string, unknown> | undefined)?.metadataFile as string) ??
|
|
303
|
+
DEFAULT_CONFIG.outputs.python.metadataFile,
|
|
304
|
+
},
|
|
305
|
+
dotnet: {
|
|
306
|
+
outputDir:
|
|
307
|
+
(outputs.dotnet as Record<string, unknown> | undefined)?.outputDir as string ??
|
|
308
|
+
DEFAULT_CONFIG.outputs.dotnet.outputDir,
|
|
309
|
+
},
|
|
310
|
+
golang: {
|
|
311
|
+
outputDir:
|
|
312
|
+
(outputs.golang as Record<string, unknown> | undefined)?.outputDir as string ??
|
|
313
|
+
DEFAULT_CONFIG.outputs.golang.outputDir,
|
|
314
|
+
},
|
|
315
|
+
rust: {
|
|
316
|
+
outputDir:
|
|
317
|
+
(outputs.rust as Record<string, unknown> | undefined)?.outputDir as string ??
|
|
318
|
+
DEFAULT_CONFIG.outputs.rust.outputDir,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
pull: {
|
|
322
|
+
exclude: (pull.exclude as string[] | undefined) ?? DEFAULT_CONFIG.pull.exclude,
|
|
323
|
+
preserveRelations:
|
|
324
|
+
(pull.preserveRelations as boolean | undefined) ?? DEFAULT_CONFIG.pull.preserveRelations,
|
|
325
|
+
},
|
|
326
|
+
generation: {
|
|
327
|
+
generateMetadata:
|
|
328
|
+
(generation.generateMetadata as boolean | undefined) ?? DEFAULT_CONFIG.generation.generateMetadata,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The connection string to use, or an error explaining where to put one.
|
|
335
|
+
*
|
|
336
|
+
* DATABASE_URL wins over the config file: it is the documented override and the
|
|
337
|
+
* one CI sets. A project may therefore commit a development connection and
|
|
338
|
+
* still have a different one in a pipeline without editing the file.
|
|
339
|
+
*/
|
|
340
|
+
export function resolveConnectionString(
|
|
341
|
+
config: An5OrmConfig,
|
|
342
|
+
env: EnvLike = process.env,
|
|
343
|
+
command = 'this command',
|
|
344
|
+
): string {
|
|
345
|
+
const fromEnv = env.DATABASE_URL;
|
|
346
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv;
|
|
347
|
+
|
|
348
|
+
const fromConfig = config.connectionString;
|
|
349
|
+
if (typeof fromConfig === 'string' && fromConfig.trim() !== '') return fromConfig;
|
|
350
|
+
|
|
351
|
+
throw new ConfigError([
|
|
352
|
+
{
|
|
353
|
+
path: 'connectionString',
|
|
354
|
+
message:
|
|
355
|
+
`required for ${command}; not set in an5Orm.config.js and DATABASE_URL is empty. ` +
|
|
356
|
+
'Set DATABASE_URL, or add a connectionString to the config file.',
|
|
357
|
+
},
|
|
358
|
+
]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The provider a connection string points at.
|
|
363
|
+
*
|
|
364
|
+
* Reads the scheme exactly as `An5Adapter` does
|
|
365
|
+
* (`an5Adapters/typescript/src/an5Adapter.ts`) so the generator and the adapter
|
|
366
|
+
* always talk about the same database: the same connection string must give the
|
|
367
|
+
* same provider, otherwise validation checks the types of one database while the
|
|
368
|
+
* SQL runs on another.
|
|
369
|
+
*
|
|
370
|
+
* Falls back to SQL Server when the scheme is unknown — it is the default
|
|
371
|
+
* provider, and ADO-style strings (`Server=...;Database=...`) have no scheme to
|
|
372
|
+
* read. `sqlite://`, a bare path ending in `.sqlite`, `.sqlite3` or `.db` — the same
|
|
373
|
+
* list `An5Adapter` uses.
|
|
374
|
+
*/
|
|
375
|
+
export function detectProvider(connectionString: string | undefined | null): Provider {
|
|
376
|
+
// Lower-cased: a URI scheme is case-insensitive, so `MySQL://` is the same
|
|
377
|
+
// connection as `mysql://`. `an5Adapters/typescript/src/an5Adapter.ts` compares
|
|
378
|
+
// the same way, and the two have to agree or this picks a provider whose DDL the
|
|
379
|
+
// adapter never runs.
|
|
380
|
+
const cs = (connectionString ?? '').trim().toLowerCase();
|
|
381
|
+
if (cs === '') return DEFAULT_PROVIDER;
|
|
382
|
+
if (cs.startsWith('googlesheets://')) return 'googlesheets';
|
|
383
|
+
if (cs.startsWith('postgres://') || cs.startsWith('postgresql://')) return 'postgres';
|
|
384
|
+
if (cs.startsWith('mysql://') || cs.startsWith('mariadb://')) return 'mysql';
|
|
385
|
+
if (SQLITE_FILE_SUFFIXES.some((suffix) => cs.endsWith(suffix))) return 'sqlite';
|
|
386
|
+
return DEFAULT_PROVIDER;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** File extensions that mean SQLite when there is no scheme to read. */
|
|
390
|
+
const SQLITE_FILE_SUFFIXES = ['.sqlite', '.sqlite3', '.db'];
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The provider for this run: `DATABASE_URL` first, then the connection string in
|
|
394
|
+
* the config file, then the default.
|
|
395
|
+
*
|
|
396
|
+
* Does not throw when there is no connection string — `generate` has to work
|
|
397
|
+
* with an empty config, and the default provider still validates the schema as
|
|
398
|
+
* before.
|
|
399
|
+
*/
|
|
400
|
+
export function providerFromConfig(
|
|
401
|
+
config: An5OrmConfig,
|
|
402
|
+
env: EnvLike = process.env,
|
|
403
|
+
): Provider {
|
|
404
|
+
const fromEnv = typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.trim() !== '' ? env.DATABASE_URL : undefined;
|
|
405
|
+
return detectProvider(fromEnv ?? config.connectionString);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The provider configured for a project directory.
|
|
410
|
+
*
|
|
411
|
+
* For the tools outside this package that read `.an5` files — the an5Agent
|
|
412
|
+
* schema tools, the VS Code extension — so they validate against the same
|
|
413
|
+
* database the generator does instead of silently assuming SQL Server.
|
|
414
|
+
*
|
|
415
|
+
* Never throws: no config, an unreadable one, or an invalid one all mean the
|
|
416
|
+
* default provider, which is what the parser does on its own anyway.
|
|
417
|
+
*/
|
|
418
|
+
export function providerForProject(
|
|
419
|
+
cwd: string = process.cwd(),
|
|
420
|
+
env: EnvLike = process.env,
|
|
421
|
+
): Provider {
|
|
422
|
+
try {
|
|
423
|
+
return providerFromConfig(loadConfig(cwd).config, env);
|
|
424
|
+
} catch {
|
|
425
|
+
return DEFAULT_PROVIDER;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Renders issues for the terminal, one per line, paths aligned.
|
|
431
|
+
*
|
|
432
|
+
* Takes the shape rather than `ConfigIssue` so field type errors
|
|
433
|
+
* (`FieldTypeIssue`, from `./field-types`) print through the same code — both
|
|
434
|
+
* are "a path in the file and what is wrong with it".
|
|
435
|
+
*/
|
|
436
|
+
export function formatIssues(issues: ReadonlyArray<{ path: string; message: string }>): string {
|
|
437
|
+
const width = Math.max(...issues.map((issue) => issue.path.length));
|
|
438
|
+
return issues
|
|
439
|
+
.map((issue) => ` ${issue.path.padEnd(width)} ${issue.message}`)
|
|
440
|
+
.join('\n');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export interface LoadedConfig {
|
|
444
|
+
/** The validated config, with defaults applied. */
|
|
445
|
+
config: An5OrmConfig;
|
|
446
|
+
/** Directory the config file lives in; every relative path resolves against it. */
|
|
447
|
+
rootDir: string;
|
|
448
|
+
/** Absolute output paths. */
|
|
449
|
+
outputs: ResolvedOutputs;
|
|
450
|
+
/** Absolute path of the config file, or null when none was found. */
|
|
451
|
+
configPath: string | null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Finds, loads and validates an5Orm.config.js.
|
|
456
|
+
*
|
|
457
|
+
* Searched in order: the working directory, the same directory as `.cjs`, and
|
|
458
|
+
* the parent directory for each. When no file is found the defaults are used,
|
|
459
|
+
* so a project can generate without a config.
|
|
460
|
+
*/
|
|
461
|
+
export function loadConfig(cwd: string = process.cwd()): LoadedConfig {
|
|
462
|
+
// Resolved before the search because `require` treats a bare specifier as a
|
|
463
|
+
// package name: with a relative `cwd` the file is found (`existsSync` is
|
|
464
|
+
// relative to the process directory) but `require('./an5Orm.config.js')` is
|
|
465
|
+
// really `require('an5Orm.config.js')`, which throws MODULE_NOT_FOUND and
|
|
466
|
+
// loses the config without saying so.
|
|
467
|
+
const startDir = path.resolve(cwd);
|
|
468
|
+
const candidates = [
|
|
469
|
+
path.join(startDir, 'an5Orm.config.js'),
|
|
470
|
+
path.join(startDir, 'an5Orm.config.cjs'),
|
|
471
|
+
path.join(startDir, '..', 'an5Orm.config.js'),
|
|
472
|
+
path.join(startDir, '..', 'an5Orm.config.cjs'),
|
|
473
|
+
];
|
|
474
|
+
|
|
475
|
+
const configPath = candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
|
476
|
+
|
|
477
|
+
if (configPath === null) {
|
|
478
|
+
// A schema directory one level up is the common monorepo layout; without a
|
|
479
|
+
// config the paths would otherwise point at a directory that does not exist.
|
|
480
|
+
const rootDir = fs.existsSync(path.join(startDir, '..', 'an5Schema'))
|
|
481
|
+
? path.resolve(startDir, '..')
|
|
482
|
+
: startDir;
|
|
483
|
+
return {
|
|
484
|
+
config: DEFAULT_CONFIG,
|
|
485
|
+
rootDir,
|
|
486
|
+
outputs: resolveOutputs(DEFAULT_CONFIG, rootDir),
|
|
487
|
+
configPath: null,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const rootDir = path.dirname(configPath);
|
|
492
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
493
|
+
const raw = require(configPath);
|
|
494
|
+
const config = validateConfig(raw);
|
|
495
|
+
return { config, rootDir, outputs: resolveOutputs(config, rootDir), configPath };
|
|
496
|
+
}
|