Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion drizzle-orm/src/sql/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface BuildQueryConfig {
escapeName(name: string): string;
escapeParam(num: number, value: unknown): string;
escapeString(str: string): string;
/** @internal */
codecs?: CodecsCollection;
paramStartIndex?: { value: number };
inlineParams?: boolean;
Expand Down Expand Up @@ -170,6 +171,7 @@ export class SQL<T = unknown> implements SQLWrapper<T> {
} as Query;
}

/** @internal */
private collectSQL(
chunks: SQLChunk[],
config: BuildQueryConfig,
Expand Down Expand Up @@ -439,6 +441,7 @@ export class SQL<T = unknown> implements SQLWrapper<T> {
}
}

/** @internal */
private mapInlineParam(
chunk: unknown,
{ escapeString }: BuildQueryConfig,
Expand Down Expand Up @@ -520,7 +523,7 @@ export class SQL<T = unknown> implements SQLWrapper<T> {
}
}

export type GetDecoderResult<T> = T extends Column ? T['_']['data'] : T extends
export type GetDecoderResult<T> = T extends { _: { data: infer TData } } ? TData : T extends
| DriverValueDecoder<infer TData, any>
| DriverValueDecoder<infer TData, any>['mapFromDriverValue'] ? TData
: never;
Expand Down
178 changes: 178 additions & 0 deletions drizzle-orm/tests/exports-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import { beforeAll, describe, expect, test } from 'vitest';
import { checkPackage } from '../../attw-fork/src/checkPackage.ts';
import { getExitCode } from '../../attw-fork/src/cli/getExitCode.ts';
Expand Down Expand Up @@ -114,6 +115,183 @@ describe.skipIf(!ARTIFACTS_PRESENT)('no public subpath is dropped', () => {
}, 120_000);
});

// Two physically distinct installs of the package produce two declaration sites for
// every class. TypeScript compares classes carrying `private`/`protected` members
// nominally, so any such member surviving into the emitted `.d.ts` makes that type
// non-portable: a value from copy A is not assignable to the same type from copy B.
// This is the failure a consumer hits whenever a transitive dep, a pnpm peer split
// or a linked tarball puts two copies of drizzle-orm on disk.
//
// The matrix below is the current, measured state of the public surface. `portable:
// false` entries are known leaks, not aspirations -- each is annotated with the member
// responsible. Fixing one is expected to flip its flag here; that is the point of
// asserting both directions.
interface PortabilityProbe {
/** Exported type name. */
name: string;
/** Package subpath it is exported from. */
subpath: string;
/** Type arguments, when the type has no usable defaults. */
typeArgs?: string;
/** Whether a value of this type survives crossing between two installs. */
portable: boolean;
/** For known leaks: the member whose nominality blocks assignment. */
blockedBy?: string;
}

const PORTABILITY_MATRIX: PortabilityProbe[] = [
{ name: 'SQL', subpath: 'sql/sql', portable: true },
{ name: 'Column', subpath: 'column', portable: true },
{ name: 'Table', subpath: 'table', portable: true },
{ name: 'Subquery', subpath: 'subquery', portable: true },
{ name: 'View', subpath: 'sql/sql', portable: true },
{ name: 'Placeholder', subpath: 'sql/sql', portable: true },
{ name: 'QueryPromise', subpath: 'query-promise', typeArgs: '<number>', portable: true },
{ name: 'MySqlTable', subpath: 'mysql-core', portable: true },
{ name: 'MySqlColumn', subpath: 'mysql-core', portable: true },
{ name: 'SQLiteTable', subpath: 'sqlite-core', portable: true },
{ name: 'SQLiteColumn', subpath: 'sqlite-core', portable: true },

{ name: 'Param', subpath: 'sql/sql', portable: false, blockedBy: 'Param#brand (protected)' },
{ name: 'Name', subpath: 'sql/sql', portable: false, blockedBy: 'Name#brand (protected)' },
{
name: 'CodecsCollection',
subpath: 'codecs',
portable: false,
blockedBy: 'CodecsCollection#resolveTypes (protected)',
},
// The three dialects all embed a CodecsCollection, so they inherit its leak.
{ name: 'PgDialect', subpath: 'pg-core', portable: false, blockedBy: 'CodecsCollection#resolveTypes (protected)' },
{
name: 'MySqlDialect',
subpath: 'mysql-core',
portable: false,
blockedBy: 'CodecsCollection#resolveTypes (protected)',
},
{
name: 'SQLiteDialect',
subpath: 'sqlite-core',
portable: false,
blockedBy: 'CodecsCollection#resolveTypes (protected)',
},
// Only pg-core exposes `toBuilder()` in a public return type, which is why the
// identical `foreignKeyConfigs` field on the other dialects' builders is unreachable
// and their table/column types above are portable.
{
name: 'PgTable',
subpath: 'pg-core',
portable: false,
blockedBy: 'PgColumnBuilder#foreignKeyConfigs (private), via PgColumn#toBuilder',
},
{
name: 'PgColumn',
subpath: 'pg-core',
portable: false,
blockedBy: 'PgColumnBuilder#foreignKeyConfigs (private), via PgColumn#toBuilder',
},
{
name: 'NodePgDatabase',
subpath: 'node-postgres/driver',
typeArgs: '<Record<string, never>>',
portable: false,
blockedBy: 'PgAsyncPreparedQuery#executor (protected)',
},
];

