@c9up/atlas 0.2.0 → 0.2.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 +8 -0
- package/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/dist/AtlasProvider.d.ts +60 -7
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +204 -38
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseEntity.d.ts +17 -0
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +63 -27
- package/dist/BaseModel.d.ts.map +1 -1
- package/dist/BaseModel.js +108 -51
- package/dist/BaseModel.js.map +1 -1
- package/dist/BaseRepository.d.ts +11 -1
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +95 -20
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ConnectionManager.d.ts +106 -0
- package/dist/ConnectionManager.d.ts.map +1 -0
- package/dist/ConnectionManager.js +228 -0
- package/dist/ConnectionManager.js.map +1 -0
- package/dist/ModelQuery.d.ts +256 -43
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +899 -231
- package/dist/ModelQuery.js.map +1 -1
- package/dist/Transaction.d.ts +54 -1
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +121 -59
- package/dist/Transaction.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts +11 -0
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +35 -7
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/console/contract.d.ts +62 -0
- package/dist/console/contract.d.ts.map +1 -0
- package/dist/console/contract.js +38 -0
- package/dist/console/contract.js.map +1 -0
- package/dist/console/factoryCommands.d.ts +23 -0
- package/dist/console/factoryCommands.d.ts.map +1 -0
- package/dist/console/factoryCommands.js +62 -0
- package/dist/console/factoryCommands.js.map +1 -0
- package/dist/console/migrationCommands.d.ts +38 -8
- package/dist/console/migrationCommands.d.ts.map +1 -1
- package/dist/console/migrationCommands.js +157 -50
- package/dist/console/migrationCommands.js.map +1 -1
- package/dist/console/schemaCheckCommand.d.ts +11 -17
- package/dist/console/schemaCheckCommand.d.ts.map +1 -1
- package/dist/console/schemaCheckCommand.js +21 -15
- package/dist/console/schemaCheckCommand.js.map +1 -1
- package/dist/console/schemaDumpCommand.d.ts +30 -0
- package/dist/console/schemaDumpCommand.d.ts.map +1 -0
- package/dist/console/schemaDumpCommand.js +69 -0
- package/dist/console/schemaDumpCommand.js.map +1 -0
- package/dist/console/schemaGenerateCommand.d.ts +100 -0
- package/dist/console/schemaGenerateCommand.d.ts.map +1 -0
- package/dist/console/schemaGenerateCommand.js +246 -0
- package/dist/console/schemaGenerateCommand.js.map +1 -0
- package/dist/console/seederCommands.d.ts +46 -0
- package/dist/console/seederCommands.d.ts.map +1 -0
- package/dist/console/seederCommands.js +136 -0
- package/dist/console/seederCommands.js.map +1 -0
- package/dist/decorators/entity.d.ts +13 -2
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +10 -0
- package/dist/decorators/entity.js.map +1 -1
- package/dist/decorators/hooks.d.ts +3 -3
- package/dist/decorators/hooks.d.ts.map +1 -1
- package/dist/decorators/hooks.js.map +1 -1
- package/dist/events.d.ts +5 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/factories.d.ts +8 -0
- package/dist/factories.d.ts.map +1 -0
- package/dist/factories.js +8 -0
- package/dist/factories.js.map +1 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/lucid-schema.d.ts +15 -0
- package/dist/lucid-schema.d.ts.map +1 -0
- package/dist/lucid-schema.js +16 -0
- package/dist/lucid-schema.js.map +1 -0
- package/dist/orm.d.ts +14 -0
- package/dist/orm.d.ts.map +1 -0
- package/dist/orm.js +14 -0
- package/dist/orm.js.map +1 -0
- package/dist/query/DatabaseQueryBuilder.d.ts +557 -0
- package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -0
- package/dist/query/DatabaseQueryBuilder.js +1798 -0
- package/dist/query/DatabaseQueryBuilder.js.map +1 -0
- package/dist/query/DmlBuilder.d.ts +62 -0
- package/dist/query/DmlBuilder.d.ts.map +1 -0
- package/dist/query/DmlBuilder.js +84 -0
- package/dist/query/DmlBuilder.js.map +1 -0
- package/dist/query/QueryBuilder.d.ts +5 -0
- package/dist/query/QueryBuilder.d.ts.map +1 -1
- package/dist/query/QueryBuilder.js +7 -0
- package/dist/query/QueryBuilder.js.map +1 -1
- package/dist/query/RawQueryBuilder.d.ts +47 -0
- package/dist/query/RawQueryBuilder.d.ts.map +1 -0
- package/dist/query/RawQueryBuilder.js +138 -0
- package/dist/query/RawQueryBuilder.js.map +1 -0
- package/dist/query/interpolate.d.ts +45 -0
- package/dist/query/interpolate.d.ts.map +1 -0
- package/dist/query/interpolate.js +51 -0
- package/dist/query/interpolate.js.map +1 -0
- package/dist/query/native.d.ts +8 -0
- package/dist/query/native.d.ts.map +1 -1
- package/dist/query/native.js +14 -0
- package/dist/query/native.js.map +1 -1
- package/dist/query/operators.d.ts +4 -0
- package/dist/query/operators.d.ts.map +1 -0
- package/dist/query/operators.js +27 -0
- package/dist/query/operators.js.map +1 -0
- package/dist/schema/Migration.d.ts +31 -7
- package/dist/schema/Migration.d.ts.map +1 -1
- package/dist/schema/Migration.js +34 -7
- package/dist/schema/Migration.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts +56 -6
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +382 -112
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/Schema.d.ts +59 -2
- package/dist/schema/Schema.d.ts.map +1 -1
- package/dist/schema/Schema.js +143 -9
- package/dist/schema/Schema.js.map +1 -1
- package/dist/schema/SchemaDumper.d.ts +121 -0
- package/dist/schema/SchemaDumper.d.ts.map +1 -0
- package/dist/schema/SchemaDumper.js +365 -0
- package/dist/schema/SchemaDumper.js.map +1 -0
- package/dist/schema/Seeder.d.ts +13 -0
- package/dist/schema/Seeder.d.ts.map +1 -1
- package/dist/schema/Seeder.js +28 -5
- package/dist/schema/Seeder.js.map +1 -1
- package/dist/schema/TableBuilder.d.ts +27 -8
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +50 -12
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/schema/catalog.d.ts +25 -1
- package/dist/schema/catalog.d.ts.map +1 -1
- package/dist/schema/catalog.js +69 -22
- package/dist/schema/catalog.js.map +1 -1
- package/dist/schema/introspect.d.ts +1 -1
- package/dist/schema/introspect.d.ts.map +1 -1
- package/dist/schema/introspect.js +26 -11
- package/dist/schema/introspect.js.map +1 -1
- package/dist/seeders.d.ts +7 -0
- package/dist/seeders.d.ts.map +1 -0
- package/dist/seeders.js +7 -0
- package/dist/seeders.js.map +1 -0
- package/dist/services/db.d.ts +120 -9
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js +182 -26
- package/dist/services/db.js.map +1 -1
- package/dist/testing/DatabaseCleanup.d.ts +29 -6
- package/dist/testing/DatabaseCleanup.d.ts.map +1 -1
- package/dist/testing/DatabaseCleanup.js +44 -17
- package/dist/testing/DatabaseCleanup.js.map +1 -1
- package/dist/testing/DbAssertions.d.ts +54 -0
- package/dist/testing/DbAssertions.d.ts.map +1 -0
- package/dist/testing/DbAssertions.js +156 -0
- package/dist/testing/DbAssertions.js.map +1 -0
- package/dist/testing/Factory.d.ts +111 -25
- package/dist/testing/Factory.d.ts.map +1 -1
- package/dist/testing/Factory.js +331 -56
- package/dist/testing/Factory.js.map +1 -1
- package/dist/testing/TestUtils.d.ts +49 -0
- package/dist/testing/TestUtils.d.ts.map +1 -0
- package/dist/testing/TestUtils.js +79 -0
- package/dist/testing/TestUtils.js.map +1 -0
- package/dist/testing/index.d.ts +3 -1
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +3 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/types/relations.d.ts +31 -0
- package/dist/types/relations.d.ts.map +1 -0
- package/dist/types/relations.js +20 -0
- package/dist/types/relations.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +30 -2
- package/src/AtlasProvider.ts +297 -51
- package/src/BaseEntity.ts +29 -0
- package/src/BaseModel.ts +174 -37
- package/src/BaseRepository.ts +136 -17
- package/src/ConnectionManager.ts +298 -0
- package/src/ModelQuery.ts +1309 -260
- package/src/Transaction.ts +194 -60
- package/src/adapters/NapiDbAdapter.ts +72 -14
- package/src/console/contract.ts +96 -0
- package/src/console/factoryCommands.ts +73 -0
- package/src/console/migrationCommands.ts +253 -63
- package/src/console/schemaCheckCommand.ts +26 -22
- package/src/console/schemaDumpCommand.ts +96 -0
- package/src/console/schemaGenerateCommand.ts +380 -0
- package/src/console/seederCommands.ts +174 -0
- package/src/decorators/entity.ts +18 -2
- package/src/decorators/hooks.ts +5 -3
- package/src/events.ts +5 -0
- package/src/factories.ts +10 -0
- package/src/index.ts +50 -3
- package/src/lucid-schema.ts +22 -0
- package/src/orm.ts +13 -0
- package/src/query/DatabaseQueryBuilder.ts +2724 -0
- package/src/query/DmlBuilder.ts +131 -0
- package/src/query/QueryBuilder.ts +8 -0
- package/src/query/RawQueryBuilder.ts +173 -0
- package/src/query/interpolate.ts +73 -0
- package/src/query/native.ts +29 -0
- package/src/query/operators.ts +27 -0
- package/src/schema/Migration.ts +36 -7
- package/src/schema/MigrationRunner.ts +483 -119
- package/src/schema/Schema.ts +168 -19
- package/src/schema/SchemaDumper.ts +518 -0
- package/src/schema/Seeder.ts +47 -10
- package/src/schema/TableBuilder.ts +48 -11
- package/src/schema/catalog.ts +94 -24
- package/src/schema/introspect.ts +26 -9
- package/src/seeders.ts +16 -0
- package/src/services/db.ts +343 -31
- package/src/testing/DatabaseCleanup.ts +73 -21
- package/src/testing/DbAssertions.ts +220 -0
- package/src/testing/Factory.ts +559 -79
- package/src/testing/TestUtils.ts +123 -0
- package/src/testing/index.ts +12 -1
- package/src/types/relations.ts +43 -0
|
@@ -5,20 +5,51 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { AsyncDatabaseConnection } from "../adapters/NapiDbAdapter.js";
|
|
8
|
+
import { AtlasError } from "../errors.js";
|
|
8
9
|
import { compileStatementNative } from "../query/native.js";
|
|
9
|
-
import { listUserTables,
|
|
10
|
+
import { listUserTables, runWithoutForeignKeys } from "../schema/catalog.js";
|
|
11
|
+
import type { TransactionClient } from "../Transaction.js";
|
|
12
|
+
|
|
13
|
+
/** What {@link useTransaction} hands back: the pinned trx plus its teardown. */
|
|
14
|
+
export interface TestTransaction {
|
|
15
|
+
/**
|
|
16
|
+
* The interactive transaction, pinned to ONE pooled connection. Run every
|
|
17
|
+
* query the test makes through THIS handle (`trx.query` / `trx.execute`, or
|
|
18
|
+
* `repo.useTransaction(trx)`), never the pooled `db` — that is what makes the
|
|
19
|
+
* rollback isolate the test.
|
|
20
|
+
*/
|
|
21
|
+
trx: TransactionClient;
|
|
22
|
+
/** Roll everything the test did back and return the connection to the pool. */
|
|
23
|
+
rollback: () => Promise<void>;
|
|
24
|
+
}
|
|
10
25
|
|
|
11
26
|
/**
|
|
12
|
-
*
|
|
27
|
+
* Isolate a test by running it inside a transaction that is rolled back after
|
|
28
|
+
* (Adonis Lucid parity — Lucid's tests use `db.transaction()` the same way).
|
|
29
|
+
*
|
|
30
|
+
* The earlier implementation issued `SAVEPOINT` / `ROLLBACK` through the pooled
|
|
31
|
+
* `db`, so with `poolMax > 1` the savepoint and its rollback — and the test's
|
|
32
|
+
* own queries — could each land on a DIFFERENT pooled connection, and the
|
|
33
|
+
* "isolation" silently did nothing. This pins a single interactive transaction
|
|
34
|
+
* instead: every query on the returned `trx` runs on that one connection, and
|
|
35
|
+
* `rollback()` reverts them together, correctly on any pool size.
|
|
13
36
|
*/
|
|
14
37
|
export async function useTransaction(
|
|
15
38
|
db: AsyncDatabaseConnection,
|
|
16
|
-
): Promise<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
39
|
+
): Promise<TestTransaction> {
|
|
40
|
+
if (typeof db.transaction !== "function") {
|
|
41
|
+
throw new AtlasError(
|
|
42
|
+
"E_NO_INTERACTIVE_TRANSACTION",
|
|
43
|
+
"useTransaction() needs a connection with an interactive transaction() (a real napi connection); this adapter has none.",
|
|
44
|
+
{
|
|
45
|
+
hint: "Use createNapiConnection(), or drive rollback yourself if your adapter can't pin a connection.",
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
// Manual (callback-less) mode: a pinned TransactionClient the test drives and
|
|
50
|
+
// tears down via rollback() — no cross-pool savepoint scatter.
|
|
51
|
+
const trx = await db.transaction();
|
|
52
|
+
return { trx, rollback: () => trx.rollback() };
|
|
22
53
|
}
|
|
23
54
|
|
|
24
55
|
/**
|
|
@@ -27,23 +58,44 @@ export async function useTransaction(
|
|
|
27
58
|
* shared dialect-aware catalog helper, not a SQLite-only `sqlite_master` query.
|
|
28
59
|
*
|
|
29
60
|
* Foreign keys are suspended for the duration so the delete order doesn't
|
|
30
|
-
* matter
|
|
31
|
-
*
|
|
32
|
-
*
|
|
61
|
+
* matter. The FK toggle and every DELETE run on ONE pinned connection (via
|
|
62
|
+
* `runWithoutForeignKeys` → `runInTransaction`), so a connection pool can't
|
|
63
|
+
* scatter the connection-local `PRAGMA`/`SET` away from the deletes. The row
|
|
64
|
+
* removal goes through the Rust compiler (`DELETE` — cross-dialect, unlike
|
|
65
|
+
* `TRUNCATE` which auto-commits on MySQL).
|
|
33
66
|
*/
|
|
34
|
-
export async function truncateAll(
|
|
67
|
+
export async function truncateAll(
|
|
68
|
+
db: AsyncDatabaseConnection,
|
|
69
|
+
ignoreTables: readonly string[] = [],
|
|
70
|
+
): Promise<void> {
|
|
35
71
|
// The connection's own dialect, not the module default — correct even when
|
|
36
72
|
// an app runs several connections on different engines.
|
|
37
73
|
const dialect = db.dialect;
|
|
38
|
-
const
|
|
74
|
+
const ignore = new Set(ignoreTables);
|
|
75
|
+
const tables = (await listUserTables(db, dialect)).filter(
|
|
76
|
+
(t) => !ignore.has(t),
|
|
77
|
+
);
|
|
39
78
|
if (tables.length === 0) return;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
79
|
+
|
|
80
|
+
// Postgres: a plain DELETE respects FKs immediately (atlas FKs aren't
|
|
81
|
+
// DEFERRABLE), so deleting a parent before its children raises 23503 — and
|
|
82
|
+
// nothing orders the tables. `TRUNCATE … CASCADE` is transactional on pg,
|
|
83
|
+
// order-independent, and needs no session-level FK toggle to leak. (Names
|
|
84
|
+
// come from the DB catalog; quote them for identifiers with embedded quotes.)
|
|
85
|
+
if (dialect === "postgres") {
|
|
86
|
+
const list = tables.map((t) => `"${t.replace(/"/g, '""')}"`).join(", ");
|
|
87
|
+
await db.execute(`TRUNCATE ${list} RESTART IDENTITY CASCADE`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MySQL/SQLite: DELETE (TRUNCATE auto-commits on MySQL, breaking test
|
|
92
|
+
// transaction isolation), with FK suspension on ONE pinned connection.
|
|
93
|
+
const statements = tables.map((name) => {
|
|
94
|
+
const compiled = compileStatementNative(
|
|
95
|
+
{ kind: "delete", table: name, wheres: [] },
|
|
96
|
+
dialect,
|
|
97
|
+
);
|
|
98
|
+
return { sql: compiled.statements[0], params: compiled.params };
|
|
48
99
|
});
|
|
100
|
+
await runWithoutForeignKeys(db, dialect, statements);
|
|
49
101
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `db()` — a helix plugin (AdonisJS Lucid database-assertions parity) that
|
|
3
|
+
* injects database assertions on the test context as `ctx.db`:
|
|
4
|
+
*
|
|
5
|
+
* // tests/bootstrap.ts
|
|
6
|
+
* import { configure } from "@c9up/helix";
|
|
7
|
+
* import { db } from "@c9up/atlas/testing";
|
|
8
|
+
* await configure({ plugins: [db(connection)] });
|
|
9
|
+
*
|
|
10
|
+
* test("registers the user", async ({ db }) => {
|
|
11
|
+
* await db.assertHas("users", { email: "a@b.c" });
|
|
12
|
+
* await db.assertCount("users", 1);
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Values are always bound as parameters; table/column identifiers are validated
|
|
16
|
+
* against a strict pattern (letters, digits, `_`, `.`) and quoted, so neither a
|
|
17
|
+
* value nor an identifier can inject SQL.
|
|
18
|
+
*
|
|
19
|
+
* NOTE (namespace): the AdonisJS docs expose these as `db.assert*`; the exact
|
|
20
|
+
* surface is re-verified against the Adonis docs when available. Model-based
|
|
21
|
+
* `assertModelExists`/`assertModelMissing` (entity PK metadata) are pending.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Plugin } from "@c9up/helix";
|
|
25
|
+
|
|
26
|
+
/** The minimal connection the assertions need — a parameterized query runner. */
|
|
27
|
+
export interface DbConnectionLike {
|
|
28
|
+
query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
|
|
29
|
+
/**
|
|
30
|
+
* The engine this connection speaks. Absent on a hand-rolled stub, in which
|
|
31
|
+
* case the assertions fall back to `?` placeholders and emit no cast, which
|
|
32
|
+
* is what SQLite and MySQL want.
|
|
33
|
+
*/
|
|
34
|
+
dialect?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Column types Postgres will NOT compare against a text-bound parameter.
|
|
39
|
+
*
|
|
40
|
+
* A driver binds a JS string as `text`; `uuid = text` then fails with
|
|
41
|
+
* "operator does not exist". The cast is derived from the COLUMN's declared
|
|
42
|
+
* type, read from the catalog — never guessed from the value, since a text
|
|
43
|
+
* column holding a UUID-shaped string must keep comparing as text.
|
|
44
|
+
*/
|
|
45
|
+
const PG_CASTABLE = new Set([
|
|
46
|
+
"uuid",
|
|
47
|
+
"timestamp without time zone",
|
|
48
|
+
"timestamp with time zone",
|
|
49
|
+
"date",
|
|
50
|
+
"time without time zone",
|
|
51
|
+
"time with time zone",
|
|
52
|
+
"numeric",
|
|
53
|
+
"json",
|
|
54
|
+
"jsonb",
|
|
55
|
+
"inet",
|
|
56
|
+
"interval",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Catalog lookups are per (table, connection) and never change mid-run. */
|
|
60
|
+
const columnTypeCache = new WeakMap<
|
|
61
|
+
DbConnectionLike,
|
|
62
|
+
Map<string, Map<string, string>>
|
|
63
|
+
>();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `column → declared type` for one table, from `information_schema`.
|
|
67
|
+
*
|
|
68
|
+
* A failure here is not fatal: the assertions simply emit no cast, which is
|
|
69
|
+
* exactly the pre-existing behaviour. A broken catalog read must not turn a
|
|
70
|
+
* passing assertion into an error about introspection.
|
|
71
|
+
*/
|
|
72
|
+
async function columnTypes(
|
|
73
|
+
conn: DbConnectionLike,
|
|
74
|
+
table: string,
|
|
75
|
+
): Promise<Map<string, string>> {
|
|
76
|
+
let perConn = columnTypeCache.get(conn);
|
|
77
|
+
if (!perConn) {
|
|
78
|
+
perConn = new Map();
|
|
79
|
+
columnTypeCache.set(conn, perConn);
|
|
80
|
+
}
|
|
81
|
+
const cached = perConn.get(table);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
|
|
84
|
+
const types = new Map<string, string>();
|
|
85
|
+
try {
|
|
86
|
+
// `table` may be schema-qualified: compare on the last segment.
|
|
87
|
+
const bare = table.includes(".")
|
|
88
|
+
? table.slice(table.lastIndexOf(".") + 1)
|
|
89
|
+
: table;
|
|
90
|
+
const rows = await conn.query(
|
|
91
|
+
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1",
|
|
92
|
+
[bare],
|
|
93
|
+
);
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
const name = row.column_name;
|
|
96
|
+
const type = row.data_type;
|
|
97
|
+
if (typeof name === "string" && typeof type === "string") {
|
|
98
|
+
types.set(name, type.toLowerCase());
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// No catalog, no cast — see the doc above.
|
|
103
|
+
}
|
|
104
|
+
perConn.set(table, types);
|
|
105
|
+
return types;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Reject identifiers that could break out of the quoted context. */
|
|
109
|
+
function quoteIdent(name: string): string {
|
|
110
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(name)) {
|
|
111
|
+
throw new Error(`db assertion: unsafe identifier ${JSON.stringify(name)}`);
|
|
112
|
+
}
|
|
113
|
+
return `"${name.replace(/\./g, '"."')}"`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function countRows(
|
|
117
|
+
conn: DbConnectionLike,
|
|
118
|
+
table: string,
|
|
119
|
+
payload?: Record<string, unknown>,
|
|
120
|
+
): Promise<number> {
|
|
121
|
+
// Postgres numbers its placeholders; MySQL and SQLite use `?`. Emitting `?`
|
|
122
|
+
// on Postgres produced "syntax error at or near AND" — the marker was never
|
|
123
|
+
// dialect-aware.
|
|
124
|
+
const isPostgres = conn.dialect === "postgres";
|
|
125
|
+
let where = "";
|
|
126
|
+
let params: unknown[] = [];
|
|
127
|
+
if (payload) {
|
|
128
|
+
const keys = Object.keys(payload);
|
|
129
|
+
if (keys.length > 0) {
|
|
130
|
+
const casts = isPostgres ? await columnTypes(conn, table) : undefined;
|
|
131
|
+
const predicates = keys.map((k, i) => {
|
|
132
|
+
if (!isPostgres) return `${quoteIdent(k)} = ?`;
|
|
133
|
+
const declared = casts?.get(k);
|
|
134
|
+
const cast =
|
|
135
|
+
declared &&
|
|
136
|
+
PG_CASTABLE.has(declared) &&
|
|
137
|
+
typeof payload[k] === "string"
|
|
138
|
+
? `::${declared}`
|
|
139
|
+
: "";
|
|
140
|
+
return `${quoteIdent(k)} = $${i + 1}${cast}`;
|
|
141
|
+
});
|
|
142
|
+
where = ` WHERE ${predicates.join(" AND ")}`;
|
|
143
|
+
params = keys.map((k) => payload[k]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const rows = await conn.query(
|
|
147
|
+
`SELECT COUNT(*) AS c FROM ${quoteIdent(table)}${where}`,
|
|
148
|
+
params,
|
|
149
|
+
);
|
|
150
|
+
return Number(rows[0]?.c ?? 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The `ctx.db` assertion surface (AdonisJS Lucid database-assertions parity). */
|
|
154
|
+
export interface DbAssertions {
|
|
155
|
+
/** At least one row in `table` matches every column in `payload`. */
|
|
156
|
+
assertHas(table: string, payload: Record<string, unknown>): Promise<void>;
|
|
157
|
+
/** No row in `table` matches `payload`. */
|
|
158
|
+
assertMissing(table: string, payload: Record<string, unknown>): Promise<void>;
|
|
159
|
+
/** `table` has exactly `expected` rows (optionally matching `payload`). */
|
|
160
|
+
assertCount(
|
|
161
|
+
table: string,
|
|
162
|
+
expected: number,
|
|
163
|
+
payload?: Record<string, unknown>,
|
|
164
|
+
): Promise<void>;
|
|
165
|
+
/** `table` has no rows at all. */
|
|
166
|
+
assertEmpty(table: string): Promise<void>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Build the assertion surface bound to a connection. */
|
|
170
|
+
export function createDbAssertions(conn: DbConnectionLike): DbAssertions {
|
|
171
|
+
return {
|
|
172
|
+
async assertHas(table, payload) {
|
|
173
|
+
const n = await countRows(conn, table, payload);
|
|
174
|
+
if (n === 0) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Expected "${table}" to have a row matching ${JSON.stringify(payload)}, found none.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
async assertMissing(table, payload) {
|
|
181
|
+
const n = await countRows(conn, table, payload);
|
|
182
|
+
if (n > 0) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Expected "${table}" to have NO row matching ${JSON.stringify(payload)}, found ${n}.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
async assertCount(table, expected, payload) {
|
|
189
|
+
const n = await countRows(conn, table, payload);
|
|
190
|
+
if (n !== expected) {
|
|
191
|
+
const scope = payload ? ` matching ${JSON.stringify(payload)}` : "";
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Expected "${table}" to have ${expected} row(s)${scope}, found ${n}.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
async assertEmpty(table) {
|
|
198
|
+
const n = await countRows(conn, table);
|
|
199
|
+
if (n > 0) {
|
|
200
|
+
throw new Error(`Expected "${table}" to be empty, found ${n} row(s).`);
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The `db()` helix plugin — registers {@link DbAssertions} as `ctx.db`. */
|
|
207
|
+
export function db(conn: DbConnectionLike): Plugin {
|
|
208
|
+
const assertions = createDbAssertions(conn);
|
|
209
|
+
return (api) => {
|
|
210
|
+
api.context.macro("db", assertions);
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Typing side of the plugin — importing `@c9up/atlas/testing` augments the
|
|
215
|
+
// helix test context with `db` (the Japa pattern).
|
|
216
|
+
declare module "@c9up/helix" {
|
|
217
|
+
interface TestContext {
|
|
218
|
+
db: DbAssertions;
|
|
219
|
+
}
|
|
220
|
+
}
|