Add Oracle & MSSQL Support

This commit is contained in:
KarimTamani
2026-06-10 18:09:54 +01:00
parent 3985e53886
commit 6c3b431ee2
45 changed files with 7351 additions and 4012 deletions
@@ -0,0 +1,389 @@
import { DatabaseDialect } from "@/lib/database";
import { DataTypes, ForeignKeyActions, Modifiers, MYSQL_MAX_VAR_LENGTH, TimeDefaultValues } from "@/lib/field";
import { DataType } from "@/lib/schemas/data-type-schema";
import { DatabaseType } from "@/lib/schemas/database-schema";
import { FieldType } from "@/lib/schemas/field-schema";
import { IndexType } from "@/lib/schemas/index-schema";
import { Cardinality, RelationshipType } from "@/lib/schemas/relationship-schema";
import { TableType } from "@/lib/schemas/table-schema";
import { DBDiffOperation, mapDiffToDBDiffOperation, normalizeDatabase } from "@/utils/database";
import { optimizeOps, prepareForMigration } from "@/utils/migration";
import { compare } from "fast-json-patch";
import { AST } from "node-sql-parser";
import { Statement } from "pgsql-ast-parser";
import { getPostgresEnumName } from "../render-uttils";
import { emptyDb } from "@/lib/database";
export default abstract class BaseDatabaseRenderer {
protected dialect: DatabaseDialect;
protected data_types: DataType[];
protected schema?: DatabaseType | undefined;
protected parser: any;
protected readyPromise: Promise<void>;
public constructor(dialect: DatabaseDialect, data_types: DataType[]) {
this.dialect = dialect;
this.data_types = data_types;
this.readyPromise = import("node-sql-parser").then((sqlParser) => {
this.parser = new sqlParser.Parser();
})
}
public getDialect(): DatabaseDialect {
return this.dialect;
}
public async renderDDL(database: DatabaseType): Promise<string> {
const previousDatabase = emptyDb(database);
// we prepare both databases by enriching them and removing some UI elements that can interfear with json fast path
const preparedCurrentDatabase = prepareForMigration(database);
const preparedPreviousDatabse = prepareForMigration(previousDatabase);
// notmalize both just ignore array elements order diff
const normalizedPreviousDatabase = normalizeDatabase(preparedPreviousDatabse);
const normalizedCurrentDatabase = normalizeDatabase(preparedCurrentDatabase);
// perform a comparison to extract the diff between the previous database and the current one .
const diff_json = compare(normalizedPreviousDatabase, normalizedCurrentDatabase);
// map the diff to DB Diff operations to facilate the rendering
// optimize the operations by enriching each operation
let operations: DBDiffOperation[] = mapDiffToDBDiffOperation(diff_json)
operations = optimizeOps(operations, preparedCurrentDatabase, preparedPreviousDatabse, this.data_types);
// extract the ast based on the dialect
const ast: ASTStatment[] = this.operationsToAst(operations, database);
// render ast from sql
const sql = await this.astToSQL(ast);
return sql;
}
protected operationsToAst(operations: DBDiffOperation[], database?: DatabaseType): ASTStatment[] {
const ast: ASTStatment[] = [];
// we basiclly loop over all operations , and turn them into ast .
// some operation can return multiple statments .
// so for that we need to check our input if its one AST or an object AST
for (const operation of operations) {
const statmentAst = this.operationToAst(operation);
if (!statmentAst)
continue;
else if (Array.isArray(statmentAst))
ast.push(...statmentAst);
else
ast.push(statmentAst);
}
return ast;
}
protected operationToAst(operation: DBDiffOperation): ASTStatment | ASTStatment[] | null {
switch (operation.type) {
case "CREATE_TABLE":
return this.createTableAst(
operation.table
);
case "CREATE_RELATIONSHIP":
if (operation.relationship.cardinality == Cardinality.many_to_many)
break;
return this.createRelationshipAst(
operation.relationship,
);
}
return null;
}
protected abstract astToSQL(ast: ASTStatment[]): Promise<string>;
protected createTableAst(table: TableType): ASTStatment[] | ASTStatment {
let indices_ast: ASTStatment[] = [];
// get primary keys and check if we hame more than one
const primaryKeys: FieldType[] = table.fields.filter((field: FieldType) => field.isPrimary);
const multiPrimaryKeys: boolean = primaryKeys.length > 1;
// check if we have multiple primary keys if so we need to create a contraint
const constraints: ASTStatment[] = multiPrimaryKeys ? [this.getPrimaryKeyContraint(primaryKeys)] : [];
// get field dification
const field_definitions = table.fields.map((field: FieldType) => this.getFieldDefinition(field, table, multiPrimaryKeys))
// the base table ast contain only the field dification and the contraints , then child renderer turn it to ast
// check if the table have indices
if (table.indices && table.indices.length > 0) {
// if so loop over it indices and ignore the index with no columns since that will cause an SQl Syntax error .
// finally get the ast of the index and push it to the indices_ast
for (const index of table.indices) {
if (index.fieldIndices.length == 0)
continue;
indices_ast.push(this.createIndexAst(
table,
index
))
}
}
return {
field_definitions,
constraints,
indices_ast
} as any;
}
protected updateFieldAst(table: TableType, field: FieldType, changes: FieldType): ASTStatment | ASTStatment[] {
// get the previous type
let type = field.type;
const ast: any = {
length: null,
scale: null,
dataType: null,
auto_increment: null,
}
// if there is changes in the type , max length , scale or precision or auto incrrement .
// in this case SQL Database treat this as a type changes
if (changes.typeId || changes.maxLength || changes.scale || changes.precision || changes.autoIncrement !== undefined || changes.values) {
// if the type changes , get the new type
if (changes.typeId)
type = changes.type;
// get the new or the old type name
ast.dataType = (this.dialect == DatabaseDialect.POSTGRES && type.name == "enum") ? getPostgresEnumName(table, field) : type.name?.toLocaleUpperCase();
// get the new or old type modifiers
const modifiers: string[] = type.modifiers ? JSON.parse(type.modifiers) : [];
// if the type support length as VARCHAR(length) and we either have an old max length , or new max length , then set it in the ast
if (modifiers.includes(Modifiers.LENGTH) && (changes.maxLength || field.maxLength)) {
if (changes.maxLength)
ast.length = changes.maxLength
else
ast.length = field.maxLength;
}
// if the field support precision and we either have a previous precision or the user add new one .
// then set the precision in the AST .
if (modifiers.includes(Modifiers.PRECISION) && (changes.precision || field.precision)) {
if (changes.precision) {
ast.length = changes.precision;
} else {
ast.length = field.precision;
}
if (modifiers.includes(Modifiers.SCALE) && !(changes.scale || field.scale)) {
// if the scale is not set , we have to set it as O
ast.scale = "0";
}
}
// if the type support scale , and scale is set then add it to the ast
if (modifiers.includes(Modifiers.SCALE) && (changes.scale || field.scale)) {
if (changes.scale) {
ast.scale = changes.scale;
} else {
ast.scale = field.scale;
}
}
// okay if the field is auto increment in postgres we need to switch the type ro serial type .
if (modifiers.includes(Modifiers.AUTO_INCREMENT) && (field.autoIncrement || changes.autoIncrement) && this.dialect == DatabaseDialect.POSTGRES) {
if (changes.autoIncrement !== false) {
if (ast.dataType == "INTEGER")
ast.dataType = "SERIAL";
else
ast.dataType = ast.dataType.replace("INT", "SERIAL");
}
}
// if there changes in the values
if (modifiers.includes(Modifiers.VALUES) && changes.values && this.dialect != DatabaseDialect.POSTGRES) {
try {
ast.values = JSON.parse(changes.values);
} catch (error) {
ast.value = [];
}
}
}
return ast as AST;
}
protected createRelationshipAst(relationship: RelationshipType): ASTStatment {
let primaryKey: FieldType = relationship.sourceField;
let foreignKey: FieldType = relationship.targetField;
let sourceTable: TableType = relationship.sourceTable;
let targetTable: TableType = relationship.targetTable;
if (relationship.cardinality == Cardinality.many_to_one) {
primaryKey = relationship.targetField;
foreignKey = relationship.sourceField;
sourceTable = relationship.targetTable;
targetTable = relationship.sourceTable;
}
return {
primaryKey,
foreignKey,
sourceTable,
targetTable
} as any
}
protected abstract createIndexAst(table: TableType, index: IndexType): ASTStatment;
protected abstract getPrimaryKeyContraint(field: FieldType[], table?: TableType): ASTStatment;
protected abstract startTransaction(): string;
protected abstract commit(): string;
protected getFieldDefinition(field: FieldType, table: TableType, ignorePkContraint: boolean): ASTStatment {
// get the modifiers based on the field data type
const modifiers: string[] = field?.type?.modifiers ? JSON.parse(field?.type.modifiers) : [];
const ast: any = {
length: null,
scale: null,
default_value: null,
dataType: null,
values: null,
default_value_type: null,
auto_increment: null,
primary_key: ignorePkContraint ? false : field.isPrimary,
modifiers: null
}
// data types by the defaut the field data type name , except for Postgres types needs to be handled diffrenet
ast.dataType = field.type?.name?.toLocaleUpperCase();
// if the field support PRECISION & SCALE modifiers set the length to precision
if (modifiers.includes(Modifiers.PRECISION) && field.precision) {
ast.length = field.precision;
if (modifiers.includes(Modifiers.SCALE) && !field.scale)
ast.scale = "0";
}
// if the field support sclae and sclae is set then set the scale
if (modifiers.includes(Modifiers.SCALE) && field.scale)
ast.scale = field.scale;
// the field support dynamic length , set the length ast
// but in case the max length is not set, and the dielect (Mysql , MariaDb ) require a length , then set the default length
if (modifiers.includes(Modifiers.LENGTH) && field.maxLength)
ast.length = field.maxLength;
else if (
modifiers.includes(Modifiers.LENGTH) &&
(field.type.dialect == DatabaseDialect.MYSQL || field.type.dialect == DatabaseDialect.MARIADB) &&
field.type.name?.startsWith('var') &&
!field.maxLength
)
ast.length = MYSQL_MAX_VAR_LENGTH;
// if the field is an enum type just parse the enum values
if (modifiers.includes(Modifiers.VALUES) && field.values) {
try {
ast.values = JSON.parse(field.values);
} catch (error) {
ast.value = [];
}
}
const default_value_defintition = this.processDefaultValue(field) as any
if (default_value_defintition) {
const { default_value_type, default_value } = default_value_defintition;
ast.default_value_type = default_value_type;
ast.default_value = default_value;
}
// if the field support auto increment and if the field it is , then set aut increment ast to true .
// but if the dialect is Postgres , then we have to change the type from INTEGERS to SERIALS
if (modifiers.includes(Modifiers.AUTO_INCREMENT) && field.autoIncrement && field.type.dialect != DatabaseDialect.POSTGRES) {
ast.auto_increment = true
} else if (modifiers.includes(Modifiers.AUTO_INCREMENT) && field.autoIncrement && field.type.dialect == DatabaseDialect.POSTGRES) {
ast.auto_increment = true;
if (ast.dataType == "INTEGER")
ast.dataType = "SERIAL";
else
ast.dataType = ast.dataType.replace("INT", "SERIAL");
} else {
ast.auto_increment = false;
}
ast.modifiers = modifiers;
return ast;
}
protected processDefaultValue(field: FieldType): AST | null {
// if the field have a default value then we need to proccess it based on it type
if (field.defaultValue && field.defaultValue.trim().length > 0) {
const ast: any = {
default_value_type: null,
default_value: null
}
if ((field.type.type == DataTypes.INTEGER || field.type.type == DataTypes.NUMERIC) && !isNaN(Number(field.defaultValue))) {
ast.default_value_type = "number"
if (field.type.type == DataTypes.NUMERIC)
ast.default_value = parseFloat(field.defaultValue);
else
ast.default_value = parseInt(field.defaultValue);
}
else if (field.type.type == DataTypes.TEXT || field.type.type == DataTypes.ENUM) {
if (field.type.type == DataTypes.ENUM) {
const values = field.values ? JSON.parse(field.values) : [];
if (field.type.name == "set") {
ast.default_value_type = "single_quote_string";
ast.default_value = field.defaultValue;
}
else if (values.includes(field.defaultValue)) {
ast.default_value_type = "single_quote_string";
ast.default_value = field.defaultValue;
}
}
else {
ast.default_value_type = "single_quote_string";
ast.default_value = field.defaultValue;
}
}
// if we have a field of type time and the default value is Now , then the default value is the fucntion CURRENT_TIME or NOW()
else if (field.type.type == DataTypes.TIME && field.defaultValue == TimeDefaultValues.NOW) {
ast.default_value_type = "function";
ast.default_value = "CURRENT_TIMESTAMP";
}
else if ((field.type.name == "uuid" || field.type.name == "uniqueidentifier") && field.defaultValue == "random") {
ast.default_value_type = "function";
ast.default_value = "random";
}
else if (field.type.type == DataTypes.TIME) {
ast.default_value_type = "single_quote_string";
ast.default_value = field.defaultValue;
}
// the field have a boolean value
else if (field.defaultValue == "true" || field.defaultValue == "false") {
ast.default_value_type = "bool"
ast.default_value = field.defaultValue == "true" ? true : false;
}
return ast;
}
return null;
}
}
export interface DBRenderOutput {
schema: DatabaseType,
diff_json: any[],
sql: string;
operations: DBDiffOperation[];
}
export type ASTStatment = AST | Statement | string | null
@@ -0,0 +1,306 @@
import { DatabaseDialect, getDatabaseByDialect } from "@/lib/database";
import BaseDatabaseRenderer, { ASTStatment } from "./base-database-renderer";
import { AST } from "node-sql-parser";
import { format } from "sql-formatter";
import { TableType } from "@/lib/schemas/table-schema";
import { FieldType } from "@/lib/schemas/field-schema";
import { ForeignKeyActions, Modifiers, TimeDefaultValues } from "@/lib/field";
import { Cardinality, RelationshipType } from "@/lib/schemas/relationship-schema";
import { IndexType } from "@/lib/schemas/index-schema";
import { DataType } from "@/lib/schemas/data-type-schema";
export abstract class BaseSQLRenderer extends BaseDatabaseRenderer {
protected async astToSQL(ast: ASTStatment[]): Promise<string> {
// in the base SQL Render we use node-sql-parser .
// by simly passing the AST and formating the code .
// other renderers can override this method to handle the parsing based on the dielact
await this.readyPromise ;
let sql: string = this.parser.sqlify(ast as AST[], {
database: getDatabaseByDialect(this.dialect).name
});
return format(sql, { language: 'sql' });
}
protected createTableAst(table: TableType): ASTStatment[] | ASTStatment {
// get the tale defintion from parent render that contain only the field defintion and primary keys contraints
// this definition is commun amount (Mysql , MariaDB , Sqlite) , for postgres we need to override it and handle enums
const { field_definitions, constraints, indices_ast } = super.createTableAst(table) as any
// wrape those defintion with create table AST
return [{
keyword: "table",
type: "create",
table: [{
table: table.name
}],
create_definitions: [...field_definitions, ...constraints]
}, ...indices_ast] as any
}
protected getFieldDefinition(field: FieldType, table: TableType, ignorePkContraint: boolean): AST {
// we get the base field definition from the parent renderer
const definition: any = super.getFieldDefinition(field, table, ignorePkContraint)
// Mysql , MariaDB ,Have some additional attributes such as charset collation , the enum values are inline unlike Postgres , Unsigned , and ZERO FILL
// also we need default value expression that it is compatible with node-sql-parser
let valuesExpr: any | null;
// if the definition extracted values os this field is an enum .
// and for that we generate the values expression that's compatible with out parser
if (definition.values && definition.values.length > 0) {
const values = definition.values.map((value: string) => ({
type: "single_quote_string",
value
}));
valuesExpr = {
parentheses: true,
type: "expr_list",
value: values
}
}
const default_val = !definition.modifiers.includes(Modifiers.NO_DEFAULT) ? this.processDefaultValue(field) : undefined;
const unique: string | null = !definition.modifiers.includes(Modifiers.NO_UNIQUE) ? (field.unique ? "unique" : null) : null;
// return an AST statment for a field definition
return {
column: {
type: "column_ref",
column: {
expr: {
type: "default", value: field.name,
}
},
},
default_val,
unique,
auto_increment: definition.auto_increment ? "auto_increment" : undefined,
nullable: {
type: field.nullable ? "null" : "not null",
value: field.nullable ? "null" : "not null",
},
definition: {
dataType: definition.dataType,
length: definition.length,
scale: definition.scale,
expr: valuesExpr
},
primary_key: definition.primary_key ? "primary key" : null,
resource: "column"
} as any
}
protected createRelationshipAst(relationship: RelationshipType): ASTStatment {
// get the foriegn key , primary key source table and target table from the parent renderer
const { primaryKey, foreignKey, sourceTable, targetTable } = super.createRelationshipAst(relationship) as any;
let on_action: any[] = [];
// get the foriegn key actions for on delete and on cascade
if (relationship.onDelete) {
const value: string | null = this.foreignKeyActionToAst(relationship.onDelete as ForeignKeyActions);
if (value)
on_action.push({
type: "on delete",
value: {
type: "origin",
value
}
})
}
if (relationship.onUpdate) {
const value: string | null = this.foreignKeyActionToAst(relationship.onUpdate as ForeignKeyActions);
if (value)
on_action.push({
type: "on update",
value: {
type: "origin",
value
}
})
}
// return create foriegn key contraint for Mysql Dialect
return {
type: "alter",
keyword: "table",
table: [
{
table: targetTable.name
}
],
expr: [{
action: "add",
create_definitions: {
constraint: relationship.name,
definition: [
{
type: "column_ref",
table: null,
column: {
expr: {
type: "default",
value: foreignKey.name
}
},
}
],
constraint_type: "FOREIGN KEY",
keyword: "constraint",
resource: "constraint",
reference_definition: {
definition: [
{
type: "column_ref",
table: null,
column: {
expr: {
type: "default",
value: primaryKey.name
}
},
}
],
table: [
{
db: null,
table: sourceTable.name
}
],
keyword: "references",
on_action
}
},
resource: "constraint",
type: "alter"
}]
} as any
}
protected foreignKeyActionToAst(action: ForeignKeyActions): string | null {
switch (action) {
case ForeignKeyActions.CASCADE:
return "cascade";
case ForeignKeyActions.SET_NULL:
return "set null";
case ForeignKeyActions.RESTRICT:
return "restrict";
case ForeignKeyActions.SET_DEFAULT:
return "set default";
}
return null;
}
protected getPrimaryKeyContraint(primaryKeys: FieldType[]): AST {
// primary key contraint
return {
constraint_type: "primary key",
resource: "constraint",
definition: primaryKeys.map((field: FieldType) => ({
column: field.name,
type: "column_ref"
}))
} as any
}
protected createIndexAst(table: TableType, index: IndexType): AST {
// create index AST for Mysql Dielect
return {
index: index.name,
type: "create",
table: { table: table.name },
keyword: "index",
on_kw: "on",
index_type: index.unique ? "unique" : null,
index_columns: index.fields.map((field: FieldType) => ({
column: field.name,
type: "column_ref"
}))
} as AST
}
protected processDefaultValue(field: FieldType): AST | null {
const definition = super.processDefaultValue(field) as any;
// if the field have a default value , then we generate an expression based on the type of the default value
if (definition && definition.default_value !== null) {
let default_val: any | null = null
if (definition.default_value_type == "string")
default_val = {
type: "default",
value: {
type: "single_quote_string",
value: field.defaultValue
}
}
else if (definition.default_value_type == "function" && field.defaultValue == TimeDefaultValues.NOW)
default_val = {
type: "default",
value: {
type: "function",
name: {
name: [
{
type: "origin",
value: "CURRENT_TIMESTAMP"
}
]
},
over: null
}
}
else if (definition.default_value_type == "function" && field.defaultValue == "random") {
default_val = {
type: "default",
value: {
type: "function",
name: {
name: [
{
type: "default",
value: this.dialect == DatabaseDialect.POSTGRES ? "gen_random_uuid" : "UUID"
}
]
},
args: {
type: "expr_list",
value: []
},
parentheses: this.dialect == DatabaseDialect.MARIADB
}
}
}
else {
default_val = {
type: "default",
value: {
type: definition.default_value_type,
value: definition.default_value
}
}
}
return default_val;
}
return null;
}
protected startTransaction(): string {
return "BEGIN TRANSACTION;" ;
}
protected commit(): string {
return "COMMIT;"
}
protected abstract getUsingAst(table: TableType, field: FieldType, dataType: DataType): AST | null;
}
@@ -0,0 +1,10 @@
import { DataType } from "@/lib/schemas/data-type-schema";
import MysqlRenderer from "./mysql-renderer";
import { DatabaseDialect } from "@/lib/database";
export default class MariaDbRenderer extends MysqlRenderer {
public constructor(data_types: DataType[]) {
super( data_types , DatabaseDialect.MARIADB)
}
}
@@ -0,0 +1,203 @@
import { FieldType } from "@/lib/schemas/field-schema";
import { TableType } from "@/lib/schemas/table-schema";
import BaseDatabaseRenderer, { ASTStatment } from "./base-database-renderer";
import { DEFAULT_LENGTH_PARAM, ForeignKeyActions, Modifiers } from "@/lib/field";
import { IndexType } from "@/lib/schemas/index-schema";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { DataType } from "@/lib/schemas/data-type-schema";
import { DatabaseDialect } from "@/lib/database";
import { format } from 'sql-formatter';
import { AST } from "node-sql-parser";
export default class MSSqlRenderer extends BaseDatabaseRenderer {
public constructor(data_types: DataType[]) {
super(DatabaseDialect.MSSQL, data_types)
}
protected async astToSQL(ast: ASTStatment[]): Promise<string> {
let sql = ast.join("\n");
sql = format(sql, { language: 'sql' });
if (sql.length > 0) {
sql = `${this.startTransaction()}
${sql}
${this.commit()}
`
}
return sql;
}
protected createTableAst(table: TableType): ASTStatment[] | ASTStatment {
const ast: any = super.createTableAst(table);
let indexes: string = ast.indices_ast.filter((index: string) => index != null).join("\n")
// get primary keys and check if we hame more than one
const primaryKeys: FieldType[] = table.fields.filter((field: FieldType) => field.isPrimary);
let constraints: ASTStatment = "";
if (primaryKeys.length > 0) {
constraints = this.getPrimaryKeyContraint(primaryKeys, table)
}
return `
CREATE TABLE ${table.name} (
${(ast as any).field_definitions.join(",\n")}
${constraints ? ',\n' + constraints : ""}
);
${indexes}
` ;
}
protected getFieldDefinition(field: FieldType, table: TableType, ignorePkContraint: boolean = false, dropDefaultValue: boolean = false): ASTStatment {
const ast: any = super.getFieldDefinition(field, table, ignorePkContraint);
let autoIncrement: string = ast.modifiers.includes(Modifiers.AUTO_INCREMENT) ? (ast.auto_increment ? "IDENTITY(1,1)" : "") : ""
let nullable: string = ""
let unique: string = "";
if (!ast.primary_key)
nullable = (field.nullable !== undefined) ? (field.nullable ? "NULL" : "NOT NULL") : "";
let options: number[] | string = [];
if (ast.length)
options.push(ast.length);
else if (ast.modifiers.includes(Modifiers.LENGTH))
options.push(DEFAULT_LENGTH_PARAM);
if (ast.scale && ast.scale != "0")
options.push(ast.scale);
options = options.length > 0 ? `(${options.join(",")})` : "";
let defaultValue: any = "";
if (ast.default_value !== undefined && ast.default_value !== null && !dropDefaultValue) {
defaultValue = `CONSTRAINT DF_${table.name}_${field.name} DEFAULT ${ast.default_value}`;
}
if (!ast.primary_key) {
unique = field.unique ? `CONSTRAINT UQ_${table.name}_${field.name} UNIQUE` : "";
}
const dataType = field.type.name == "uuid" ? "RAW(16)" : ast.dataType;
return `${field.name} ${dataType}${options} ${autoIncrement} ${nullable} ${defaultValue} ${unique} `;
}
protected processDefaultValue(field: FieldType): AST | null {
const ast: any = super.processDefaultValue(field);
if (ast && ast.default_value_type) {
if (ast.default_value_type == "single_quote_string") {
ast.default_value = `'${ast.default_value}'`
}
else if (ast.default_value_type == "number" || ast.default_value_type == "bool")
ast.default_value = ast.default_value;
else if (ast.default_value_type == "function") {
if (ast.default_value == "CURRENT_TIMESTAMP") {
switch (field.type.name?.toUpperCase()) {
case "DATE":
ast.default_value = "CAST(GETDATE() AS DATE)"
break;
case "DATETIME":
ast.default_value = "GETDATE()"
break;
case "SMALLDATETIME":
ast.default_value = "GETDATE()"
break;
case "DATETIME2":
ast.default_value = "SYSDATETIME()"
break;
case "DATETIMEOFFSET":
ast.default_value = "SYSDATETIMEOFFSET()"
break;
}
} else if (ast.default_value == "random") {
if (field.isPrimary)
ast.default_value = "NEWSEQUENTIALID()"
else
ast.default_value = "NEWID()"
}
}
if ( ast.default_value_type == "bool") {
if (ast.default_value) {
ast.default_value = 1 ;
}else {
ast.default_value = 0 ;
}
}
}
return ast;
}
protected createRelationshipAst(relationship: RelationshipType): ASTStatment {
const { primaryKey, foreignKey, sourceTable, targetTable } = super.createRelationshipAst(relationship) as any;
const constraintName: string = relationship.name ? " CONSTRAINT " + relationship.name : "";
const onDeleteFKAction: string | null = this.foreignKeyActionToAst(relationship.onDelete as ForeignKeyActions);
const onUpdateFKAction: string | null = this.foreignKeyActionToAst(relationship.onUpdate as ForeignKeyActions);
const onDeleteAction: string = onDeleteFKAction ? `ON DELETE ${onDeleteFKAction}` : ""
const onUpdateAction: string = onUpdateFKAction ? `ON UPDATE ${onUpdateFKAction}` : ""
const FKActions: string = [onDeleteAction, onUpdateAction].join(" ");
return `
ALTER TABLE ${targetTable.name}
ADD ${constraintName} FOREIGN KEY (${foreignKey.name})
REFERENCES ${sourceTable.name}(${primaryKey.name}) ${FKActions};
`
}
protected foreignKeyActionToAst(action: ForeignKeyActions): string | null {
switch (action) {
case ForeignKeyActions.CASCADE:
return "CASCADE";
case ForeignKeyActions.SET_NULL:
return "SET NULL";
case ForeignKeyActions.SET_DEFAULT:
return "SET DEFAULT";
}
return null;
}
protected createIndexAst(table: TableType, index: IndexType): ASTStatment {
const unique: string = index.unique ? " UNIQUE" : "";
let columns: string[] | string = index.fields.map((field: FieldType) => field.name);
if (columns.length == 0)
return null;
columns = `(${columns.join(",")})`
return `CREATE${unique} INDEX ${index.name} ON ${table.name} ${columns} ;`
}
protected getPrimaryKeyContraint(fields: FieldType[], table?: TableType): ASTStatment {
const pks: string[] = fields.map((field: FieldType) => field.name);
return `CONSTRAINT PK_${table?.name} PRIMARY KEY (${pks.join(",")})`
}
protected startTransaction(): string {
return `BEGIN TRANSACTION;`
}
protected commit(): string {
return "COMMIT;"
}
}
@@ -0,0 +1,91 @@
import { FieldType } from "@/lib/schemas/field-schema";
import { AST } from "node-sql-parser";
import { DataType } from "@/lib/schemas/data-type-schema";
import { DatabaseDialect } from "@/lib/database";
import { fixCharsetPlacement } from "../render-uttils";
import { format } from 'sql-formatter';
import { Modifiers } from "@/lib/field";
import { TableType } from "@/lib/schemas/table-schema";
import { BaseSQLRenderer } from "./base-sql-renderer";
import { IndexType } from "@/lib/schemas/index-schema";
export default class MysqlRenderer extends BaseSQLRenderer {
public constructor(data_types: DataType[], dialect: DatabaseDialect = DatabaseDialect.MYSQL) {
super(dialect, data_types)
}
protected async astToSQL(ast: AST[]): Promise<string> {
// for Mysql , MariaDB , we use node-sql-parser to turn AST to SQL ,
// the package have a bug which is CHARSET and COLLATION missplacementts
// so we patch it using fixCharsetPlacement function
// and finally we format the code
try {
let sql: string = await super.astToSQL(ast);
sql = fixCharsetPlacement(format(sql, { language: "sql" }));
sql = format(sql, { language: 'sql' });
if (sql.length > 0) {
sql = `${this.startTransaction()}
${sql};
${this.commit()}
`
}
return sql;
} catch (error) {
throw error;
}
}
protected getFieldDefinition(field: FieldType, table: TableType, ignorePkContraint: boolean): AST {
// get the modifiers based on the field data type
const modifiers: string[] = field?.type.modifiers ? JSON.parse(field?.type.modifiers) : [];
// we get the base field definition from the parent renderer
const definition: any = super.getFieldDefinition(field, table, ignorePkContraint)
// Mysql , MariaDB ,Have some additional attributes such as charset collation , the enum values are inline unlike Postgres , Unsigned , and ZERO FILL
// also we need default value expression that it is compatible with node-sql-parser
let suffix: string[] = [];
// check if the field support ZEROFILL and if it set , and the same thing for unsigned
if (modifiers.includes(Modifiers.ZEROFILL) && field.zeroFill) {
suffix.push(Modifiers.ZEROFILL.toUpperCase());
}
if (modifiers.includes(Modifiers.UNSIGNED) && field.unsigned) {
suffix.push(Modifiers.UNSIGNED.toUpperCase());
}
// if it support charset and charset is set , then generate it expression
if (modifiers.includes(Modifiers.CHARSET) && field.charset)
definition.character_set = {
type: "CHARACTER SET",
value: {
type: "default",
value: field.charset
}
}
// if it support collate and collate is set , then generate it expression
if (modifiers.includes(Modifiers.COLLATE) && field.collate)
definition.collate = {
keyword: "collate",
type: "collate",
collate: {
name: field.collate,
}
}
if (suffix.length > 0) {
definition.definition.suffix = suffix
}
return definition;
}
protected getUsingAst(table: TableType, field: FieldType, dataType: DataType): AST | null {
return null;
}
}
@@ -0,0 +1,196 @@
import { FieldType } from "@/lib/schemas/field-schema";
import { TableType } from "@/lib/schemas/table-schema";
import { AST } from "node-sql-parser";
import BaseDatabaseRenderer, { ASTStatment } from "./base-database-renderer";
import { DEFAULT_LENGTH_PARAM, ForeignKeyActions, Modifiers } from "@/lib/field";
import { IndexType } from "@/lib/schemas/index-schema";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { DataType } from "@/lib/schemas/data-type-schema";
import { DatabaseDialect } from "@/lib/database";
import { format } from 'sql-formatter';
export default class OracleRenderer extends BaseDatabaseRenderer {
public constructor(data_types: DataType[]) {
super(DatabaseDialect.ORACLE, data_types)
}
protected async astToSQL(ast: ASTStatment[]): Promise<string> {
let sql = ast.join("\n");
sql = format(sql, { language: 'sql' });
return sql;
}
protected createTableAst(table: TableType): ASTStatment[] | ASTStatment {
const ast: any = super.createTableAst(table);
let pk_constraint: string = ast.constraints?.pop();
if (pk_constraint)
pk_constraint = "," + pk_constraint
else
pk_constraint = "";
let indexes: string = ast.indices_ast.filter((index: string) => index != null).join("\n")
return `
CREATE TABLE ${table.name} (
${(ast as any).field_definitions.join(",\n")}
${pk_constraint}
);
${indexes}
` ;
}
protected getFieldDefinition(field: FieldType, table: TableType, ignorePkContraint: boolean = false, dropDefaultValue: boolean = false): ASTStatment {
const ast: any = super.getFieldDefinition(field, table, ignorePkContraint);
let primaryKey: string = ast.primary_key ? "PRIMARY KEY" : "";
let autoIncrement: string = ast.modifiers.includes(Modifiers.AUTO_INCREMENT) ? (ast.auto_increment ? "GENERATED BY DEFAULT AS IDENTITY" : "") : ""
let nullable: string = ""
let unique: string = "";
if (!ast.primary_key)
nullable = (field.nullable !== undefined) ? (field.nullable ? "NULL" : "NOT NULL") : "";
if (!ast.primary_key)
unique = field.unique ? "UNIQUE" : "";
let options: number[] | string = [];
if (ast.length)
options.push(ast.length);
else if (ast.modifiers.includes(Modifiers.LENGTH))
options.push(DEFAULT_LENGTH_PARAM);
if (ast.scale && ast.scale != "0")
options.push(ast.scale);
options = options.length > 0 ? `(${options.join(",")})` : "";
let defaultValue: any = "";
if (ast.default_value !== undefined && ast.default_value !== null) {
defaultValue = "DEFAULT " + ast.default_value
}
if (dropDefaultValue) {
defaultValue = "DEFAULT NULL"
}
let dataType = ast.dataType;
if (dataType === "TIMESTAMP WITH LOCAL TIME ZONE" && options) {
dataType = `TIMESTAMP${options} WITH LOCAL TIME ZONE`
}
else if (dataType === "TIMESTAMP WITH TIME ZONE" && options) {
dataType = `TIMESTAMP${options} WITH TIME ZONE`
}
else if (dataType === "INTERVAL DAY TO SECOND" && options) {
dataType = `INTERVAL DAY${options} TO SECOND`
}
else if (dataType === "INTERVAL YEAR TO MONTH" && options) {
dataType = `INTERVAL YEAR${options} TO MONTH`
}
else
dataType = dataType + options
//const dataType = field.type.name == "uuid" ? "RAW(16)" : ast.dataType;
return `${field.name} ${dataType} ${defaultValue} ${nullable} ${unique} ${autoIncrement} ${primaryKey}`;
}
protected processDefaultValue(field: FieldType): AST | null {
const ast: any = super.processDefaultValue(field);
if (ast && ast.default_value !== undefined && ast.default_value !== null) {
if (ast.default_value_type == "single_quote_string") {
ast.default_value = `'${ast.default_value}'`
}
else if (ast.default_value_type == "number" || ast.default_value_type == "bool")
ast.default_value = ast.default_value;
else if (ast.default_value_type == "function") {
if (ast.default_value == "CURRENT_TIMESTAMP") {
switch (field.type.name?.toUpperCase()) {
case "DATE":
ast.default_value = "SYSDATE"
break;
case "TIMESTAMP":
ast.default_value = "CURRENT_TIMESTAMP"
break;
case "TIMESTAMP WITH TIME ZONE":
ast.default_value = "SYSTIMESTAMP"
break;
case "TIMESTAMP WITH LOCAL TIME ZONE":
ast.default_value = "SYSTIMESTAMP"
break;
}
} else if (ast.default_value == "random") {
ast.default_value = "SYS_GUID()"
}
}
}
return ast;
}
protected createIndexAst(table: TableType, index: IndexType): ASTStatment {
const unique: string = index.unique ? " UNIQUE" : "";
let columns: string[] | string = index.fields.map((field: FieldType) => field.name);
if (columns.length == 0)
return null;
columns = `(${columns.join(",")})`
return `CREATE${unique} INDEX ${index.name} ON ${table.name} ${columns} ;`
}
protected createRelationshipAst(relationship: RelationshipType): ASTStatment {
const { primaryKey, foreignKey, sourceTable, targetTable } = super.createRelationshipAst(relationship) as any;
const constraintName: string = relationship.name ? " CONSTRAINT " + relationship.name : "";
const FKAction: string | null = this.foreignKeyActionToAst(relationship.onDelete as ForeignKeyActions);
const onDeleteAction: string = FKAction ? `ON DELETE ${FKAction}` : ""
return `
ALTER TABLE ${targetTable.name}
ADD ${constraintName} FOREIGN KEY (${foreignKey.name})
REFERENCES ${sourceTable.name}(${primaryKey.name}) ${onDeleteAction};
`
}
protected foreignKeyActionToAst(action: ForeignKeyActions): string | null {
switch (action) {
case ForeignKeyActions.CASCADE:
return "CASCADE";
case ForeignKeyActions.SET_NULL:
return "SET NULL";
}
return null;
}
protected getPrimaryKeyContraint(fields: FieldType[]): ASTStatment {
//throw new Error("Method not implemented.");
const pks: string[] = fields.map((field: FieldType) => field.name);
return `
PRIMARY KEY (${pks.join(",")})
`
}
protected startTransaction(): string {
throw new Error("Method not implemented.");
}
protected commit(): string {
throw new Error("Method not implemented.");
}
}
@@ -0,0 +1,157 @@
import { DataType } from "@/lib/schemas/data-type-schema";
import { DatabaseDialect, getDatabaseByDialect } from "@/lib/database";
import { FieldType } from "@/lib/schemas/field-schema";
import { TableType } from "@/lib/schemas/table-schema";
import { BaseSQLRenderer } from "./base-sql-renderer";
import { AST } from "node-sql-parser";
import { getPostgresEnumName } from "../render-uttils";
import { Cardinality, RelationshipType } from "@/lib/schemas/relationship-schema";
import { IndexType } from "@/lib/schemas/index-schema";
import { Statement, toSql } from "pgsql-ast-parser";
import { ASTStatment } from "./base-database-renderer";
import { format } from "sql-formatter";
import { DBDiffOperation } from "@/utils/database";
import { DataTypes } from "@/lib/field";
export default class PostgresqlRenderer extends BaseSQLRenderer {
public constructor(data_types: DataType[]) {
super(DatabaseDialect.POSTGRES, data_types)
}
protected async astToSQL(ast: ASTStatment[]): Promise<string> {
// postgres renderer is different , we use a different parser pgsql-ast-parser for index , enum renaming .
// those kind of operations return Statment not AST .
// so the we pass AST to the default parser and Statment to Postgres parser
await this.readyPromise ;
let sql: string[] = [];
for (const statment of ast) {
if ((statment as Statement)?.type == "alter index" || (statment as Statement)?.type == "alter enum") {
// index renaming
sql.push(toSql.statement(statment as Statement) + ";");
}
else {
const statment_sql = this.parser.sqlify(statment as AST, {
database: getDatabaseByDialect(this.dialect).name
});
sql.push(statment_sql + ";");
}
}
if (sql.length > 0) {
sql = [this.startTransaction( ) , ...sql , this.commit()]
}
return format(sql.join(""), { language: 'postgresql' });
}
protected createTableAst(table: TableType): AST[] | AST {
// the only difference in create table statment in postgres is we need to get declare all enums before creating the table
const definition: AST[] = super.createTableAst(table) as any
// so we need an enum ast
const enumsAst: AST[] = [];
// get fields to type enum
let postgresEnums: FieldType[] = table.fields.filter((field: FieldType) => field.type?.name == "enum");
if (postgresEnums.length > 0) {
// loop over them and intiat them one by one
for (const postgresEnum of postgresEnums) {
(postgresEnum as any).table = table;
enumsAst.push(this.createEnumAst(postgresEnum));
}
}
definition.splice(0, 0, ...enumsAst);
return definition;
}
protected getFieldDefinition(field: FieldType, table: TableType, ignorePkContraint: boolean): AST {
// we get the base field definition from the parent renderer
const definition: any = super.getFieldDefinition(field, table, ignorePkContraint);
// since postrges uses the serial data types , then there is not need for auto incrmenet attribute
definition.auto_increment = undefined;
// postgres handle enums diffrently , we need to create the enum first then reference to it in the column line
if (definition.definition.dataType == "ENUM") {
definition.definition.dataType = getPostgresEnumName(table, field);
definition.definition.expr = undefined;
}
return definition;
}
private createEnumAst(field: FieldType): AST {
// get the enum values , and cast it into an array
const jsonValues = field.values ? JSON.parse(field.values) : [];
// return the ast of creating a table
return {
as: "as",
type: "create",
resource: "enum",
name: {
schema: null,
name: `${(field as any).table.name}_${field.name.toLowerCase()}_enum`
},
keyword: "type",
create_definitions: {
parentheses: true,
type: "expr_list",
value: jsonValues.map((value: string) => ({
type: "single_quote_string",
value
}))
}
} as any
}
protected getUsingAst(table: TableType, field: FieldType, newDataType: DataType): AST | null {
const oldType: DataTypes = field.type.type as DataTypes;
const newType: DataTypes = newDataType.type as DataTypes;
if (oldType === newType && oldType != DataTypes.ENUM) return null;
if (
(oldType === DataTypes.INTEGER && newType === DataTypes.NUMERIC) ||
(oldType === DataTypes.NUMERIC && newType === DataTypes.INTEGER)
) return null;
let dataType = newDataType.name?.toUpperCase();
if (newDataType.name == "enum")
dataType = "TEXT::" + getPostgresEnumName(table, field);
else if (newDataType.name != "text")
dataType = "TEXT::" + newDataType.name?.toUpperCase();
return {
as: null,
symbol: "::",
target: [
{
dataType
}
],
type: "cast",
keyword: "cast",
expr: {
type: "column_ref",
table: null,
column: {
expr: {
type: "default",
value: field.name
}
},
collate: null
}
} as any;
}
protected startTransaction(): string {
return "BEGIN;" ;
}
}
@@ -0,0 +1,110 @@
import { DataType } from "@/lib/schemas/data-type-schema";
import { DatabaseDialect } from "@/lib/database";
import { AST } from "node-sql-parser";
import { fixSQLiteColumnOrder } from "../render-uttils";
import { format } from 'sql-formatter';
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { ASTStatment } from "./base-database-renderer";
import { BaseSQLRenderer } from "./base-sql-renderer";
import { FieldType } from "@/lib/schemas/field-schema";
import { TableType } from "@/lib/schemas/table-schema";
import { DBDiffOperation } from "@/utils/database";
import { RenderableTable, SortableTable, toRenderableTable, toSortableTable } from "@/lib/table";
import { DatabaseType } from "@/lib/schemas/database-schema";
import { orderTables } from "@/utils/tables";
export default class SqliteRenderer extends BaseSQLRenderer {
public constructor(data_types: DataType[]) {
super(DatabaseDialect.SQLITE, data_types)
}
protected async astToSQL(ast: AST[]): Promise<string> {
try {
let sql: string = await super.astToSQL(ast);
sql = fixSQLiteColumnOrder(format(sql, { language: "sql" }));
sql = format(sql, { language: 'sql' });
if (sql.length > 0) {
sql = `${this.startTransaction()}
${sql};
${this.commit()}
`
}
return sql;
} catch (error) {
throw error;
}
}
protected operationsToAst(operations: DBDiffOperation[], database?: DatabaseType): ASTStatment[] {
const ast: ASTStatment[] = [];
let sortedTables: TableType[] | RenderableTable = operations.filter((operation: DBDiffOperation) => operation.type == "CREATE_TABLE").map((operation: DBDiffOperation) => (operation as any).table)
if (database) {
let renderableTables: RenderableTable[] = sortedTables.map((table: TableType) => toRenderableTable(table, database));
let sortableTables: SortableTable[] = renderableTables.map((table: RenderableTable) => toSortableTable(table));
const sortedTablesIds: string[] = orderTables(sortableTables);
sortedTables = sortedTablesIds.map((id: string) =>
renderableTables.find((table: TableType) => table.id == id) as TableType
);
}
const relationshipOperations: DBDiffOperation[] = operations.filter((operation: DBDiffOperation) => operation.type == "CREATE_RELATIONSHIP");
// we basiclly loop over all operations , and turn them into ast .
// some operation can return multiple statments .
// so for that we need to check our input if its one AST or an object AST
for (const table of sortedTables) {
const statmentAst = this.createTableAst(table);
if (!statmentAst)
continue;
if ((table as RenderableTable).foreignRelationships?.length > 0) {
const relationshipsAst: ASTStatment[] = (table as RenderableTable).foreignRelationships.map((relationship: RelationshipType) => {
const operation: DBDiffOperation = relationshipOperations.find((operation: DBDiffOperation) => (operation as any).relationship?.id == relationship.id) as DBDiffOperation;
return this.createRelationshipAst((operation as any).relationship)
})
if (Array.isArray(statmentAst)) {
const index = statmentAst.findIndex((statment: any) => statment.type == "create" && statment.keyword == "table");
if (index >= 0) {
(statmentAst[index] as any).create_definitions.push(...relationshipsAst)
}
}
}
if (Array.isArray(statmentAst))
ast.push(...statmentAst);
else
ast.push(statmentAst);
}
return ast;
}
protected createRelationshipAst(relationship: RelationshipType): ASTStatment {
const relationshipAst: any = super.createRelationshipAst(relationship) as any;
return relationshipAst.expr[0].create_definitions;
}
protected getUsingAst(table: TableType, field: FieldType, dataType: DataType): AST | null {
throw new Error("Method not implemented.");
}
protected dropPrimaryKeyConstraintExpr(table: TableType): ASTStatment {
throw new Error("Method not implemented.");
}
protected createPrimaryKeyConstraintExpr(table: TableType, fields: FieldType[]): ASTStatment {
throw new Error("Method not implemented.");
}
}