refactor: move ws_specific from resource column to separate table (#8766)

* Move ws_specific to separate table

* on delete cascade

* feat: handle ws_specific on resource rename and delete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* is_false never used

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-04-08 18:10:41 +02:00
committed by GitHub
parent e36d440a25
commit d2992af8be
17 changed files with 249 additions and 51 deletions

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE ws_specific SET path = $1 WHERE workspace_id = $2 AND item_kind = 'resource' AND path = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "20ca0b26a0d2e9cfb9fb8e7066d6d5f3a154de0f76ce0d12ee678bcabd7b445c"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "2d236ba78748c6160abec9ae1ad11e9e90063e2c583e2145f6d5b86fbf981861"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = ANY($2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "3ea2e1994d576e7a1b32ab276820dcff8e4e3b83f0d6a78fa0120c73fe90f345"
}

View File

@@ -50,36 +50,31 @@
}, },
{ {
"ordinal": 9, "ordinal": 9,
"name": "ws_specific",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "is_expired", "name": "is_expired",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 11, "ordinal": 10,
"name": "is_refreshed", "name": "is_refreshed",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 12, "ordinal": 11,
"name": "refresh_error", "name": "refresh_error",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 13, "ordinal": 12,
"name": "is_linked", "name": "is_linked",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 14, "ordinal": 13,
"name": "is_oauth?", "name": "is_oauth?",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 15, "ordinal": 14,
"name": "account", "name": "account",
"type_info": "Int4" "type_info": "Int4"
} }
@@ -100,7 +95,6 @@
true, true,
true, true,
true, true,
false,
null, null,
null, null,
true, true,

View File

@@ -47,11 +47,6 @@
"ordinal": 8, "ordinal": 8,
"name": "labels", "name": "labels",
"type_info": "TextArray" "type_info": "TextArray"
},
{
"ordinal": 9,
"name": "ws_specific",
"type_info": "Bool"
} }
], ],
"parameters": { "parameters": {
@@ -68,8 +63,7 @@
false, false,
true, true,
true, true,
true, true
false
] ]
}, },
"hash": "45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7" "hash": "45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7"

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "4a623e8789073abd92c52615077b89ed24398f59205c64290fa52ab19b3e44a3"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, description, resource_type\n FROM resource\n WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "resource_type",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
true,
false
]
},
"hash": "819c233915383e89af1bcf1a56c5f67c4e1fc217f216f609e36a9944a7807b33"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE resource\n SET value = jsonb_set(value, '{dbname}', to_jsonb($3::text))\n WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "c19df45b092a246141e52770e70e02293b0a5eac859cbdc45f07c08957f00e00"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Jsonb",
"Text",
"Varchar",
"Varchar",
"TextArray"
]
},
"nullable": []
},
"hash": "deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ws_specific (workspace_id, item_kind, path) VALUES ($1, 'resource', $2) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "edcda7e58fd1c586b5fb7aadedfde6b9dcffbc859426295c58719f6a8a544936"
}

View File

@@ -0,0 +1,12 @@
-- Re-add the column to resource
ALTER TABLE resource ADD COLUMN IF NOT EXISTS ws_specific BOOLEAN NOT NULL DEFAULT false;
-- Migrate data back
UPDATE resource SET ws_specific = true
FROM ws_specific ws
WHERE ws.workspace_id = resource.workspace_id
AND ws.item_kind = 'resource'
AND ws.path = resource.path;
-- Drop the table
DROP TABLE IF EXISTS ws_specific;

View File

@@ -0,0 +1,17 @@
-- Create the ws_specific table
CREATE TABLE IF NOT EXISTS ws_specific (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
item_kind VARCHAR(50) NOT NULL,
path VARCHAR(255) NOT NULL,
PRIMARY KEY (workspace_id, item_kind, path)
);
-- Migrate existing data from resource.ws_specific
INSERT INTO ws_specific (workspace_id, item_kind, path)
SELECT workspace_id, 'resource', path
FROM resource
WHERE ws_specific = true
ON CONFLICT DO NOTHING;
-- Drop the column from resource
ALTER TABLE resource DROP COLUMN IF EXISTS ws_specific;

View File

