diff --git a/.github/workflows/1panel-appstore.yml b/.github/workflows/1panel-appstore.yml index 00681cd9ef..dd40977b41 100644 --- a/.github/workflows/1panel-appstore.yml +++ b/.github/workflows/1panel-appstore.yml @@ -1,46 +1,41 @@ name: 1Panel App Store on: - release: - types: [published, released] + workflow_dispatch: + inputs: + version: + description: "Version to submit (e.g. 0.5.3)" + required: true jobs: submit: runs-on: ubuntu-latest - if: ${{ !github.event.release.draft && !github.event.release.prerelease }} env: REPO_OWNER: t8y2 steps: - uses: actions/checkout@v4 - - name: Extract version from tag - id: version - run: | - VERSION="${{ github.event.release.tag_name }}" - VERSION="${VERSION#v}" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - name: Submit to 1Panel App Store env: TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} run: | - VERSION="${{ steps.version.outputs.version }}" + VERSION="${{ github.event.inputs.version }}" - git clone --depth 1 \ - "https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/${REPO_OWNER}/appstore.git" \ + git clone --depth 1 -b localApps \ + "https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/${REPO_OWNER}/appstore-1panel.git" \ appstore cd appstore APP_DIR="apps/dbx" - mkdir -p "${APP_DIR}/${VERSION}" + mkdir -p "${APP_DIR}/latest" cp "${{ github.workspace }}/1panel/data.yml" "${APP_DIR}/data.yml" cp "${{ github.workspace }}/1panel/README.md" "${APP_DIR}/README.md" if [ -f "${{ github.workspace }}/1panel/logo.png" ]; then cp "${{ github.workspace }}/1panel/logo.png" "${APP_DIR}/logo.png" fi - cp -r "${{ github.workspace }}/1panel/latest/." "${APP_DIR}/${VERSION}/" + cp -r "${{ github.workspace }}/1panel/latest/." "${APP_DIR}/latest/" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -55,10 +50,10 @@ jobs: git commit -m "feat(dbx): release ${VERSION}" git push origin "${BRANCH}" gh pr create \ - --repo "1Panel-dev/appstore" \ + --repo "okxlin/appstore" \ --head "${REPO_OWNER}:${BRANCH}" \ --title "feat(dbx): release ${VERSION}" \ - --body "$(printf 'Automated release of DBX v%s\n\n```release-note\nfeat(dbx): release %s\n```' "${VERSION}" "${VERSION}")" \ - --base dev + --body "Automated release of DBX v${VERSION}" \ + --base localApps echo "::notice::1Panel App Store PR created for ${VERSION}" fi diff --git a/1panel/data.yml b/1panel/data.yml index 7b863b42b3..e7ddea8053 100644 --- a/1panel/data.yml +++ b/1panel/data.yml @@ -1,13 +1,24 @@ -additional: +name: DBX +tags: + - 工具 +title: 轻量级数据库管理工具 +description: 轻量级数据库管理工具,支持 25+ 种数据库 +additionalProperties: key: dbx name: DBX tags: - Tool shortDescZh: 轻量级数据库管理工具,支持 25+ 种数据库 shortDescEn: Lightweight database manager supporting 25+ databases + description: + en: Lightweight database manager supporting 25+ databases + zh: 轻量级数据库管理工具,支持 25+ 种数据库 type: tool crossVersionUpdate: true limit: 0 + recommend: 0 website: https://github.com/t8y2/dbx github: https://github.com/t8y2/dbx document: https://github.com/t8y2/dbx#readme + architectures: + - amd64 diff --git a/1panel/latest/data.yml b/1panel/latest/data.yml index 0394fcc7c3..a8f6eac7d5 100644 --- a/1panel/latest/data.yml +++ b/1panel/latest/data.yml @@ -1,7 +1,10 @@ additionalProperties: formFields: - default: 4224 + edit: true envKey: PANEL_APP_PORT_HTTP + labelZh: 端口 + labelEn: Port label: zh: 端口 en: Port @@ -10,7 +13,10 @@ additionalProperties: type: number - default: dbx_password + edit: true envKey: PANEL_DB_PASSWORD + labelZh: 访问密码 + labelEn: Access Password label: zh: 访问密码 en: Access Password diff --git a/1panel/latest/docker-compose.yml b/1panel/latest/docker-compose.yml index 443ad24245..f1b9d7a3b8 100644 --- a/1panel/latest/docker-compose.yml +++ b/1panel/latest/docker-compose.yml @@ -1,11 +1,9 @@ -version: "3" - services: - ${CONTAINER_NAME}: - image: t8y2/dbx:${PANEL_APP_VERSION_TAG} + dbx: + image: t8y2/dbx:latest container_name: ${CONTAINER_NAME} ports: - - ${PANEL_APP_PORT_HTTP}:4224 + - "${PANEL_APP_PORT_HTTP}:4224" environment: - DBX_PASSWORD=${PANEL_DB_PASSWORD} volumes: @@ -13,6 +11,8 @@ services: restart: unless-stopped networks: - 1panel-network + labels: + createdBy: "Apps" networks: 1panel-network: diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 9349bf9d3a..05005063ef 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -677,6 +677,217 @@ pub async fn get_table_ddl_core( } } +fn sql_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn pg_ident(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn mysql_ident(value: &str) -> String { + format!("`{}`", value.replace('`', "``")) +} + +fn sqlite_object_type(kind: &db::ObjectSourceKind) -> &'static str { + match kind { + db::ObjectSourceKind::View => "view", + db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function => "routine", + } +} + +fn sqlserver_object_type_filter(kind: &db::ObjectSourceKind) -> &'static str { + match kind { + db::ObjectSourceKind::View => "'V'", + db::ObjectSourceKind::Procedure => "'P'", + db::ObjectSourceKind::Function => "'FN','IF','TF','FS','FT'", + } +} + +pub fn sqlserver_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourceKind) -> String { + format!( + "SELECT m.definition FROM sys.sql_modules m \ + JOIN sys.objects o ON o.object_id = m.object_id \ + JOIN sys.schemas s ON s.schema_id = o.schema_id \ + WHERE s.name = {} AND o.name = {} AND o.type IN ({})", + sql_string(schema), + sql_string(name), + sqlserver_object_type_filter(kind) + ) +} + +pub fn postgres_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourceKind) -> String { + match kind { + db::ObjectSourceKind::View => { + format!("SELECT pg_get_viewdef('{}.{}'::regclass, true)", pg_ident(schema), pg_ident(name)) + } + db::ObjectSourceKind::Procedure | db::ObjectSourceKind::Function => { + let prokind = if matches!(kind, db::ObjectSourceKind::Procedure) { "p" } else { "f" }; + format!( + "SELECT pg_get_functiondef(p.oid) \ + FROM pg_proc p \ + JOIN pg_namespace n ON n.oid = p.pronamespace \ + WHERE n.nspname = {} AND p.proname = {} AND p.prokind = '{}' \ + ORDER BY p.oid LIMIT 1", + sql_string(schema), + sql_string(name), + prokind + ) + } + } +} + +pub fn oracle_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourceKind) -> String { + let object_type = match kind { + db::ObjectSourceKind::View => "VIEW", + db::ObjectSourceKind::Procedure => "PROCEDURE", + db::ObjectSourceKind::Function => "FUNCTION", + }; + format!( + "SELECT DBMS_METADATA.GET_DDL({}, {}, {}) FROM DUAL", + sql_string(object_type), + sql_string(name), + sql_string(schema) + ) +} + +pub fn sqlite_object_source_sql(name: &str, kind: &db::ObjectSourceKind) -> String { + format!( + "SELECT sql FROM sqlite_master WHERE type = {} AND name = {}", + sql_string(sqlite_object_type(kind)), + sql_string(name) + ) +} + +pub fn mysql_object_source_sql(name: &str, kind: &db::ObjectSourceKind) -> String { + match kind { + db::ObjectSourceKind::View => format!("SHOW CREATE VIEW {}", mysql_ident(name)), + db::ObjectSourceKind::Procedure => format!("SHOW CREATE PROCEDURE {}", mysql_ident(name)), + db::ObjectSourceKind::Function => format!("SHOW CREATE FUNCTION {}", mysql_ident(name)), + } +} + +fn first_string_cell(result: db::QueryResult) -> Result { + result + .rows + .first() + .and_then(|row| row.iter().find_map(|value| value.as_str().map(str::to_string))) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Object source not found".to_string()) +} + +async fn mysql_object_source( + pool: &sqlx::mysql::MySqlPool, + name: &str, + kind: &db::ObjectSourceKind, +) -> Result { + use sqlx::Row; + let sql = mysql_object_source_sql(name, kind); + let row: sqlx::mysql::MySqlRow = sqlx::raw_sql(&sql).fetch_one(pool).await.map_err(|e| e.to_string())?; + let index = if matches!(kind, db::ObjectSourceKind::View) { 1 } else { 2 }; + row.try_get::(index) + .or_else(|_| row.try_get::, _>(index).map(|b| String::from_utf8_lossy(&b).to_string())) + .map_err(|e| e.to_string()) +} + +pub async fn get_object_source_core( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + name: &str, + object_type: db::ObjectSourceKind, +) -> Result { + let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + let source = { + let connections = state.connections.read().await; + if let Some(client) = extract_sqlserver(&connections, &pool_key) { + drop(connections); + let mut client = client.lock().await; + first_string_cell( + db::sqlserver::execute_query(&mut client, &sqlserver_object_source_sql(schema, name, &object_type)) + .await?, + )? + } else if let Some(pool) = extract_oracle(&connections, &pool_key) { + drop(connections); + let client = pool.client(); + let client = client.lock().await; + first_string_cell( + db::oracle_driver::execute_query(&*client, &oracle_object_source_sql(schema, name, &object_type)) + .await?, + )? + } else if let Some(client) = extract_gaussdb(&connections, &pool_key) { + drop(connections); + let mut client = client.lock().await; + first_string_cell( + db::gaussdb_driver::execute_query(&mut client, &postgres_object_source_sql(schema, name, &object_type)) + .await?, + )? + } else { + match connections.get(&pool_key).ok_or("Pool not found")? { + PoolKind::Mysql(pool, _) => mysql_object_source(pool, name, &object_type).await?, + PoolKind::Postgres(pool) => first_string_cell( + db::postgres::execute_query(pool, &postgres_object_source_sql(schema, name, &object_type)).await?, + )?, + PoolKind::Sqlite(pool) => first_string_cell( + db::sqlite::execute_query(pool, &sqlite_object_source_sql(name, &object_type)).await?, + )?, + PoolKind::ClickHouse(client) if matches!(object_type, db::ObjectSourceKind::View) => { + let result = db::clickhouse_driver::execute_query( + client, + database, + &format!("SHOW CREATE TABLE {}", mysql_ident(name)), + ) + .await?; + first_string_cell(result)? + } + _ => return Err("Object source is not supported for this database type".to_string()), + } + } + }; + + Ok(db::ObjectSource { + name: name.to_string(), + object_type, + schema: if schema.is_empty() { None } else { Some(schema.to_string()) }, + source, + }) +} + +#[cfg(test)] +mod object_source_tests { + use super::*; + use crate::types::ObjectSourceKind; + + #[test] + fn builds_sqlserver_object_source_sql_for_schema_scoped_routines() { + assert_eq!( + sqlserver_object_source_sql("dbo", "refresh_cache", &ObjectSourceKind::Procedure), + "SELECT m.definition FROM sys.sql_modules m JOIN sys.objects o ON o.object_id = m.object_id JOIN sys.schemas s ON s.schema_id = o.schema_id WHERE s.name = 'dbo' AND o.name = 'refresh_cache' AND o.type IN ('P')" + ); + } + + #[test] + fn builds_postgres_object_source_sql_for_views_and_functions() { + assert_eq!( + postgres_object_source_sql("public", "active_users", &ObjectSourceKind::View), + "SELECT pg_get_viewdef('\"public\".\"active_users\"'::regclass, true)" + ); + assert_eq!( + postgres_object_source_sql("public", "recalc_score", &ObjectSourceKind::Function), + "SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' AND p.proname = 'recalc_score' AND p.prokind = 'f' ORDER BY p.oid LIMIT 1" + ); + } + + #[test] + fn builds_oracle_object_source_sql_using_metadata_api() { + assert_eq!( + oracle_object_source_sql("HR", "ACTIVE_USERS", &ObjectSourceKind::View), + "SELECT DBMS_METADATA.GET_DDL('VIEW', 'ACTIVE_USERS', 'HR') FROM DUAL" + ); + } +} + pub async fn mysql_ddl(pool: &sqlx::mysql::MySqlPool, table: &str) -> Result { use sqlx::Row; let sql = format!("SHOW CREATE TABLE `{}`", table.replace('`', "``")); diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index b77adc5fd2..71d59cb811 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -20,6 +20,22 @@ pub struct ObjectInfo { pub comment: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ObjectSourceKind { + View, + Procedure, + Function, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObjectSource { + pub name: String, + pub object_type: ObjectSourceKind, + pub schema: Option, + pub source: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ColumnInfo { pub name: String, diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 2f7ff5e518..ee8514481f 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -41,6 +41,18 @@ pub async fn list_objects( dbx_core::schema::list_objects_core(&state, &connection_id, &database, &schema).await } +#[tauri::command] +pub async fn get_object_source( + state: State<'_, Arc>, + connection_id: String, + database: String, + schema: String, + name: String, + object_type: db::ObjectSourceKind, +) -> Result { + dbx_core::schema::get_object_source_core(&state, &connection_id, &database, &schema, &name, object_type).await +} + #[tauri::command] pub async fn get_columns( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b52625059..832b25652d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -83,6 +83,7 @@ pub fn run() { commands::schema::list_databases, commands::schema::list_tables, commands::schema::list_objects, + commands::schema::get_object_source, commands::schema::list_schemas, commands::schema::get_columns, commands::schema::list_indexes, diff --git a/src-web/src/main.rs b/src-web/src/main.rs index 7f818df7a5..a7df117bcc 100644 --- a/src-web/src/main.rs +++ b/src-web/src/main.rs @@ -87,6 +87,7 @@ async fn main() { .route("/schema/schemas", get(routes::schema::list_schemas)) .route("/schema/tables", get(routes::schema::list_tables)) .route("/schema/objects", get(routes::schema::list_objects)) + .route("/schema/object-source", get(routes::schema::get_object_source)) .route("/schema/columns", get(routes::schema::list_columns)) .route("/schema/indexes", get(routes::schema::list_indexes)) .route("/schema/foreign-keys", get(routes::schema::list_foreign_keys)) diff --git a/src-web/src/routes/schema.rs b/src-web/src/routes/schema.rs index c5cf7857a1..12a2db4d8c 100644 --- a/src-web/src/routes/schema.rs +++ b/src-web/src/routes/schema.rs @@ -13,6 +13,7 @@ pub struct SchemaQuery { pub database: Option, pub schema: Option, pub table: Option, + pub object_type: Option, } pub async fn list_databases( @@ -54,6 +55,21 @@ pub async fn list_objects( Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } +pub async fn get_object_source( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let database = q.database.as_deref().unwrap_or(""); + let schema = q.schema.as_deref().unwrap_or(""); + let name = q.table.as_deref().unwrap_or(""); + let object_type = q.object_type.ok_or_else(|| AppError("Missing object_type".to_string()))?; + let result = + dbx_core::schema::get_object_source_core(&state.app, &q.connection_id, database, schema, name, object_type) + .await + .map_err(AppError)?; + Ok(Json(result)) +} + pub async fn list_columns( State(state): State>, Query(q): Query,