forked from 1b-agent/opencadstudio-upstream
feat(plugin): name-addressed XRecord CRUD on the HostApi (API v8)
Add create/read/update/delete/list for XRecord objects under named-object-dictionary paths to the plugin HostApi, with five additive PluginRequest/PluginResponse variants, dispatch through the shared handler, in-process HostSession methods and both out-of-process proxies. - dictionary paths walk from the NOD root; create seeds missing dictionaries (ensure_image_dictionary bootstrap) and refuses existing names; update refuses missing ones; refusals precede the undo step - entry group codes validated (1..=369, never 5/105); payload content is the plugin's own - each mutating call is one undo step: the pending history snapshot restores the objects map byte-exactly, auto-created dictionaries included (tested) - typed entries survive DWG and DXF write/reload round-trips (tested); update drops verbatim raw bytes so saves emit the new content - bump API_VERSION 7 -> 8 with the build.rs version_info in lockstep, mirroring the v7 addition; MIN_SUPPORTED stays 3
This commit is contained in:
1 parent
6cde0e02d8
commit
ca2f0c9857
11 files changed
+1212
-3
No files matched your search
@@ -603,6 +603,95 @@ impl HostApi for PluginHostApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
match self.client.request(PluginRequest::CreateXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
entries,
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordCreateResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_xrecord(
|
||||
&self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<Vec<crate::host::XRecordEntry>, String> {
|
||||
match self.client.request(PluginRequest::ReadXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordEntries(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
match self.client.request(PluginRequest::UpdateXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
entries,
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordUpdateResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<crate::host::XRecord, String> {
|
||||
match self.client.request(PluginRequest::DeleteXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordDeleteResult(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
|
||||
match self.client.request(PluginRequest::ListXRecords {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordNames(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord list response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentinel reader used when the shared-memory view could not be initialized.
|
||||
@@ -810,4 +899,68 @@ mod tests {
|
||||
assert!(api.remove_entity(Handle::new(7)));
|
||||
peer_handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xrecord_requests_await_their_typed_responses() {
|
||||
let (mut api, mut peer) = make_client();
|
||||
let peer_handle = thread::spawn(move || {
|
||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||
match msg {
|
||||
PluginToHost::Request(req) => match *req {
|
||||
PluginRequest::CreateXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
entries,
|
||||
} => {
|
||||
assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]);
|
||||
assert_eq!(name, "alpha");
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")]
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
send(
|
||||
&mut peer,
|
||||
&HostToPlugin::Response(Box::new(PluginResponse::XRecordCreateResult(Ok(
|
||||
Handle::new(21),
|
||||
)))),
|
||||
)
|
||||
.unwrap();
|
||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||
match msg {
|
||||
PluginToHost::Request(req) => match *req {
|
||||
PluginRequest::ListXRecords { dictionary_path } => {
|
||||
assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
send(
|
||||
&mut peer,
|
||||
&HostToPlugin::Response(Box::new(PluginResponse::XRecordNames(Ok(vec![
|
||||
"alpha".to_owned()
|
||||
])))),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
assert_eq!(
|
||||
api.create_xrecord(
|
||||
&["CVIFC"],
|
||||
"alpha",
|
||||
vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")]
|
||||
)
|
||||
.unwrap(),
|
||||
Handle::new(21)
|
||||
);
|
||||
assert_eq!(
|
||||
api.list_xrecords(&["CVIFC"]).unwrap(),
|
||||
vec!["alpha".to_owned()]
|
||||
);
|
||||
peer_handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,32 @@ pub enum PluginRequest {
|
||||
RunCommand {
|
||||
request: crate::host::CommandRequest,
|
||||
},
|
||||
/// V8 (additive): create an XRecord under a named-object-dictionary path.
|
||||
CreateXRecord {
|
||||
dictionary_path: Vec<String>,
|
||||
name: String,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
},
|
||||
/// V8 (additive): read the entries of an existing XRecord.
|
||||
ReadXRecord {
|
||||
dictionary_path: Vec<String>,
|
||||
name: String,
|
||||
},
|
||||
/// V8 (additive): replace the entries of an existing XRecord.
|
||||
UpdateXRecord {
|
||||
dictionary_path: Vec<String>,
|
||||
name: String,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
},
|
||||
/// V8 (additive): delete an XRecord and its dictionary entry.
|
||||
DeleteXRecord {
|
||||
dictionary_path: Vec<String>,
|
||||
name: String,
|
||||
},
|
||||
/// V8 (additive): list the XRecord names under a dictionary path.
|
||||
ListXRecords {
|
||||
dictionary_path: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Responses the host sends back for `PluginRequest`.
|
||||
@@ -236,6 +262,16 @@ pub enum PluginResponse {
|
||||
SolidResult(Result<Handle, String>),
|
||||
TableResult(Result<Handle, String>),
|
||||
CommandResult(Result<crate::host::CommandOutcome, String>),
|
||||
/// V8 (additive): handle of a created XRecord.
|
||||
XRecordCreateResult(Result<Handle, String>),
|
||||
/// V8 (additive): entries of a read XRecord.
|
||||
XRecordEntries(Result<Vec<crate::host::XRecordEntry>, String>),
|
||||
/// V8 (additive): handle of an updated XRecord.
|
||||
XRecordUpdateResult(Result<Handle, String>),
|
||||
/// V8 (additive): the removed XRecord, with its prior content.
|
||||
XRecordDeleteResult(Result<crate::host::XRecord, String>),
|
||||
/// V8 (additive): XRecord names under a dictionary path.
|
||||
XRecordNames(Result<Vec<String>, String>),
|
||||
}
|
||||
|
||||
/// Messages sent from the host to the plugin runner.
|
||||
|
||||
@@ -117,5 +117,39 @@ pub fn handle_plugin_request(
|
||||
PluginResponse::TableResult(host.table_operation(operation))
|
||||
}
|
||||
RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)),
|
||||
CreateXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
entries,
|
||||
} => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordCreateResult(host.create_xrecord(&path, &name, entries))
|
||||
}
|
||||
ReadXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
} => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordEntries(host.read_xrecord(&path, &name))
|
||||
}
|
||||
UpdateXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
entries,
|
||||
} => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordUpdateResult(host.update_xrecord(&path, &name, entries))
|
||||
}
|
||||
DeleteXRecord {
|
||||
dictionary_path,
|
||||
name,
|
||||
} => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordDeleteResult(host.delete_xrecord(&path, &name))
|
||||
}
|
||||
ListXRecords { dictionary_path } => {
|
||||
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
|
||||
PluginResponse::XRecordNames(host.list_xrecords(&path))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,6 +898,95 @@ impl HostApi for V4PluginHostApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
match self.request(PluginRequest::CreateXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
entries,
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordCreateResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_xrecord(
|
||||
&self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<Vec<crate::host::XRecordEntry>, String> {
|
||||
match self.request(PluginRequest::ReadXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordEntries(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
entries: Vec<crate::host::XRecordEntry>,
|
||||
) -> Result<Handle, String> {
|
||||
match self.request(PluginRequest::UpdateXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
entries,
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordUpdateResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_xrecord(
|
||||
&mut self,
|
||||
dictionary_path: &[&str],
|
||||
name: &str,
|
||||
) -> Result<crate::host::XRecord, String> {
|
||||
match self.request(PluginRequest::DeleteXRecord {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
name: name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordDeleteResult(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
|
||||
match self.request(PluginRequest::ListXRecords {
|
||||
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
|
||||
}) {
|
||||
Ok(PluginResponse::XRecordNames(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected XRecord list response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -388,6 +388,111 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xrecord_request_and_response_roundtrip() {
|
||||
// The five XRecord request variants (API v8) serialize with their
|
||||
// dictionary paths and typed entries, and the responses answer with
|
||||
// handles, entries, records and names.
|
||||
let entries = vec![
|
||||
crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}"),
|
||||
crate::host::XRecordEntry::int16(70, 3),
|
||||
];
|
||||
let bytes = bincode::serialize(&PluginRequest::CreateXRecord {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
name: "alpha".to_owned(),
|
||||
entries: entries.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::CreateXRecord { dictionary_path, name, entries: decoded } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginRequest::ReadXRecord {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
name: "alpha".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::ReadXRecord { dictionary_path, name } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginRequest::UpdateXRecord {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
name: "alpha".to_owned(),
|
||||
entries: entries.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::UpdateXRecord { dictionary_path, name, entries: decoded } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginRequest::DeleteXRecord {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
name: "alpha".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::DeleteXRecord { dictionary_path, name } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginRequest::ListXRecords {
|
||||
dictionary_path: vec!["CVIFC".to_owned()],
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::ListXRecords { dictionary_path } if
|
||||
dictionary_path == vec!["CVIFC".to_owned()])
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginResponse::XRecordCreateResult(Ok(
|
||||
codec::Handle::new(42),
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordCreateResult(Ok(handle)) if handle == codec::Handle::new(42))
|
||||
);
|
||||
let bytes =
|
||||
bincode::serialize(&PluginResponse::XRecordEntries(Ok(entries.clone()))).unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordEntries(Ok(decoded)) if decoded == entries)
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginResponse::XRecordUpdateResult(Ok(
|
||||
codec::Handle::new(42),
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordUpdateResult(Ok(handle)) if handle == codec::Handle::new(42))
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginResponse::XRecordNames(Ok(vec!["alpha".to_owned()])))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordNames(Ok(names)) if names == vec!["alpha".to_owned()])
|
||||
);
|
||||
let deleted = crate::host::XRecord::named("alpha");
|
||||
let bytes =
|
||||
bincode::serialize(&PluginResponse::XRecordDeleteResult(Ok(deleted.clone()))).unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordDeleteResult(Ok(record)) if record == deleted)
|
||||
);
|
||||
let bytes = bincode::serialize(&PluginResponse::XRecordEntries(Err(
|
||||
"XRecord reads are not supported by this host".to_owned(),
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::XRecordEntries(Err(error)) if error.contains("not supported"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_notification_roundtrip() {
|
||||
let frame = PluginToHostV4::Notification(NotificationEnvelope {
|
||||
|
||||
Reference in new issue
Block a user