@@ -4160,7 +4160,7 @@ async fn create_workspace_fork_branch(
/// Update a forked workspace's datatable config to point to the new database. /// Update a forked workspace's datatable config to point to the new database.
/// For instance datatables: updates resource_path in the datatable config. /// For instance datatables: updates resource_path in the datatable config.
/// For resource datatables: updates the resource's dbname and sets ws_specific. /// For resource datatables: updates the resource's dbname and marks it as ws_specific.
/// Snapshot the schema from the source datatable by connecting to its database. /// Snapshot the schema from the source datatable by connecting to its database.
async fn snapshot_datatable_schema( async fn snapshot_datatable_schema(
db: &DB, db: &DB,
@@ -4242,12 +4242,11 @@ async fn apply_forked_datatable(
.execute(&mut **tx) .execute(&mut **tx)
.await?; .await?;
} else { } else {
// Resource: update the resource's dbname and set ws_specific // Resource: update the resource's dbname and mark as ws_specific
let resource_path = &dt.database.resource_path; let resource_path = &dt.database.resource_path;
sqlx::query!( sqlx::query!(
r#"UPDATE resource r#"UPDATE resource
SET value = jsonb_set(value, '{dbname}', to_jsonb($3::text)), SET value = jsonb_set(value, '{dbname}', to_jsonb($3::text))
ws_specific = true
WHERE workspace_id = $1 AND path = $2"#, WHERE workspace_id = $1 AND path = $2"#,
forked_w_id, forked_w_id,
resource_path, resource_path,
@@ -4256,6 +4255,14 @@ async fn apply_forked_datatable(
.execute(&mut **tx) .execute(&mut **tx)
.await?; .await?;
sqlx::query!(
"INSERT INTO ws_specific (workspace_id, item_kind, path) VALUES ($1, 'resource', $2) ON CONFLICT DO NOTHING",
forked_w_id,
resource_path,
)
.execute(&mut **tx)
.await?;
// Set forked_from on the datatable config // Set forked_from on the datatable config
sqlx::query!( sqlx::query!(
r#"UPDATE workspace_settings r#"UPDATE workspace_settings
@@ -6081,7 +6088,7 @@ async fn compare_two_resources(
) -> Result<ItemComparison> { ) -> Result<ItemComparison> {
// Get resource from each workspace // Get resource from each workspace
let source_resource = sqlx::query!( let source_resource = sqlx::query!(
"SELECT value, description, resource_type, ws_specific "SELECT value, description, resource_type
FROM resource FROM resource
WHERE workspace_id = $1 AND path = $2", WHERE workspace_id = $1 AND path = $2",
source_workspace_id, source_workspace_id,
@@ -6091,7 +6098,7 @@ async fn compare_two_resources(
.await?; .await?;
let target_resource = sqlx::query!( let target_resource = sqlx::query!(
"SELECT value, description, resource_type, ws_specific "SELECT value, description, resource_type
FROM resource FROM resource
WHERE workspace_id = $1 AND path = $2", WHERE workspace_id = $1 AND path = $2",
fork_workspace_id, fork_workspace_id,
@@ -6101,8 +6108,24 @@ async fn compare_two_resources(
.await?; .await?;
// If either side is ws_specific, consider unchanged // If either side is ws_specific, consider unchanged
let source_ws_specific = source_resource.as_ref().map_or(false, |r| r.ws_specific); let source_ws_specific = sqlx::query_scalar!(
let target_ws_specific = target_resource.as_ref().map_or(false, |r| r.ws_specific); "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2)",
source_workspace_id,
path
)
.fetch_one(db)
.await?
.unwrap_or(false);
let target_ws_specific = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2)",
fork_workspace_id,
path
)
.fetch_one(db)
.await?
.unwrap_or(false);
if source_ws_specific || target_ws_specific { if source_ws_specific || target_ws_specific {
return Ok(ItemComparison { return Ok(ItemComparison {
has_changes: false, has_changes: false,

View File

@@ -21659,9 +21659,6 @@ components:
resource_type: resource_type:
type: string type: string
description: The new resource_type to be associated with the resource description: The new resource_type to be associated with the resource
ws_specific:
type: boolean
description: When true, the resource is excluded from workspace diff comparisons
labels: labels:
type: array type: array
items: items:

View File

@@ -87,10 +87,6 @@ pub fn public_service() -> Router {
Router::new().route("/custom_component/{name}", get(custom_component)) Router::new().route("/custom_component/{name}", get(custom_component))
} }
fn is_false(b: &bool) -> bool {
!b
}
#[derive(FromRow, Serialize, Deserialize)] #[derive(FromRow, Serialize, Deserialize)]
pub struct ResourceType { pub struct ResourceType {
pub workspace_id: String, pub workspace_id: String,
@@ -129,8 +125,6 @@ pub struct Resource {
pub extra_perms: serde_json::Value, pub extra_perms: serde_json::Value,
pub created_by: Option<String>, pub created_by: Option<String>,
pub edited_at: Option<chrono::DateTime<chrono::Utc>>, pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "is_false")]
pub ws_specific: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>, pub labels: Option<Vec<String>>,
} }
@@ -151,8 +145,6 @@ pub struct ListableResource {
pub is_expired: Option<bool>, pub is_expired: Option<bool>,
pub refresh_error: Option<String>, pub refresh_error: Option<String>,
pub account: Option<i32>, pub account: Option<i32>,
#[serde(default, skip_serializing_if = "is_false")]
pub ws_specific: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>, pub labels: Option<Vec<String>>,
} }
@@ -163,8 +155,6 @@ pub struct CreateResource {
pub value: Option<Box<RawValue>>, pub value: Option<Box<RawValue>>,
pub description: Option<String>, pub description: Option<String>,
pub resource_type: String, pub resource_type: String,
#[serde(default)]
pub ws_specific: Option<bool>,
pub labels: Option<Vec<String>>, pub labels: Option<Vec<String>>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -172,7 +162,6 @@ struct EditResource {
path: Option<String>, path: Option<String>,
description: Option<String>, description: Option<String>,
value: Option<Box<RawValue>>, value: Option<Box<RawValue>>,
ws_specific: Option<bool>,
labels: Option<Vec<String>>, labels: Option<Vec<String>>,
} }
@@ -270,7 +259,6 @@ async fn list_resources(
"account.refresh_error", "account.refresh_error",
"resource.created_by", "resource.created_by",
"resource.edited_at", "resource.edited_at",
"resource.ws_specific",
"resource.labels", "resource.labels",
]) ])
.left() .left()
@@ -847,16 +835,15 @@ async fn create_resource(
} }
sqlx::query!( sqlx::query!(
"INSERT INTO resource "INSERT INTO resource
(workspace_id, path, value, description, resource_type, created_by, edited_at, ws_specific, labels) (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)
VALUES ($1, $2, $3, $4, $5, $6, now(), $7, $8) ON CONFLICT (workspace_id, path) VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)
DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), ws_specific = EXCLUDED.ws_specific, labels = EXCLUDED.labels", DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels",
w_id, w_id,
resource.path, resource.path,
raw_json as sqlx::types::Json<&RawValue>, raw_json as sqlx::types::Json<&RawValue>,
resource.description, resource.description,
resource.resource_type, resource.resource_type,
authed.username, authed.username,
resource.ws_specific.unwrap_or(false),
resource.labels.as_deref() as Option<&[String]> resource.labels.as_deref() as Option<&[String]>
) )
.execute(&mut *tx) .execute(&mut *tx)
@@ -963,6 +950,14 @@ async fn delete_resource(
q.fetch_all(&mut *tx).await? q.fetch_all(&mut *tx).await?
}; };
sqlx::query!(
"DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2",
w_id,
path
)
.execute(&mut *tx)
.await?;
let deleted_path = sqlx::query_scalar!( let deleted_path = sqlx::query_scalar!(
"DELETE FROM resource WHERE path = $1 AND workspace_id = $2 RETURNING path", "DELETE FROM resource WHERE path = $1 AND workspace_id = $2 RETURNING path",
path, path,
@@ -1138,6 +1133,14 @@ async fn delete_resources_bulk(
} }
} }
sqlx::query!(
"DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = ANY($2)",
w_id,
&request.paths
)
.execute(&mut *tx)
.await?;
let deleted_paths = sqlx::query_scalar!( let deleted_paths = sqlx::query_scalar!(
"DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2 RETURNING path", "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2 RETURNING path",
&request.paths, &request.paths,
@@ -1224,10 +1227,6 @@ async fn update_resource(
if let Some(ndesc) = ns.description { if let Some(ndesc) = ns.description {
sqlb.set_str("description", ndesc); sqlb.set_str("description", ndesc);
} }
if let Some(nd) = ns.ws_specific {
sqlb.set_str("ws_specific", if nd { "true" } else { "false" });
}
sqlb.set_str("edited_at", "now()"); sqlb.set_str("edited_at", "now()");
sqlb.returning("path"); sqlb.returning("path");
@@ -1286,6 +1285,15 @@ async fn update_resource(
) )
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::query!(
"UPDATE ws_specific SET path = $1 WHERE workspace_id = $2 AND item_kind = 'resource' AND path = $3",
npath,
w_id,
path
)
.execute(&mut *tx)
.await?;
} }
} }

View File

@@ -26,7 +26,6 @@ export interface ResourceFile {
description?: string; description?: string;
resource_type: string; resource_type: string;
is_oauth?: boolean; // deprecated is_oauth?: boolean; // deprecated
ws_specific?: boolean;
} }
async function readFilesetDirectory(dirPath: string): Promise<Record<string, string>> { async function readFilesetDirectory(dirPath: string): Promise<Record<string, string>> {

View File

@@ -418,7 +418,6 @@ impl Windmill {
description: None, description: None,
value: Some(value), value: Some(value),
resource_type: Some(resource_type.to_owned()), resource_type: Some(resource_type.to_owned()),
ws_specific: None,
labels: None, labels: None,
}, },
) )