@bunyad/orm 0.2.0-beta.0 → 0.2.0-beta.2
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 +23 -1
- package/dist/casts.d.ts +16 -3
- package/dist/casts.js +150 -4
- package/dist/eager.js +130 -11
- package/dist/factory.d.ts +20 -1
- package/dist/factory.js +143 -37
- package/dist/index.d.ts +21 -1
- package/dist/index.js +3 -1
- package/dist/model-events.d.ts +8 -2
- package/dist/model-events.js +27 -4
- package/dist/model-query.d.ts +85 -33
- package/dist/model-query.js +295 -139
- package/dist/model.d.ts +173 -35
- package/dist/model.js +725 -45
- package/dist/relations.d.ts +187 -11
- package/dist/relations.js +815 -89
- package/dist/schema-types.d.ts +27 -0
- package/dist/schema-types.js +145 -0
- package/dist/test-drivers.d.ts +10 -0
- package/dist/test-drivers.js +40 -0
- package/dist/typed-names.d.ts +53 -0
- package/dist/typed-names.js +4 -0
- package/package.json +3 -3
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a model's table from the database and (a) print a TypeScript interface for its
|
|
3
|
+
* columns, (b) report where the model and the table disagree.
|
|
4
|
+
*
|
|
5
|
+
* Used by `bunyad schema:types` and `bunyad schema:check`; usable directly in tests.
|
|
6
|
+
*/
|
|
7
|
+
import { type ColumnInfo, type Connection } from "@bunyad/database";
|
|
8
|
+
import type { ModelClass } from "./model.js";
|
|
9
|
+
type Driver = Connection["driver"];
|
|
10
|
+
/** The TypeScript type a column hydrates to, taking the model's cast into account. */
|
|
11
|
+
export declare function tsTypeForColumn(column: ColumnInfo, driver: Driver, cast?: unknown): string;
|
|
12
|
+
/**
|
|
13
|
+
* `export interface UserColumns { id: number; … }` for one model. Pair it with the model class via
|
|
14
|
+
* declaration merging: `export interface User extends UserColumns {}`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function modelColumnsInterface(model: ModelClass, connection?: Connection): Promise<string>;
|
|
17
|
+
export type SchemaIssue = {
|
|
18
|
+
severity: "error" | "warning";
|
|
19
|
+
model: string;
|
|
20
|
+
table: string;
|
|
21
|
+
message: string;
|
|
22
|
+
};
|
|
23
|
+
/** Where a model's settings reference columns the table does not have. */
|
|
24
|
+
export declare function checkModelSchema(model: ModelClass, connection?: Connection): Promise<SchemaIssue[]>;
|
|
25
|
+
/** The whole `models.generated.ts` file for the given models. */
|
|
26
|
+
export declare function generateModelTypes(models: ModelClass[], connection?: Connection): Promise<string>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a model's table from the database and (a) print a TypeScript interface for its
|
|
3
|
+
* columns, (b) report where the model and the table disagree.
|
|
4
|
+
*
|
|
5
|
+
* Used by `bunyad schema:types` and `bunyad schema:check`; usable directly in tests.
|
|
6
|
+
*/
|
|
7
|
+
import { schemaFor } from "@bunyad/database";
|
|
8
|
+
import { parseCast } from "./casts.js";
|
|
9
|
+
/** The TypeScript type a column hydrates to, taking the model's cast into account. */
|
|
10
|
+
export function tsTypeForColumn(column, driver, cast) {
|
|
11
|
+
let base = castType(cast) ?? databaseType(column.type.toLowerCase(), driver);
|
|
12
|
+
if (column.nullable && !column.primary)
|
|
13
|
+
base += " | null";
|
|
14
|
+
return base;
|
|
15
|
+
}
|
|
16
|
+
function castType(cast) {
|
|
17
|
+
if (typeof cast === "object" && cast !== null)
|
|
18
|
+
return null; // Attribute / enum: leave to the database type
|
|
19
|
+
if (typeof cast !== "string")
|
|
20
|
+
return null;
|
|
21
|
+
const parsed = parseCast(cast);
|
|
22
|
+
if (!parsed)
|
|
23
|
+
return null;
|
|
24
|
+
switch (parsed.type) {
|
|
25
|
+
case "boolean":
|
|
26
|
+
case "bool":
|
|
27
|
+
return "boolean";
|
|
28
|
+
case "integer":
|
|
29
|
+
case "int":
|
|
30
|
+
case "number":
|
|
31
|
+
case "float":
|
|
32
|
+
case "double":
|
|
33
|
+
case "real":
|
|
34
|
+
return "number";
|
|
35
|
+
case "decimal":
|
|
36
|
+
return parsed.arg !== undefined ? "string" : "number";
|
|
37
|
+
case "bigint":
|
|
38
|
+
return "bigint";
|
|
39
|
+
case "string":
|
|
40
|
+
case "hashed":
|
|
41
|
+
case "encrypted":
|
|
42
|
+
return "string";
|
|
43
|
+
case "date":
|
|
44
|
+
case "datetime":
|
|
45
|
+
return "Date";
|
|
46
|
+
case "json":
|
|
47
|
+
return "unknown";
|
|
48
|
+
case "array":
|
|
49
|
+
return "unknown[]";
|
|
50
|
+
case "collection":
|
|
51
|
+
return "Collection<unknown>";
|
|
52
|
+
default:
|
|
53
|
+
return "unknown"; // encrypted:json, encrypted:array, …
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function databaseType(type, driver) {
|
|
57
|
+
const t = type.replace(/\(.*\)/, "").trim();
|
|
58
|
+
if (/^(bool|boolean)$/.test(t))
|
|
59
|
+
return driver === "sqlite" ? "number" : "boolean";
|
|
60
|
+
if (/^tinyint/.test(t))
|
|
61
|
+
return "number";
|
|
62
|
+
if (/^(smallint|integer|int|int2|int4|mediumint|serial|smallserial|float|real|double( precision)?|float4|float8)$/.test(t))
|
|
63
|
+
return "number";
|
|
64
|
+
if (/^(bigint|int8|bigserial)$/.test(t))
|
|
65
|
+
return "number | bigint";
|
|
66
|
+
if (/^(numeric|decimal|money)$/.test(t))
|
|
67
|
+
return "number | string"; // drivers disagree: some return exact strings
|
|
68
|
+
if (/^(date|datetime|timestamp|timestamptz|timestamp without time zone|timestamp with time zone)$/.test(t)) {
|
|
69
|
+
return driver === "sqlite" ? "string" : "Date";
|
|
70
|
+
}
|
|
71
|
+
if (/^(json|jsonb)$/.test(t))
|
|
72
|
+
return driver === "sqlite" ? "string" : "unknown";
|
|
73
|
+
if (/^(blob|bytea|binary|varbinary|longblob)$/.test(t))
|
|
74
|
+
return "Uint8Array";
|
|
75
|
+
return "string"; // text, varchar, char, uuid, enum, time, …
|
|
76
|
+
}
|
|
77
|
+
function modelName(model) {
|
|
78
|
+
return model.name || "Model";
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `export interface UserColumns { id: number; … }` for one model. Pair it with the model class via
|
|
82
|
+
* declaration merging: `export interface User extends UserColumns {}`.
|
|
83
|
+
*/
|
|
84
|
+
export async function modelColumnsInterface(model, connection = model.getConnection()) {
|
|
85
|
+
const columns = await schemaFor(connection).getColumns(model.table);
|
|
86
|
+
if (columns.length === 0) {
|
|
87
|
+
throw new Error(`Table [${model.table}] does not exist (model ${modelName(model)}).`);
|
|
88
|
+
}
|
|
89
|
+
const casts = model.getCasts();
|
|
90
|
+
const lines = columns.map((c) => ` ${c.name}: ${tsTypeForColumn(c, connection.driver, casts[c.name])};`);
|
|
91
|
+
return `/** Columns of \`${model.table}\`. */\nexport interface ${modelName(model)}Columns {\n${lines.join("\n")}\n}\n`;
|
|
92
|
+
}
|
|
93
|
+
/** Where a model's settings reference columns the table does not have. */
|
|
94
|
+
export async function checkModelSchema(model, connection = model.getConnection()) {
|
|
95
|
+
const name = modelName(model);
|
|
96
|
+
const table = model.table;
|
|
97
|
+
const issue = (severity, message) => ({ severity, model: name, table, message });
|
|
98
|
+
const columns = await schemaFor(connection).getColumns(table);
|
|
99
|
+
if (columns.length === 0)
|
|
100
|
+
return [issue("error", `table [${table}] does not exist`)];
|
|
101
|
+
const names = new Set(columns.map((c) => c.name));
|
|
102
|
+
const issues = [];
|
|
103
|
+
const list = (value) => (Array.isArray(value) ? value : []);
|
|
104
|
+
for (const [setting, values] of [["fillable", list(model.fillable)], ["guarded", list(model.guarded).filter((v) => v !== "*")], ["hidden", list(model.hidden)], ["visible", list(model.visible)]]) {
|
|
105
|
+
for (const column of values) {
|
|
106
|
+
if (!names.has(column))
|
|
107
|
+
issues.push(issue("error", `${setting} lists [${column}], which is not a column of [${table}]`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const [column, cast] of Object.entries(model.getCasts())) {
|
|
111
|
+
// `Attribute.make` casts describe computed attributes; only plain casts must match a column.
|
|
112
|
+
if (typeof cast === "string" && !names.has(column))
|
|
113
|
+
issues.push(issue("error", `cast for [${column}] has no matching column in [${table}]`));
|
|
114
|
+
}
|
|
115
|
+
if (!names.has(model.primaryKey)) {
|
|
116
|
+
issues.push(issue("error", `primary key [${model.primaryKey}] is not a column of [${table}]`));
|
|
117
|
+
}
|
|
118
|
+
if (model.timestamps !== false) {
|
|
119
|
+
for (const column of ["created_at", "updated_at"]) {
|
|
120
|
+
if (!names.has(column))
|
|
121
|
+
issues.push(issue("error", `timestamps are on but [${table}] has no [${column}] column`));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (model.softDeletes && !names.has("deleted_at")) {
|
|
125
|
+
issues.push(issue("error", `softDeletes is on but [${table}] has no [deleted_at] column`));
|
|
126
|
+
}
|
|
127
|
+
const known = new Set([...list(model.fillable), ...list(model.hidden), ...list(model.visible), model.primaryKey, "created_at", "updated_at", "deleted_at", ...Object.keys(model.getCasts())]);
|
|
128
|
+
const untracked = columns.filter((c) => !known.has(c.name) && !c.primary).map((c) => c.name);
|
|
129
|
+
if (untracked.length > 0 && list(model.fillable).length > 0) {
|
|
130
|
+
issues.push(issue("warning", `columns not in fillable, hidden or casts: ${untracked.join(", ")}`));
|
|
131
|
+
}
|
|
132
|
+
return issues;
|
|
133
|
+
}
|
|
134
|
+
/** The whole `models.generated.ts` file for the given models. */
|
|
135
|
+
export async function generateModelTypes(models, connection) {
|
|
136
|
+
const sorted = [...models].sort((a, b) => modelName(a).localeCompare(modelName(b)));
|
|
137
|
+
const parts = [];
|
|
138
|
+
for (const model of sorted)
|
|
139
|
+
parts.push(await modelColumnsInterface(model, connection ?? model.getConnection()));
|
|
140
|
+
const body = parts.join("\n");
|
|
141
|
+
const header = "// Generated by `bunyad schema:types`. Do not edit; run it again after changing a migration.\n" +
|
|
142
|
+
(body.includes("Collection<") ? 'import type { Collection } from "@bunyad/common";\n' : "") +
|
|
143
|
+
"\n";
|
|
144
|
+
return header + body;
|
|
145
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-database test harness: SQLite always; Postgres / MySQL when reachable.
|
|
3
|
+
* Set BUNYAD_TEST_POSTGRES_URL and/or BUNYAD_TEST_MYSQL_URL to enable them.
|
|
4
|
+
*/
|
|
5
|
+
import { type Connection } from "@bunyad/database";
|
|
6
|
+
export interface TestDriver {
|
|
7
|
+
name: "sqlite" | "postgres" | "mysql";
|
|
8
|
+
connection: Connection;
|
|
9
|
+
}
|
|
10
|
+
export declare function testDrivers(): Promise<TestDriver[]>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-database test harness: SQLite always; Postgres / MySQL when reachable.
|
|
3
|
+
* Set BUNYAD_TEST_POSTGRES_URL and/or BUNYAD_TEST_MYSQL_URL to enable them.
|
|
4
|
+
*/
|
|
5
|
+
import { connectMysql, connectPostgres, connectSqlite, } from "@bunyad/database";
|
|
6
|
+
async function reachable(connection) {
|
|
7
|
+
try {
|
|
8
|
+
await connection.exec("SELECT 1");
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
// A URL was configured but the server is unusable — say why instead of silently skipping.
|
|
13
|
+
console.warn(`[test-drivers] ${connection.driver} unavailable: ${error.message}`);
|
|
14
|
+
try {
|
|
15
|
+
await connection.close();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* ignore */
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function testDrivers() {
|
|
24
|
+
const drivers = [
|
|
25
|
+
{ name: "sqlite", connection: connectSqlite() },
|
|
26
|
+
];
|
|
27
|
+
const pg = Bun.env.BUNYAD_TEST_POSTGRES_URL ?? Bun.env.DATABASE_URL;
|
|
28
|
+
if (pg && /^(postgres|postgresql):\/\//i.test(pg)) {
|
|
29
|
+
const connection = connectPostgres({ url: pg, max: 2 });
|
|
30
|
+
if (await reachable(connection))
|
|
31
|
+
drivers.push({ name: "postgres", connection });
|
|
32
|
+
}
|
|
33
|
+
const my = Bun.env.BUNYAD_TEST_MYSQL_URL;
|
|
34
|
+
if (my && /^mysql:\/\//i.test(my)) {
|
|
35
|
+
const connection = connectMysql({ url: my, tls: { rejectUnauthorized: false } });
|
|
36
|
+
if (await reachable(connection))
|
|
37
|
+
drivers.push({ name: "mysql", connection });
|
|
38
|
+
}
|
|
39
|
+
return drivers;
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor-hint types: relation and column names for a model, with a `string`
|
|
3
|
+
* fallback so dynamic names, `rel as alias`, nested `a.b` paths and
|
|
4
|
+
* `table.column` keep compiling. Typos still run; the editor just suggests
|
|
5
|
+
* the real names first.
|
|
6
|
+
*/
|
|
7
|
+
import type { BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, HasOneThrough, MorphMany, MorphOne, MorphTo, MorphToMany, MorphedByMany } from "./relations.js";
|
|
8
|
+
import type { Model } from "./model.js";
|
|
9
|
+
import type { OrmCollection } from "./orm-collection.js";
|
|
10
|
+
import type { ModelQuery } from "./model-query.js";
|
|
11
|
+
import type { OrmTypeOptions } from "./index.js";
|
|
12
|
+
type AnyRelation = BelongsTo<any> | BelongsToMany<any> | HasMany<any> | HasManyThrough<any> | HasOne<any> | HasOneThrough<any> | MorphMany<any> | MorphOne<any> | MorphTo | MorphToMany<any> | MorphedByMany<any>;
|
|
13
|
+
/**
|
|
14
|
+
* Relations of a model: methods that return a relation (`posts() { return this.hasMany(Post) }`)
|
|
15
|
+
* and declared relation properties (`declare posts: OrmCollection<Post>`, `declare author: User | null`),
|
|
16
|
+
* which is how models that define `static relations = { … }` describe them.
|
|
17
|
+
*/
|
|
18
|
+
export type RelationNames<T> = {
|
|
19
|
+
[K in Exclude<keyof T, keyof Model> & string]: T[K] extends () => AnyRelation ? K : T[K] extends (...args: any[]) => unknown ? never : [NonNullable<T[K]>] extends [never] ? never : NonNullable<T[K]> extends OrmCollection<any> | readonly Model[] | Model ? K : never;
|
|
20
|
+
}[Exclude<keyof T, keyof Model> & string];
|
|
21
|
+
/** Members `Model` itself declares that are really columns (every table has `id`). */
|
|
22
|
+
type ModelOwnKeys = Exclude<keyof Model, "id">;
|
|
23
|
+
/** Declared data fields on the model (`declare title: string`), excluding methods, plus the timestamp columns. */
|
|
24
|
+
export type ColumnNames<T> = {
|
|
25
|
+
[K in Exclude<keyof T, ModelOwnKeys> & string]: T[K] extends (...args: any[]) => unknown ? never : K;
|
|
26
|
+
}[Exclude<keyof T, ModelOwnKeys> & string] | "created_at" | "updated_at" | "deleted_at";
|
|
27
|
+
/** Suggest `K` in the editor but accept any string. */
|
|
28
|
+
export type Hint<K extends string> = K | (string & {});
|
|
29
|
+
type StrictNames = OrmTypeOptions extends {
|
|
30
|
+
strictNames: false;
|
|
31
|
+
} ? false : true;
|
|
32
|
+
/**
|
|
33
|
+
* Strict relation name: a relation method, optionally with a nested path
|
|
34
|
+
* (`posts.comments`) or an alias (`posts as p`), so only typos in the first
|
|
35
|
+
* segment are rejected.
|
|
36
|
+
*/
|
|
37
|
+
type StrictRelation<T> = RelationNames<T> | `${RelationNames<T>}.${string}` | `${RelationNames<T>} as ${string}`;
|
|
38
|
+
/** Strict column name: a declared field, or a qualified `table.column`. */
|
|
39
|
+
type StrictColumn<T> = ColumnNames<T> | `${string}.${string}`;
|
|
40
|
+
export type RelationHint<T> = StrictNames extends true ? Model extends T ? string : StrictRelation<T> : Hint<RelationNames<T>>;
|
|
41
|
+
export type ColumnHint<T> = StrictNames extends true ? Model extends T ? string : StrictColumn<T> : Hint<ColumnNames<T>>;
|
|
42
|
+
/** Escape hatch for a name only known at run time: `Post.with(unsafeName(input))`. */
|
|
43
|
+
export declare function unsafeName(name: string): never;
|
|
44
|
+
/**
|
|
45
|
+
* The `{ relation: constraint }` form of `with()`. Strict names check the keys like the
|
|
46
|
+
* string form and type the constraint callback; otherwise any record is accepted.
|
|
47
|
+
*/
|
|
48
|
+
export type WithMap<T> = StrictNames extends true ? Model extends T ? Record<string, unknown> : {
|
|
49
|
+
[K in StrictRelation<T>]?: true | string | {
|
|
50
|
+
as?: string;
|
|
51
|
+
} | ((query: ModelQuery) => void);
|
|
52
|
+
} : Record<string, unknown>;
|
|
53
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bunyad/orm",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.2",
|
|
4
4
|
"description": "Active-record ORM for Bunyad: models, relations, casts, scopes, events, factories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shah Khalid",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@bunyad/common": "^0.2.0-beta.
|
|
40
|
-
"@bunyad/database": "^0.2.0-beta.
|
|
39
|
+
"@bunyad/common": "^0.2.0-beta.2",
|
|
40
|
+
"@bunyad/database": "^0.2.0-beta.2"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|