// Unpacks the built tarball's declarations twice, under two different package names,
// so the compiler sees two independent installs of the same types.
function materializeTwoInstalls(outDir: string): void {
const sourcePrefix = `/node_modules/${pkg.packageName}/`;
for (const packageName of ['drizzle-a', 'drizzle-b']) {
for (const file of pkg.listFiles(sourcePrefix)) {
if (file !== `${sourcePrefix}package.json` && !file.endsWith('.d.ts')) continue;

const destination = join(outDir, 'node_modules', packageName, file.slice(sourcePrefix.length));
mkdirSync(dirname(destination), { recursive: true });
const contents = pkg.readFile(file);
writeFileSync(
destination,
file === `${sourcePrefix}package.json`
? JSON.stringify({ ...JSON.parse(contents), name: packageName })
: contents,
);
}
}
}

describe.skipIf(!ARTIFACTS_PRESENT)('types are portable across package instances', () => {
test('the published surface matches the recorded portability matrix', () => {
const outDir = mkdtempSync(join(tmpdir(), 'drizzle-duplicate-types-'));
try {
materializeTwoInstalls(outDir);

// One program covering every probe: each assignment gets its own line so a
// diagnostic's line number identifies which type failed.
const lines: string[] = [];
const lineToName = new Map<number, string>();
for (const { name, subpath } of PORTABILITY_MATRIX) {
lines.push(`import type { ${name} as ${name}_A } from 'drizzle-a/${subpath}';`);
lines.push(`import type { ${name} as ${name}_B } from 'drizzle-b/${subpath}';`);
}
for (const { name, typeArgs = '' } of PORTABILITY_MATRIX) {
lines.push(`declare const v_${name}: ${name}_B${typeArgs};`);
lineToName.set(lines.length + 1, name);
lines.push(`export const c_${name}: ${name}_A${typeArgs} = v_${name};`);
}

const entrypoint = join(outDir, 'index.mts');
writeFileSync(entrypoint, lines.join('\n') + '\n');

const program = ts.createProgram({
rootNames: [entrypoint],
options: {
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
noEmit: true,
skipLibCheck: true,
strict: true,
target: ts.ScriptTarget.ESNext,
},
});

const failed = new Map<string, string>();
const unattributed: string[] = [];
for (const diagnostic of ts.getPreEmitDiagnostics(program)) {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ');
if (!diagnostic.file || diagnostic.start === undefined) {
unattributed.push(message);
continue;
}
const line = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1;
const name = lineToName.get(line);
// A diagnostic on a non-assignment line means the probe itself is malformed
// (bad subpath, wrong type arity) rather than a portability result.
if (name === undefined) {
unattributed.push(`line ${line}: ${message}`);
continue;
}
if (!failed.has(name)) failed.set(name, message);
}

expect(unattributed, `malformed probes:\n${unattributed.join('\n')}`).toEqual([]);

const actual = PORTABILITY_MATRIX.filter((p) => !failed.has(p.name)).map((p) => p.name).sort();
const expected = PORTABILITY_MATRIX.filter((p) => p.portable).map((p) => p.name).sort();

const regressed = expected.filter((n) => !actual.includes(n));
const fixed = actual.filter((n) => !expected.includes(n));
const hint = [
...regressed.map((n) => `REGRESSED ${n} is no longer portable: ${failed.get(n)}`),
...fixed.map((n) => `FIXED ${n} is now portable -- set portable: true in PORTABILITY_MATRIX`),
].join('\n');

expect(actual, hint).toEqual(expected);
} finally {
rmSync(outDir, { recursive: true, force: true });
}
});
});

describe('directory-index shim emitter refuses to shadow a source artifact', () => {
test('throws a path-named error on a synthetic collision', async () => {
const outDir = mkdtempSync(join(tmpdir(), 'shim-guard-'));
Expand Down