Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
dialect:
- postgres
- cockroachdb
- mysql
- mariadb
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
},
"dependencies": {
"@testcontainers/cockroachdb": "^11.11.0",
"@testcontainers/mariadb": "^11.11.0",
"@testcontainers/mysql": "^11.11.0",
"@testcontainers/postgresql": "^11.11.0",
"better-sqlite3": "^12.4.1",
"c12": "^3.3.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const indexSchema = z.object({
});
export type IndexSchema = z.infer<typeof indexSchema>;

const dialectEnum = z.enum(["postgres", "cockroachdb"]);
const dialectEnum = z.enum(["postgres", "cockroachdb", "mysql", "mariadb"]);
export type DialectEnum = z.infer<typeof dialectEnum>;

const databaseSchema = z.object({
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/dialect/factory.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { KyrageDialect } from "./types";
import { PostgresKyrageDialect } from "./postgres";
import { CockroachDBKyrageDialect } from "./cockroachdb";
import { MysqlKyrageDialect } from "./mysql";
import { MariadbKyrageDialect } from "./mariadb";
import { DialectEnum } from "../config/loader";

const dialects = {
postgres: new PostgresKyrageDialect(),
cockroachdb: new CockroachDBKyrageDialect(),
mysql: new MysqlKyrageDialect(),
mariadb: new MariadbKyrageDialect(),
} as const;

export const getDialect = (dialectName: DialectEnum) => {
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/dialect/mariadb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { MysqlDialect } from "kysely";
import { createPool } from "mysql2";
import { MariaDbContainer } from "@testcontainers/mariadb";
import { KyrageDialect } from "./types";
import { DBClient } from "../client";
import { convertMysqlTypeName, doMysqlIntrospect } from "./mysql";
import {
buildContainerDevDatabaseConfigSchema,
ContainerDevDatabaseProvider,
hasRunningDevStartContainer,
} from "../dev/providers/container";

/**
* MariaDB dialect that reuses MySQL implementation.
* MariaDB is MySQL-compatible, so we use the same Kysely dialect
* and introspection logic as MySQL.
*/
export class MariadbKyrageDialect implements KyrageDialect {
getName() {
return "mariadb" as const;
}

createKyselyDialect(connectionString: string) {
return new MysqlDialect({
pool: createPool(connectionString),
});
}

createIntrospectionDriver(client: DBClient) {
return {
convertTypeName: convertMysqlTypeName,
introspect: doMysqlIntrospect(client),
};
}

createDevDatabaseProvider() {
return new ContainerDevDatabaseProvider(
this.getName(),
(image) => new MariaDbContainer(image)
);
}

parseDevDatabaseConfig(config: unknown) {
return buildContainerDevDatabaseConfigSchema({
defaultImage: "mariadb:11",
}).parse(config);
}

async hasReusableDevDatabase(): Promise<boolean> {
return hasRunningDevStartContainer(this.getName());
}
}
243 changes: 243 additions & 0 deletions packages/cli/src/dialect/mysql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { MysqlDialect, sql } from "kysely";
import { createPool } from "mysql2";
import { MySqlContainer } from "@testcontainers/mysql";
import { IntrospectProps, KyrageDialect } from "./types";
import { DBClient, PlannableKysely } from "../client";
import { ReferentialActions } from "../operations/shared/types";
import { computeAutoGeneratedIndexesAndConstraints } from "./shared";
import {
buildContainerDevDatabaseConfigSchema,
ContainerDevDatabaseProvider,
hasRunningDevStartContainer,
} from "../dev/providers/container";

export class MysqlKyrageDialect implements KyrageDialect {
getName() {
return "mysql" as const;
}

createKyselyDialect(connectionString: string) {
return new MysqlDialect({
pool: createPool(connectionString),
});
}

createIntrospectionDriver(client: DBClient) {
return {
convertTypeName: convertMysqlTypeName,
introspect: doMysqlIntrospect(client),
};
}

createDevDatabaseProvider() {
return new ContainerDevDatabaseProvider(
this.getName(),
(image) => new MySqlContainer(image)
);
}

parseDevDatabaseConfig(config: unknown) {
return buildContainerDevDatabaseConfigSchema({
defaultImage: "mysql:8",
}).parse(config);
}

async hasReusableDevDatabase(): Promise<boolean> {
return hasRunningDevStartContainer(this.getName());
}
}

export const doMysqlIntrospect =
(client: DBClient) => async (props: IntrospectProps) => {
await using db = client.getDB();
const [tables, indexes, constraints] = await Promise.all([
introspectMysqlTables(db),
introspectMysqlIndexes(db),
introspectMysqlConstraints(db),
]);
const {
indexes: normalizedIndexes,
uniqueConstraints: normalizedUniqueConstraints,
} = computeAutoGeneratedIndexesAndConstraints(props.config, {
indexes,
constraints,
});

return {
tables,
indexes: normalizedIndexes,
constraints: {
...constraints,
unique: normalizedUniqueConstraints,
},
};
};

/*
* Convert MySQL type names to more general SQL type names.
*/
export const convertMysqlTypeName = (typeName: string) => {
const nameDict = {
tinyint: "boolean",
int: "integer",
};

return nameDict[typeName as keyof typeof nameDict] ?? typeName;
};

export const introspectMysqlTables = async (db: PlannableKysely) => {
const { rows } = await sql`
SELECT
TABLE_SCHEMA as table_schema,
TABLE_NAME as table_name,
COLUMN_NAME as column_name,
COLUMN_DEFAULT as column_default,
CHARACTER_MAXIMUM_LENGTH as character_maximum_length
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
ORDER BY TABLE_NAME, ORDINAL_POSITION;
`
.$castTo<MysqlColumnInfo>()
.execute(db);

return rows.map((row) => ({
schema: row.table_schema,
table: row.table_name,
name: row.column_name,
default: row.column_default,
characterMaximumLength: row.character_maximum_length
? Number(row.character_maximum_length)
: null,
}));
};

type MysqlColumnInfo = {
table_schema: string;
table_name: string;
column_name: string;
column_default: string | null;
character_maximum_length: number | null;
};

export const introspectMysqlIndexes = async (db: PlannableKysely) => {
const { rows } = await sql`
SELECT
s.TABLE_NAME as table_name,
s.INDEX_NAME as index_name,
s.NON_UNIQUE = 0 as is_unique,
GROUP_CONCAT(s.COLUMN_NAME ORDER BY s.SEQ_IN_INDEX) as column_names
FROM information_schema.STATISTICS s
WHERE s.TABLE_SCHEMA = DATABASE()
AND s.INDEX_NAME != 'PRIMARY'
GROUP BY s.TABLE_NAME, s.INDEX_NAME, s.NON_UNIQUE;
`
.$castTo<MysqlIndexInfo>()
.execute(db);
return rows.map((r) => ({
table: r.table_name,
name: r.index_name,
columns: (r.column_names as unknown as string).split(','),
unique: Boolean(r.is_unique),
}));
};

type MysqlIndexInfo = {
table_name: string;
index_name: string;
is_unique: number;
column_names: ReadonlyArray<string>;
};

export const introspectMysqlConstraints = async (db: PlannableKysely) => {
const { rows } = await sql`
SELECT
tc.TABLE_SCHEMA as schema_name,
tc.TABLE_NAME as table_name,
tc.CONSTRAINT_NAME as constraint_name,
tc.CONSTRAINT_TYPE as constraint_type,
GROUP_CONCAT(kcu.COLUMN_NAME ORDER BY kcu.ORDINAL_POSITION) as columns,
-- Foreign Key specific information
kcu.REFERENCED_TABLE_NAME as referenced_table,
rc.UPDATE_RULE as on_update,
rc.DELETE_RULE as on_delete,
GROUP_CONCAT(kcu.REFERENCED_COLUMN_NAME ORDER BY kcu.ORDINAL_POSITION) as referenced_columns
FROM information_schema.TABLE_CONSTRAINTS tc
JOIN information_schema.KEY_COLUMN_USAGE kcu
ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA
AND tc.TABLE_NAME = kcu.TABLE_NAME
LEFT JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
ON tc.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
AND tc.TABLE_SCHEMA = rc.CONSTRAINT_SCHEMA
WHERE tc.TABLE_SCHEMA = DATABASE()
AND tc.CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE', 'FOREIGN KEY')
GROUP BY tc.TABLE_SCHEMA, tc.TABLE_NAME, tc.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE,
kcu.REFERENCED_TABLE_NAME, rc.UPDATE_RULE, rc.DELETE_RULE
ORDER BY tc.TABLE_NAME, tc.CONSTRAINT_NAME;
`
.$castTo<MysqlConstraint & { referenced_columns: string | null }>()
.execute(db);

return {
primaryKey: rows
.filter((row) => row.constraint_type === "PRIMARY KEY")
.map((row) => ({
schema: row.schema_name,
table: row.table_name,
name: row.constraint_name,
type: "PRIMARY KEY" as const,
columns: (row.columns as unknown as string).split(','),
})),
unique: rows
.filter((row) => row.constraint_type === "UNIQUE")
.map((row) => ({
schema: row.schema_name,
table: row.table_name,
name: row.constraint_name,
type: "UNIQUE" as const,
columns: (row.columns as unknown as string).split(','),
})),
foreignKey: rows
.filter((row) => row.constraint_type === "FOREIGN KEY")
.map((row) => ({
schema: row.schema_name,
table: row.table_name,
name: row.constraint_name,
type: "FOREIGN KEY" as const,
columns: (row.columns as unknown as string).split(','),
referencedTable: row.referenced_table!,
referencedColumns: row.referenced_columns
? row.referenced_columns.split(',')
: [],
onDelete: convertMysqlReferentialAction(row.on_delete),
onUpdate: convertMysqlReferentialAction(row.on_update),
})),
};
};

type MysqlConstraint = {
schema_name: string;
table_name: string;
constraint_name: string;
constraint_type: "PRIMARY KEY" | "UNIQUE" | "FOREIGN KEY";
columns: ReadonlyArray<string>;
referenced_table: string | null;
on_delete: string | null;
on_update: string | null;
};

export const convertMysqlReferentialAction = (
action: string | null
): ReferentialActions | undefined => {
if (!action) return undefined;

const actionMap: Record<string, ReferentialActions> = {
CASCADE: "cascade",
"SET NULL": "set null",
"SET DEFAULT": "set default",
RESTRICT: "restrict",
"NO ACTION": "no action",
};

return actionMap[action];
};
3 changes: 2 additions & 1 deletion packages/cli/src/dialect/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const convertPSQLTypeName = (typeName: string) => {
int2: "smallint",
int4: "integer",
int8: "bigint",
bpchar: "char",
};

return nameDict[typeName as keyof typeof nameDict] ?? typeName;
Expand All @@ -95,7 +96,7 @@ export const introspectPSQLTables = async (db: PlannableKysely) => {
a.attname AS column_name,
pg_get_expr(d.adbin, d.adrelid) AS column_default,
CASE
WHEN t.typname = 'varchar' OR t.typname = 'char' THEN a.atttypmod - 4
WHEN t.typname = 'varchar' OR t.typname = 'bpchar' THEN a.atttypmod - 4
ELSE NULL
END AS character_maximum_length
FROM pg_class c
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/introspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,19 @@ export const getIntrospector = (client: DBClient) => {
continue;
}

const convertedType = extIntrospectorDriver.convertTypeName(column.dataType);
// Reconstruct type with length for char and varchar types
let dataType = convertedType;
if (extraInfo.characterMaximumLength !== null &&
(convertedType === "char" || convertedType === "varchar")) {
dataType = `${convertedType}(${extraInfo.characterMaximumLength})`;
}

columns[column.name] = {
schema: table.schema,
table: table.name,
name: column.name,
dataType: extIntrospectorDriver.convertTypeName(column.dataType),
dataType,
default: extraInfo.default ?? null,
characterMaximumLength: extraInfo.characterMaximumLength ?? null,
notNull: !column.isNullable,
Expand Down
Loading
Loading