Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix schema exporting. #15123

Merged
Merged
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
14 changes: 14 additions & 0 deletions .github/workflows/budibase_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,20 @@ jobs:

- run: yarn --frozen-lockfile

- name: Set up PostgreSQL 16
if: matrix.datasource == 'postgres'
run: |
sudo systemctl stop postgresql
sudo apt-get remove --purge -y postgresql* libpq-dev
sudo rm -rf /etc/postgresql /var/lib/postgresql
sudo apt-get autoremove -y
sudo apt-get autoclean

sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update
sudo apt-get install -y postgresql-16

- name: Test server
env:
DATASOURCE: ${{ matrix.datasource }}
Expand Down
7 changes: 4 additions & 3 deletions packages/server/src/api/controllers/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,10 @@ export async function getExternalSchema(
if (!connector.getExternalSchema) {
ctx.throw(400, "Datasource does not support exporting external schema")
}
const response = await connector.getExternalSchema()

ctx.body = {
schema: response,
try {
ctx.body = { schema: await connector.getExternalSchema() }
} catch (e: any) {
ctx.throw(400, e.message)
}
}
99 changes: 99 additions & 0 deletions packages/server/src/api/routes/tests/datasource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,3 +588,102 @@ if (descriptions.length) {
}
)
}

const datasources = datasourceDescribe({
exclude: [DatabaseName.MONGODB, DatabaseName.SQS, DatabaseName.ORACLE],
})

if (datasources.length) {
describe.each(datasources)(
"$dbName",
({ config, dsProvider, isPostgres, isMySQL, isMariaDB }) => {
let datasource: Datasource
let client: Knex

beforeEach(async () => {
const ds = await dsProvider()
datasource = ds.datasource!
client = ds.client!
})

describe("external export", () => {
let table: Table

beforeEach(async () => {
table = await config.api.table.save(
tableForDatasource(datasource, {
name: "simple",
primary: ["id"],
primaryDisplay: "name",
schema: {
id: {
name: "id",
autocolumn: true,
type: FieldType.NUMBER,
constraints: {
presence: false,
},
},
name: {
name: "name",
autocolumn: false,
type: FieldType.STRING,
constraints: {
presence: false,
},
},
},
})
)
})

it("should be able to export and reimport a schema", async () => {
let { schema } = await config.api.datasource.externalSchema(
datasource
)

if (isPostgres) {
// pg_dump 17 puts this config parameter into the dump but no DB < 17
// can load it. We're using postgres 16 in tests at the time of writing.
schema = schema.replace("SET transaction_timeout = 0;", "")
}

await config.api.table.destroy(table._id!, table._rev!)

if (isMySQL || isMariaDB) {
// MySQL/MariaDB clients don't let you run multiple queries in a
// single call. They also throw an error when given an empty query.
// The below handles both of these things.
for (let query of schema.split(";\n")) {
query = query.trim()
if (!query) {
continue
}
await client.raw(query)
}
} else {
await client.raw(schema)
}

await config.api.datasource.fetchSchema({
datasourceId: datasource._id!,
})

const tables = await config.api.table.fetch()
const newTable = tables.find(t => t.name === table.name)!

// This is only set on tables created through Budibase, we don't
// expect it to match after we import the table.
delete table.created

for (const field of Object.values(newTable.schema)) {
// Will differ per-database, not useful for this test.
delete field.externalType
}

expect(newTable).toEqual(table)
})
})
}
)
}
81 changes: 67 additions & 14 deletions packages/server/src/integrations/microsoftSqlServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,34 @@ const SCHEMA: Integration = {
},
}

interface MSSQLColumnDefinition {
TableName: string
ColumnName: string
DataType: string
MaxLength: number
IsNullable: boolean
IsIdentity: boolean
Precision: number
Scale: number
}

interface ColumnDefinitionMetadata {
usesMaxLength?: boolean
usesPrecision?: boolean
}

const COLUMN_DEFINITION_METADATA: Record<string, ColumnDefinitionMetadata> = {
DATETIME2: { usesMaxLength: true },
TIME: { usesMaxLength: true },
DATETIMEOFFSET: { usesMaxLength: true },
NCHAR: { usesMaxLength: true },
NVARCHAR: { usesMaxLength: true },
BINARY: { usesMaxLength: true },
VARBINARY: { usesMaxLength: true },
DECIMAL: { usesPrecision: true },
NUMERIC: { usesPrecision: true },
}

class SqlServerIntegration extends Sql implements DatasourcePlus {
private readonly config: MSSQLConfig
private index: number = 0
Expand Down Expand Up @@ -527,20 +555,24 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
return this.queryWithReturning(json, queryFn, processFn)
}

