#![forbid(unsafe_code)]
use serde_json::Value;
use std::collections::HashMap;
use sz_rust_orm_facade::{Model, ModelExt, RelationLoader};
pub trait BaseModel: Model + ModelExt + RelationLoader + Send + Sync + 'static {
fn append() -> Vec<&'static str> {
Vec::new()
}
fn get_appended_value(&self, _field: &str) -> Option<Value> {
None
}
fn to_json_with_append(&self) -> Value {
let mut json = self.to_json();
if let Value::Object(ref mut map) = json {
for field in Self::append() {
let value = self.get_appended_value(field).unwrap_or(Value::Null);
map.insert(field.to_string(), value);
}
}
json
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MutatorResult {
Value(Value),
Skip,
}
pub trait Accessor {
fn data_map(&self) -> &HashMap<String, Value>;
fn data_map_mut(&mut self) -> &mut HashMap<String, Value>;
fn accessor_cache(&self) -> &HashMap<String, Value>;
fn accessor_cache_mut(&mut self) -> &mut HashMap<String, Value>;
fn real_field_name(&self, name: &str) -> String {
name.to_string()
}
fn accessor_for(&self, field: &str, value: Option<&Value>) -> Value {
let _ = field;
value.cloned().unwrap_or(Value::Null)
}
fn get_attr(&mut self, name: &str) -> Value {
let field = self.real_field_name(name);
if let Some(cached) = self.accessor_cache().get(&field) {
return cached.clone();
}
let value = self.data_map().get(&field);
let result = self.accessor_for(&field, value);
self.accessor_cache_mut().insert(field, result.clone());
result
}
fn get_data(&self, name: &str) -> Option<&Value> {
let field = self.real_field_name(name);
self.data_map().get(&field)
}
fn has_attr(&mut self, name: &str) -> bool {
!self.get_attr(name).is_null()
}
}
pub trait Mutator: Accessor {
fn mutator_for(
&mut self,
field: &str,
value: &Value,
merged_data: &HashMap<String, Value>,
) -> Option<MutatorResult>;
fn set_attr(&mut self, name: &str, value: Value, data: Option<&HashMap<String, Value>>) {
let field = self.real_field_name(name);
let merged_data = if let Some(d) = data {
let mut m = self.data_map().clone();
m.extend(d.clone());
m
} else {
self.data_map().clone()
};
let result = self.mutator_for(&field, &value, &merged_data);
match result {
Some(MutatorResult::Skip) => {
self.accessor_cache_mut().remove(&field);
}
Some(MutatorResult::Value(v)) => {
self.data_map_mut().insert(field.clone(), v);
self.accessor_cache_mut().remove(&field);
}
None => {
self.data_map_mut().insert(field.clone(), value);
self.accessor_cache_mut().remove(&field);
}
}
}
fn set_attrs(&mut self, data: &HashMap<String, Value>) {
let fields: Vec<(String, Value)> =
data.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (field, value) in fields {
self.set_attr(&field, value, Some(data));
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AppendState {
dynamic: Option<Vec<String>>,
}
impl AppendState {
pub fn new() -> Self {
Self::default()
}
pub fn replace(&mut self, fields: Vec<String>) {
self.dynamic = Some(fields);
}
pub fn merge(&mut self, fields: Vec<String>) {
match &mut self.dynamic {
Some(existing) => {
for field in fields {
if !existing.contains(&field) {
existing.push(field);
}
}
}
None => {
self.dynamic = Some(fields);
}
}
}
pub fn dynamic_fields(&self) -> Option<&Vec<String>> {
self.dynamic.as_ref()
}
}
pub trait Appendable: BaseModel + Accessor {
fn append_state(&self) -> &AppendState;
fn append_state_mut(&mut self) -> &mut AppendState;
fn append_dyn(&mut self, fields: Vec<String>) -> &mut Self {
self.append_state_mut().replace(fields);
self
}
fn append_merge(&mut self, fields: Vec<String>) -> &mut Self {
if self.append_state().dynamic_fields().is_none() {
let mut combined: Vec<String> = Self::append().iter().map(|s| s.to_string()).collect();
for field in fields {
if !combined.contains(&field) {
combined.push(field);
}
}
self.append_state_mut().replace(combined);
} else {
self.append_state_mut().merge(fields);
}
self
}
fn effective_append(&self) -> Vec<String> {
match self.append_state().dynamic_fields() {
Some(dyn_fields) => dyn_fields.clone(),
None => Self::append().iter().map(|s| s.to_string()).collect(),
}
}
fn to_json_with_append_cached(&mut self) -> Value {
let mut json = self.to_json();
if let Value::Object(ref mut map) = json {
let fields = self.effective_append();
for field in fields {
let value = self.get_attr(&field);
map.insert(field, value);
}
}
json
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
use sz_rust_orm_facade::Value as OrmValue;
use sz_rust_orm_facade::{Model, ModelExt, RelationLoader, TimestampFields};
struct UserWithoutAppend {
user_id: i64,
username: String,
password: String,
}
impl Model for UserWithoutAppend {
type PrimaryKey = i64;
fn table_name() -> &'static str {
"sz_user"
}
fn pk_name() -> &'static str {
"user_id"
}
fn pk(&self) -> Self::PrimaryKey {
self.user_id
}
fn set_pk(&mut self, pk: Self::PrimaryKey) {
self.user_id = pk;
}
fn timestamp_fields() -> Option<TimestampFields> {
None
}
fn soft_delete_field() -> Option<&'static str> {
None
}
}
impl ModelExt for UserWithoutAppend {
fn columns() -> Vec<&'static str> {
vec!["user_id", "username", "password"]
}
fn fillable() -> Vec<&'static str> {
vec!["username", "password"]
}
fn guarded() -> Vec<&'static str> {
vec!["user_id"]
}
fn hidden() -> Vec<&'static str> {
vec!["password"]
}
fn get_column_value(&self, column: &str) -> Option<OrmValue> {
match column {
"user_id" => Some(OrmValue::I64(self.user_id)),
"username" => Some(OrmValue::String(self.username.clone())),
"password" => Some(OrmValue::String(self.password.clone())),
_ => None,
}
}
fn from_value(&mut self, _map: HashMap<String, OrmValue>) {
}
}
impl RelationLoader for UserWithoutAppend {
fn get_relation(&self, _name: &str) -> Option<&OrmValue> {
None
}
fn set_relation_data(&mut self, _name: &str, _data: OrmValue) {}
fn get_relation_fk_value(&self, _fk_name: &str) -> String {
String::new()
}
}
impl BaseModel for UserWithoutAppend {}
struct CustomerWithAppend {
customer_id: i64,
status: i32,
name: String,
}
impl Model for CustomerWithAppend {
type PrimaryKey = i64;
fn table_name() -> &'static str {
"szoa_customer"
}
fn pk_name() -> &'static str {
"customer_id"
}
fn pk(&self) -> Self::PrimaryKey {
self.customer_id
}
fn set_pk(&mut self, pk: Self::PrimaryKey) {
self.customer_id = pk;
}
}
impl ModelExt for CustomerWithAppend {
fn columns() -> Vec<&'static str> {
vec!["customer_id", "status", "name"]
}
fn fillable() -> Vec<&'static str> {
vec!["status", "name"]
}
fn guarded() -> Vec<&'static str> {
vec!["customer_id"]
}
fn get_column_value(&self, column: &str) -> Option<OrmValue> {
match column {
"customer_id" => Some(OrmValue::I64(self.customer_id)),
"status" => Some(OrmValue::I32(self.status)),
"name" => Some(OrmValue::String(self.name.clone())),
_ => None,
}
}
fn from_value(&mut self, _map: HashMap<String, OrmValue>) {}
}
impl RelationLoader for CustomerWithAppend {
fn get_relation(&self, _name: &str) -> Option<&OrmValue> {
None
}
fn set_relation_data(&mut self, _name: &str, _data: OrmValue) {}
fn get_relation_fk_value(&self, _fk_name: &str) -> String {
String::new()
}
}
impl BaseModel for CustomerWithAppend {
fn append() -> Vec<&'static str> {
vec!["status_text"]
}
fn get_appended_value(&self, field: &str) -> Option<Value> {
match field {
"status_text" => Some(json!(match self.status {
0 => "禁用",
1 => "启用",
_ => "未知",
})),
_ => None,
}
}
}
#[test]
fn test_base_model_table_name() {
assert_eq!(UserWithoutAppend::table_name(), "sz_user");
assert_eq!(CustomerWithAppend::table_name(), "szoa_customer");
}
#[test]
fn test_base_model_pk_name() {
assert_eq!(UserWithoutAppend::pk_name(), "user_id");
assert_eq!(CustomerWithAppend::pk_name(), "customer_id");
}
#[test]
fn test_base_model_fillable() {
assert_eq!(UserWithoutAppend::fillable(), vec!["username", "password"]);
assert_eq!(CustomerWithAppend::fillable(), vec!["status", "name"]);
}
#[test]
fn test_base_model_guarded() {
assert_eq!(UserWithoutAppend::guarded(), vec!["user_id"]);
assert_eq!(CustomerWithAppend::guarded(), vec!["customer_id"]);
}
#[test]
fn test_base_model_hidden() {
assert_eq!(UserWithoutAppend::hidden(), vec!["password"]);
assert_eq!(CustomerWithAppend::hidden(), Vec::<&str>::new());
}
#[test]
fn test_base_model_pk_value() {
let user = UserWithoutAppend {
user_id: 42,
username: "alice".to_string(),
password: "secret".to_string(),
};
assert_eq!(user.pk(), 42);
}
#[test]
fn test_base_model_append_default_empty() {
assert_eq!(UserWithoutAppend::append(), Vec::<&str>::new());
}
#[test]
fn test_base_model_append_with_status_text() {
assert_eq!(CustomerWithAppend::append(), vec!["status_text"]);
}
#[test]
fn test_base_model_get_appended_value_default_none() {
let user = UserWithoutAppend {
user_id: 1,
username: "alice".to_string(),
password: "secret".to_string(),
};
assert_eq!(user.get_appended_value("any_field"), None);
}
#[test]
fn test_base_model_get_appended_value_status_text() {
let customer = CustomerWithAppend {
customer_id: 1,
status: 0,
name: "Alice Corp".to_string(),
};
assert_eq!(
customer.get_appended_value("status_text"),
Some(json!("禁用"))
);
let customer = CustomerWithAppend {
customer_id: 1,
status: 1,
name: "Alice Corp".to_string(),
};
assert_eq!(
customer.get_appended_value("status_text"),
Some(json!("启用"))
);
let customer = CustomerWithAppend {
customer_id: 1,
status: 99,
name: "Alice Corp".to_string(),
};
assert_eq!(
customer.get_appended_value("status_text"),
Some(json!("未知"))
);
assert_eq!(customer.get_appended_value("unknown_field"), None);
}
#[test]
fn test_base_model_to_json_without_append() {
let user = UserWithoutAppend {
user_id: 1,
username: "alice".to_string(),
password: "secret".to_string(),
};
let json = user.to_json_with_append();
assert_eq!(json["user_id"], 1);
assert_eq!(json["username"], "alice");
assert!(json.get("password").is_none(), "password 应被 hidden 隐藏");
assert!(json.get("status_text").is_none(), "无 append 字段");
}
#[test]
fn test_base_model_to_json_with_append() {
let customer = CustomerWithAppend {
customer_id: 1,
status: 1,
name: "Alice Corp".to_string(),
};
let json = customer.to_json_with_append();
assert_eq!(json["customer_id"], 1);
assert_eq!(json["status"], 1);
assert_eq!(json["name"], "Alice Corp");
assert_eq!(json["status_text"], "启用");
}
#[test]
fn test_base_model_to_json_append_field_order() {
let customer = CustomerWithAppend {
customer_id: 1,
status: 0,
name: "Test".to_string(),
};
let json = customer.to_json_with_append();
if let Value::Object(map) = json {
let keys: Vec<&String> = map.keys().collect();
let customer_id_pos = keys.iter().position(|k| *k == "customer_id").unwrap();
let status_pos = keys.iter().position(|k| *k == "status").unwrap();
let name_pos = keys.iter().position(|k| *k == "name").unwrap();
let status_text_pos = keys.iter().position(|k| *k == "status_text").unwrap();
assert!(
customer_id_pos < status_text_pos,
"customer_id 应在 status_text 之前"
);
assert!(status_pos < status_text_pos, "status 应在 status_text 之前");
assert!(name_pos < status_text_pos, "name 应在 status_text 之前");
} else {
panic!("to_json_with_append 应返回 JSON Object");
}
}
#[test]
fn test_php_consistency_model_name_aligns_php_name_property() {
assert_eq!(CustomerWithAppend::table_name(), "szoa_customer");
}
#[test]
fn test_php_consistency_model_pk_aligns_php_pk_property() {
assert_eq!(CustomerWithAppend::pk_name(), "customer_id");
}
#[test]
fn test_php_consistency_model_append_aligns_php_append_property() {
assert_eq!(CustomerWithAppend::append(), vec!["status_text"]);
}
#[test]
fn test_php_consistency_model_hidden_aligns_php_hidden_property() {
let user = UserWithoutAppend {
user_id: 1,
username: "alice".to_string(),
password: "secret".to_string(),
};
let json = user.to_json_with_append();
assert!(
json.get("password").is_none(),
"password 应被 hidden 隐藏(对齐 PHP $hidden)"
);
}
#[test]
fn test_php_consistency_get_xxx_attr_aligns_php_accessor() {
let test_cases = vec![(0i32, "禁用"), (1, "启用"), (99, "未知")];
for (status, expected) in test_cases {
let customer = CustomerWithAppend {
customer_id: 1,
status,
name: "Test".to_string(),
};
assert_eq!(
customer.get_appended_value("status_text"),
Some(json!(expected)),
"status={} 应返回 '{}'",
status,
expected
);
}
}
#[test]
fn test_php_consistency_serialization_includes_append_fields() {
let customer = CustomerWithAppend {
customer_id: 1,
status: 1,
name: "Alice Corp".to_string(),
};
let json = customer.to_json_with_append();
assert_eq!(json["customer_id"], 1);
assert_eq!(json["status"], 1);
assert_eq!(json["name"], "Alice Corp");
assert_eq!(json["status_text"], "启用");
}
struct AccessorTestModel {
data: HashMap<String, Value>,
get_cache: HashMap<String, Value>,
}
impl AccessorTestModel {
fn new() -> Self {
Self {
data: HashMap::new(),
get_cache: HashMap::new(),
}
}
fn with_data(mut self, key: &str, value: Value) -> Self {
self.data.insert(key.to_string(), value);
self
}
}
impl Accessor for AccessorTestModel {
fn data_map(&self) -> &HashMap<String, Value> {
&self.data
}
fn data_map_mut(&mut self) -> &mut HashMap<String, Value> {
&mut self.data
}
fn accessor_cache(&self) -> &HashMap<String, Value> {
&self.get_cache
}
fn accessor_cache_mut(&mut self) -> &mut HashMap<String, Value> {
&mut self.get_cache
}
fn accessor_for(&self, field: &str, value: Option<&Value>) -> Value {
match field {
"status_text" => {
let status = self
.data
.get("status")
.and_then(|v| v.as_i64())
.unwrap_or(-1);
json!(match status {
0 => "禁用",
1 => "启用",
_ => "未知",
})
}
"rentarea_ids" => {
let raw = value.and_then(|v| v.as_str()).unwrap_or("");
if raw.is_empty() {
json!([])
} else {
let arr: Vec<Value> = raw
.split(',')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<i64>().ok())
.map(Value::from)
.collect();
json!(arr)
}
}
"contract_price" => {
let price = value
.and_then(|v| {
if v.is_null() {
Some(0.0)
} else if let Some(f) = v.as_f64() {
Some(f)
} else if let Some(s) = v.as_str() {
s.parse::<f64>().ok()
} else {
None
}
})
.unwrap_or(0.0);
json!(price)
}
_ => value.cloned().unwrap_or(Value::Null),
}
}
}
impl Mutator for AccessorTestModel {
fn mutator_for(
&mut self,
field: &str,
value: &Value,
merged_data: &HashMap<String, Value>,
) -> Option<MutatorResult> {
match field {
"rentarea_ids" => {
let arr: Vec<String> = match value {
Value::Array(items) => items
.iter()
.filter_map(|v| {
let s = match v {
Value::String(s) => s.trim().to_string(),
_ => v.to_string(),
};
if s.is_empty() {
None
} else {
Some(s)
}
})
.collect(),
_ => return Some(MutatorResult::Value(Value::String(String::new()))),
};
Some(MutatorResult::Value(Value::String(arr.join(","))))
}
"field_b" => {
let field_a = merged_data
.get("field_a")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let val_b = value.as_i64().unwrap_or(0);
Some(MutatorResult::Value(json!(format!(
"{}_{}",
field_a, val_b
))))
}
"special_attr" => {
self.data.insert("field_a".to_string(), json!("A"));
self.data.insert("field_b".to_string(), json!("B"));
Some(MutatorResult::Skip)
}
_ => None,
}
}
}
#[test]
fn test_accessor_basic_status_text() {
let mut model = AccessorTestModel::new().with_data("status", json!(0));
assert_eq!(model.get_attr("status_text"), json!("禁用"));
let mut model = AccessorTestModel::new().with_data("status", json!(1));
assert_eq!(model.get_attr("status_text"), json!("启用"));
let mut model = AccessorTestModel::new().with_data("status", json!(99));
assert_eq!(model.get_attr("status_text"), json!("未知"));
}
#[test]
fn test_accessor_real_field_value() {
let mut model = AccessorTestModel::new().with_data("rentarea_ids", json!("1,2,3"));
assert_eq!(model.get_attr("rentarea_ids"), json!([1, 2, 3]));
let mut model = AccessorTestModel::new().with_data("rentarea_ids", json!(""));
assert_eq!(model.get_attr("rentarea_ids"), json!([]));
}
#[test]
fn test_accessor_float_coercion() {
let mut model = AccessorTestModel::new().with_data("contract_price", Value::Null);
assert_eq!(model.get_attr("contract_price"), json!(0.0));
let mut model = AccessorTestModel::new().with_data("contract_price", json!("100.5"));
assert_eq!(model.get_attr("contract_price"), json!(100.5));
}
#[test]
fn test_accessor_cache_hit() {
let mut model = AccessorTestModel::new().with_data("status", json!(1));
let v1 = model.get_attr("status_text");
model.data.insert("status".to_string(), json!(0));
let v2 = model.get_attr("status_text");
assert_eq!(v1, v2, "缓存命中,访问器不重新执行");
assert_eq!(v1, json!("启用"));
}
#[test]
fn test_accessor_cache_invalidation_on_set_same_field() {
let mut model = AccessorTestModel::new().with_data("status", json!(1));
let v1 = model.get_attr("status_text");
assert_eq!(v1, json!("启用"));
model.set_attr("status", json!(0), None);
let v2 = model.get_attr("status_text");
assert_eq!(v2, json!("启用"), "status_text 缓存未失效(PHP bug 复刻)");
}
#[test]
fn test_mutator_basic_array_to_string() {
let mut model = AccessorTestModel::new();
model.set_attr("rentarea_ids", json!([1, 2, 3]), None);
assert_eq!(model.data.get("rentarea_ids"), Some(&json!("1,2,3")));
model.set_attr("rentarea_ids", json!([]), None);
assert_eq!(model.data.get("rentarea_ids"), Some(&json!("")));
}
#[test]
fn test_mutator_skip_php_bug_replication() {
let mut model = AccessorTestModel::new();
model.set_attr("special_attr", json!("X"), None);
assert!(
!model.data.contains_key("special_attr"),
"special_attr 应被跳过(PHP bug 复刻)"
);
assert_eq!(model.data.get("field_a"), Some(&json!("A")));
assert_eq!(model.data.get("field_b"), Some(&json!("B")));
}
#[test]
fn test_mutator_merged_data() {
let mut model = AccessorTestModel::new();
model.data.insert("field_a".to_string(), json!(1));
let mut batch = HashMap::new();
batch.insert("field_a".to_string(), json!(100));
batch.insert("field_b".to_string(), json!(2));
model.set_attrs(&batch);
assert_eq!(model.data.get("field_b"), Some(&json!("100_2")));
}
#[test]
fn test_set_attrs_batch() {
let mut model = AccessorTestModel::new();
let mut batch = HashMap::new();
batch.insert("status".to_string(), json!(1));
batch.insert("name".to_string(), json!("Alice"));
model.set_attrs(&batch);
assert_eq!(model.data.get("status"), Some(&json!(1)));
assert_eq!(model.data.get("name"), Some(&json!("Alice")));
}
#[test]
fn test_has_attr_triggers_accessor() {
let mut model = AccessorTestModel::new().with_data("status", json!(1));
assert!(model.has_attr("status_text"), "status_text 应存在");
assert!(!model.has_attr("nonexistent"), "不存在的字段应返回 false");
}
#[test]
fn test_get_data_returns_raw_value() {
let model = AccessorTestModel::new().with_data("rentarea_ids", json!("1,2,3"));
assert_eq!(model.get_data("rentarea_ids"), Some(&json!("1,2,3")));
}
#[test]
fn test_accessor_for_unknown_field_returns_null() {
let mut model = AccessorTestModel::new();
let v = model.get_attr("nonexistent_field");
assert!(v.is_null(), "未知字段应返回 Null");
}
#[test]
fn test_real_field_name_default_identity() {
let model = AccessorTestModel::new();
assert_eq!(model.real_field_name("status_text"), "status_text");
assert_eq!(model.real_field_name("user_id"), "user_id");
}
#[test]
fn test_php_consistency_accessor_cache_asymmetric_invalidation() {
let mut model = AccessorTestModel::new().with_data("status", json!(1));
let v1 = model.get_attr("status_text");
assert_eq!(v1, json!("启用"));
model.set_attr("status", json!(0), None);
let v2 = model.get_attr("status_text");
assert_eq!(
v2,
json!("启用"),
"PHP bug 复刻:status_text 缓存未失效,仍返回旧值"
);
}
#[test]
fn test_php_consistency_mutator_skip_with_data_modification() {
let mut model = AccessorTestModel::new();
model.set_attr("special_attr", json!("X"), None);
assert!(
!model.data.contains_key("special_attr"),
"special_attr 应被跳过"
);
assert_eq!(model.data.get("field_a"), Some(&json!("A")));
assert_eq!(model.data.get("field_b"), Some(&json!("B")));
}
#[test]
fn test_php_consistency_mutator_receives_merged_data() {
let mut model = AccessorTestModel::new();
model.data.insert("field_a".to_string(), json!(1));
let mut batch = HashMap::new();
batch.insert("field_a".to_string(), json!(100));
batch.insert("field_b".to_string(), json!(2));
model.set_attrs(&batch);
assert_eq!(
model.data.get("field_b"),
Some(&json!("100_2")),
"修改器应使用 merged_data 中的 field_a=100"
);
}
#[test]
fn test_php_consistency_append_field_without_accessor_returns_null() {
let mut model = AccessorTestModel::new();
let v = model.get_attr("nonexistent_append_field");
assert!(v.is_null(), "PHP 行为复刻:$append 字段无访问器应返回 null");
}
#[test]
fn test_php_consistency_isset_triggers_accessor() {
let mut model = AccessorTestModel::new().with_data("status", json!(1));
assert!(model.has_attr("status_text"));
let v = model.get_attr("status_text");
assert_eq!(v, json!("启用"));
}
#[test]
fn test_php_consistency_accessor_overrides_raw_value() {
let mut model = AccessorTestModel::new().with_data("contract_price", Value::Null);
assert_eq!(model.get_data("contract_price"), Some(&Value::Null));
assert_eq!(model.get_attr("contract_price"), json!(0.0));
}
#[test]
fn test_php_consistency_set_attrs_preserves_batch_context() {
let mut model = AccessorTestModel::new();
let mut batch = HashMap::new();
batch.insert("field_a".to_string(), json!(50));
batch.insert("field_b".to_string(), json!(99));
model.set_attrs(&batch);
assert_eq!(model.data.get("field_b"), Some(&json!("50_99")));
assert_eq!(model.data.get("field_a"), Some(&json!(50)));
}
struct AppendableTestModel {
data: HashMap<String, Value>,
get_cache: HashMap<String, Value>,
append_state: AppendState,
}
impl AppendableTestModel {
fn new() -> Self {
Self {
data: HashMap::new(),
get_cache: HashMap::new(),
append_state: AppendState::new(),
}
}
fn with_data(mut self, key: &str, value: Value) -> Self {
self.data.insert(key.to_string(), value);
self
}
}
impl Model for AppendableTestModel {
type PrimaryKey = i64;
fn table_name() -> &'static str {
"test_appendable"
}
fn pk_name() -> &'static str {
"id"
}
fn pk(&self) -> Self::PrimaryKey {
self.data.get("id").and_then(|v| v.as_i64()).unwrap_or(0)
}
fn set_pk(&mut self, pk: Self::PrimaryKey) {
self.data.insert("id".to_string(), json!(pk));
}
}
impl ModelExt for AppendableTestModel {
fn columns() -> Vec<&'static str> {
vec![
"id",
"status",
"name",
"password",
"add_time",
"sales_initial",
"sales_actual",
]
}
fn fillable() -> Vec<&'static str> {
vec![
"status",
"name",
"password",
"add_time",
"sales_initial",
"sales_actual",
]
}
fn guarded() -> Vec<&'static str> {
vec!["id"]
}
fn hidden() -> Vec<&'static str> {
vec!["password"]
}
fn get_column_value(&self, column: &str) -> Option<OrmValue> {
match column {
"id" => self
.data
.get("id")
.and_then(|v| v.as_i64())
.map(OrmValue::I64),
"status" => self
.data
.get("status")
.and_then(|v| v.as_i64())
.map(|i| OrmValue::I32(i as i32)),
"name" => self
.data
.get("name")
.and_then(|v| v.as_str())
.map(|s| OrmValue::String(s.to_string())),
"password" => self
.data
.get("password")
.and_then(|v| v.as_str())
.map(|s| OrmValue::String(s.to_string())),
"add_time" => self
.data
.get("add_time")
.and_then(|v| v.as_i64())
.map(OrmValue::I64),
"sales_initial" => self
.data
.get("sales_initial")
.and_then(|v| v.as_i64())
.map(OrmValue::I64),
"sales_actual" => self
.data
.get("sales_actual")
.and_then(|v| v.as_i64())
.map(OrmValue::I64),
_ => None,
}
}
fn from_value(&mut self, _map: HashMap<String, OrmValue>) {}
}
impl RelationLoader for AppendableTestModel {
fn get_relation(&self, _name: &str) -> Option<&OrmValue> {
None
}
fn set_relation_data(&mut self, _name: &str, _data: OrmValue) {}
fn get_relation_fk_value(&self, _fk_name: &str) -> String {
String::new()
}
}
impl BaseModel for AppendableTestModel {
fn append() -> Vec<&'static str> {
vec!["status_text", "no_accessor_field"]
}
}
impl Accessor for AppendableTestModel {
fn data_map(&self) -> &HashMap<String, Value> {
&self.data
}
fn data_map_mut(&mut self) -> &mut HashMap<String, Value> {
&mut self.data
}
fn accessor_cache(&self) -> &HashMap<String, Value> {
&self.get_cache
}
fn accessor_cache_mut(&mut self) -> &mut HashMap<String, Value> {
&mut self.get_cache
}
fn accessor_for(&self, field: &str, _value: Option<&Value>) -> Value {
match field {
"status_text" => {
let status = self
.data
.get("status")
.and_then(|v| v.as_i64())
.unwrap_or(-1);
json!(match status {
0 => "禁用",
1 => "启用",
_ => "未知",
})
}
"stat_day" => {
let timestamp = self
.data
.get("add_time")
.and_then(|v| v.as_i64())
.unwrap_or(0);
json!(format!("day_{}", timestamp / 86400))
}
"product_sales" => {
let initial = self
.data
.get("sales_initial")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let actual = self
.data
.get("sales_actual")
.and_then(|v| v.as_i64())
.unwrap_or(0);
json!(initial + actual)
}
_ => Value::Null,
}
}
}
impl Appendable for AppendableTestModel {
fn append_state(&self) -> &AppendState {
&self.append_state
}
fn append_state_mut(&mut self) -> &mut AppendState {
&mut self.append_state
}
}
#[test]
fn test_base_model_to_json_with_append_outputs_null_for_no_accessor() {
let model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1));
let json = model.to_json_with_append();
assert_eq!(
json["status_text"],
Value::Null,
"无访问器 append 字段应输出 null"
);
assert_eq!(
json["no_accessor_field"],
Value::Null,
"无访问器 append 字段应输出 null"
);
}
#[test]
fn test_appendable_to_json_with_append_cached_uses_accessor() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1));
let json = model.to_json_with_append_cached();
assert_eq!(json["status_text"], "启用");
assert_eq!(json["no_accessor_field"], Value::Null);
}
#[test]
fn test_appendable_caches_accessor_result() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1));
let json1 = model.to_json_with_append_cached();
assert_eq!(json1["status_text"], "启用");
model.data.insert("status".to_string(), json!(0));
let json2 = model.to_json_with_append_cached();
assert_eq!(
json2["status_text"], "启用",
"缓存命中,访问器不重新执行(PHP bug 复刻)"
);
}
#[test]
fn test_append_field_bypasses_hidden_filter() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1))
.with_data("password", json!("secret"));
let json = model.to_json_with_append_cached();
assert!(json.get("password").is_none(), "password 应被 hidden 过滤");
assert_eq!(json["status_text"], "启用");
}
#[test]
fn test_append_dyn_overrides_static_append() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1));
model.append_dyn(vec!["dynamic_field".to_string()]);
let json = model.to_json_with_append_cached();
assert!(
json.get("status_text").is_none(),
"status_text 应被动态 append 覆盖"
);
assert!(
json.get("no_accessor_field").is_none(),
"no_accessor_field 应被动态 append 覆盖"
);
assert_eq!(json["dynamic_field"], Value::Null);
}
#[test]
fn test_append_merge_combines_with_static() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1));
model.append_merge(vec!["extra_field".to_string()]);
let json = model.to_json_with_append_cached();
assert_eq!(json["status_text"], "启用");
assert_eq!(json["no_accessor_field"], Value::Null);
assert_eq!(json["extra_field"], Value::Null);
}
#[test]
fn test_append_dyn_returns_self_for_chaining() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1));
model
.append_merge(vec!["field1".to_string()])
.append_merge(vec!["field2".to_string()]);
let json = model.to_json_with_append_cached();
assert!(json.get("field1").is_some(), "链式 append_merge 应生效");
assert!(json.get("field2").is_some(), "链式 append_merge 应生效");
assert_eq!(json["status_text"], "启用");
}
#[test]
fn test_effective_append_priority() {
let model = AppendableTestModel::new();
assert_eq!(
model.effective_append(),
vec!["status_text".to_string(), "no_accessor_field".to_string()]
);
let mut model = model;
model.append_dyn(vec!["override".to_string()]);
assert_eq!(model.effective_append(), vec!["override".to_string()]);
}
#[test]
fn test_append_state_replace_and_merge() {
let mut state = AppendState::new();
assert!(state.dynamic_fields().is_none(), "初始状态无动态字段");
state.replace(vec!["a".to_string(), "b".to_string()]);
assert_eq!(
state.dynamic_fields().unwrap(),
&vec!["a".to_string(), "b".to_string()]
);
state.merge(vec!["b".to_string(), "c".to_string()]);
assert_eq!(
state.dynamic_fields().unwrap(),
&vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
}
#[test]
fn test_php_consistency_status_text_pattern() {
let test_cases = vec![(0i64, "禁用"), (1, "启用"), (99, "未知")];
for (status, expected) in test_cases {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(status));
let json = model.to_json_with_append_cached();
assert_eq!(
json["status_text"], expected,
"status={} 应返回 '{}'",
status, expected
);
}
}
#[test]
fn test_php_consistency_stat_day_pattern() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("add_time", json!(1690000000));
model.append_merge(vec!["stat_day".to_string()]);
let json = model.to_json_with_append_cached();
assert_eq!(json["stat_day"], "day_19560");
}
#[test]
fn test_php_consistency_product_sales_pattern() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("sales_initial", json!(100))
.with_data("sales_actual", json!(50));
model.append_merge(vec!["product_sales".to_string()]);
let json = model.to_json_with_append_cached();
assert_eq!(json["product_sales"], 150);
}
#[test]
fn test_php_consistency_append_always_outputs_even_without_accessor() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1));
let json = model.to_json_with_append_cached();
assert_eq!(
json["no_accessor_field"],
Value::Null,
"PHP 行为复刻:append 字段无访问器应输出 null"
);
}
#[test]
fn test_php_consistency_append_overrides_hidden() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1))
.with_data("password", json!("secret"));
let json = model.to_json_with_append_cached();
assert!(json.get("password").is_none(), "password 应被 hidden 过滤");
assert_eq!(json["status_text"], "启用", "append 字段应绕过 hidden");
}
#[test]
fn test_php_consistency_dynamic_append_overrides_static() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1));
model.append_dyn(vec!["stat_day".to_string()]);
let json = model.to_json_with_append_cached();
assert!(
json.get("status_text").is_none(),
"动态 append 应覆盖静态,status_text 不应输出"
);
assert!(json.get("stat_day").is_some(), "动态 append 字段应输出");
}
#[test]
fn test_php_consistency_append_method_returns_this_for_chaining() {
let mut model = AppendableTestModel::new()
.with_data("id", json!(1))
.with_data("status", json!(1));
model
.append_merge(vec!["stat_day".to_string()])
.append_merge(vec!["product_sales".to_string()]);
let json = model.to_json_with_append_cached();
assert_eq!(json["status_text"], "启用");
assert!(json.get("stat_day").is_some());
assert!(json.get("product_sales").is_some());
}
}