async getExternalSchema() {
private async getColumnDefinitions(): Promise<MSSQLColumnDefinition[]> {
// Query to retrieve table schema
const query = `
SELECT
t.name AS TableName,
c.name AS ColumnName,
ty.name AS DataType,
ty.precision AS Precision,
ty.scale AS Scale,
c.max_length AS MaxLength,
c.is_nullable AS IsNullable,
c.is_identity AS IsIdentity
FROM
sys.tables t
INNER JOIN sys.columns c ON t.object_id = c.object_id
INNER JOIN sys.types ty ON c.system_type_id = ty.system_type_id
INNER JOIN sys.types ty
ON c.system_type_id = ty.system_type_id
AND c.user_type_id = ty.user_type_id
WHERE
t.is_ms_shipped = 0
ORDER BY
Expand All @@ -553,27 +585,48 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
sql: query,
})

return result.recordset as MSSQLColumnDefinition[]
}

private getDataType(columnDef: MSSQLColumnDefinition): string {
const { DataType, MaxLength, Precision, Scale } = columnDef
const { usesMaxLength = false, usesPrecision = false } =
COLUMN_DEFINITION_METADATA[DataType] || {}

let dataType = DataType

if (usesMaxLength) {
if (MaxLength === -1) {
dataType += `(MAX)`
} else {
dataType += `(${MaxLength})`
}
}
if (usesPrecision) {
dataType += `(${Precision}, ${Scale})`
}

return dataType
}

async getExternalSchema() {
const scriptParts = []
const tables: any = {}
for (const row of result.recordset) {
const {
TableName,
ColumnName,
DataType,
MaxLength,
IsNullable,
IsIdentity,
} = row
const columns = await this.getColumnDefinitions()
for (const row of columns) {
const { TableName, ColumnName, IsNullable, IsIdentity } = row

if (!tables[TableName]) {
tables[TableName] = {
columns: [],
}
}

const columnDefinition = `${ColumnName} ${DataType}${
MaxLength ? `(${MaxLength})` : ""
}${IsNullable ? " NULL" : " NOT NULL"}`
const nullable = IsNullable ? "NULL" : "NOT NULL"
const identity = IsIdentity ? "IDENTITY" : ""
const columnDefinition = `[${ColumnName}] ${this.getDataType(
row
)} ${nullable} ${identity}`

tables[TableName].columns.push(columnDefinition)

Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/integrations/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
async getExternalSchema() {
try {
const [databaseResult] = await this.internalQuery({
sql: `SHOW CREATE DATABASE ${this.config.database}`,
sql: `SHOW CREATE DATABASE IF NOT EXISTS \`${this.config.database}\``,
})
let dumpContent = [databaseResult["Create Database"]]

Expand All @@ -432,7 +432,7 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
dumpContent.push(createTableStatement)
}

return dumpContent.join("\n")
return dumpContent.join(";\n") + ";"
} finally {
this.disconnect()
}
Expand Down
16 changes: 5 additions & 11 deletions packages/server/src/integrations/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,21 +476,15 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
this.config.password
}" pg_dump --schema-only "${dumpCommandParts.join(" ")}"`

return new Promise<string>((res, rej) => {
return new Promise<string>((resolve, reject) => {
exec(dumpCommand, (error, stdout, stderr) => {
if (error) {
console.error(`Error generating dump: ${error.message}`)
rej(error.message)
if (error || stderr) {
console.error(stderr)
reject(new Error(stderr))
return
}

if (stderr) {
console.error(`pg_dump error: ${stderr}`)
rej(stderr)
return
}

res(stdout)
resolve(stdout)
console.log("SQL dump generated successfully!")
})
})
Expand Down
11 changes: 6 additions & 5 deletions packages/server/src/integrations/tests/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export function datasourceDescribe(opts: DatasourceDescribeOpts) {
isMongodb: dbName === DatabaseName.MONGODB,
isMSSQL: dbName === DatabaseName.SQL_SERVER,
isOracle: dbName === DatabaseName.ORACLE,
isMariaDB: dbName === DatabaseName.MARIADB,
}))
}

Expand All @@ -158,19 +159,19 @@ function getDatasource(
return providers[sourceName]()
}

export async function knexClient(ds: Datasource) {
export async function knexClient(ds: Datasource, opts?: Knex.Config) {
switch (ds.source) {
case SourceName.POSTGRES: {
return postgres.knexClient(ds)
return postgres.knexClient(ds, opts)
}
case SourceName.MYSQL: {
return mysql.knexClient(ds)
return mysql.knexClient(ds, opts)
}
case SourceName.SQL_SERVER: {
return mssql.knexClient(ds)
return mssql.knexClient(ds, opts)
}
case SourceName.ORACLE: {
return oracle.knexClient(ds)
return oracle.knexClient(ds, opts)
}
default: {
throw new Error(`Unsupported source: ${ds.source}`)
Expand Down
5 changes: 3 additions & 2 deletions packages/server/src/integrations/tests/utils/mssql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Datasource, SourceName } from "@budibase/types"
import { GenericContainer, Wait } from "testcontainers"
import { generator, testContainerUtils } from "@budibase/backend-core/tests"
import { startContainer } from "."
import knex from "knex"
import knex, { Knex } from "knex"
import { MSSQL_IMAGE } from "./images"

let ports: Promise<testContainerUtils.Port[]>
Expand Down Expand Up @@ -57,7 +57,7 @@ export async function getDatasource(): Promise<Datasource> {
return datasource
}

export async function knexClient(ds: Datasource) {
export async function knexClient(ds: Datasource, opts?: Knex.Config) {
if (!ds.config) {
throw new Error("Datasource config is missing")
}
Expand All @@ -68,5 +68,6 @@ export async function knexClient(ds: Datasource) {
return knex({
client: "mssql",
connection: ds.config,
...opts,
})
}
5 changes: 3 additions & 2 deletions packages/server/src/integrations/tests/utils/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { GenericContainer, Wait } from "testcontainers"
import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy"
import { generator, testContainerUtils } from "@budibase/backend-core/tests"
import { startContainer } from "."
import knex from "knex"
import knex, { Knex } from "knex"
import { MYSQL_IMAGE } from "./images"

let ports: Promise<testContainerUtils.Port[]>
Expand Down Expand Up @@ -63,7 +63,7 @@ export async function getDatasource(): Promise<Datasource> {
return datasource
}

export async function knexClient(ds: Datasource) {
export async function knexClient(ds: Datasource, opts?: Knex.Config) {
if (!ds.config) {
throw new Error("Datasource config is missing")
}
Expand All @@ -74,5 +74,6 @@ export async function knexClient(ds: Datasource) {
return knex({
client: "mysql2",
connection: ds.config,
...opts,
})
}
Loading
Loading