在 OData API 设计中,集合属性(Collection-Valued Property)是指一个实体拥有的属性类型为集合(如 List<string>、List<Address>)。
支持的接口
Request Method Route Template 说明 GET ~/{entityset}/{key}/{property} 查询基类类型Entity的属性值 GET ~/{entityset}/{key}/{cast}/{property} 查询派生类型Entity的属性值 GET ~/{singleton}/{property} 查询基类类型单例的属性值 GET ~/{singleton}/{cast}/{property} 查询派生类型单例的属性值 GET ~/{entityset}/{key}/{collectionvaluedproperty}/$count 查询基类类型Entity的基础类型集合属性值的个数 GET ~/{entityset}/{key}/{cast}/{collectionvaluedproperty}/$count 查询派生类型Entity的基础类型集合属性值的个数 GET ~/{singleton}/{collectionvaluedproperty}/$count 查询基类类型单例的基础类型集合属性值的个数 GET ~/{singleton}/{cast}/{collectionvaluedproperty}/$count 查询派生类型单例的基础类型集合属性值的个数 POST ~/{entityset}/{key}/{collectionvaluedproperty} 新增基类类型Entity的基础类型集合属性值 POST ~/{entityset}/{key}/{cast}/{collectionvaluedproperty} 新增派生类型Entity的基础类型集合属性值 POST ~/{singleton}/{collectionvaluedproperty} 新增基类类型单例的基础类型集合属性值 POST ~/{singleton}/{cast}/{collectionvaluedproperty} 新增派生类型单例的基础类型集合属性值 PUT ~/{entityset}/{key}/{property} 完整更新基类类型Entity的属性值 PUT ~/{entityset}/{key}/{cast}/{property} 完整更新派生类型Entity的属性值 PUT ~/{singleton}/{property} 完整更新基类类型单例的属性值 PUT ~/{singleton}/{cast}/{property} 完整更新派生类型单例的属性值 DELETE ~/{entityset}/{key}/{nullableproperty} 删除基类类型Entity的非空属性 DELETE ~/{entityset}/{key}/{cast}/{nullableproperty} 删除派生类型Entity的非空属性 DELETE ~/{singleton}/{nullableproperty} 删除基类类型单例的非空属性 DELETE ~/{singleton}/{cast}/{nullableproperty} 删除派生类型单例的非空属性
主要模型设计
在项目下新增Models文件夹,并添加Address、PostalAddress 、Customer和EnterpriseCustomer类。
namespace Lesson8. Models
{
public class Address
{
public required string Street { get ; set ; }
}
}
namespace Lesson8. Models
{
public class PostalAddress : Address
{
public required string PostalCode { get ; set ; }
}
}
using System. Net ;
namespace Lesson8. Models
{
using System. Collections. Generic ;
public class Customer
{
public int Id { get ; set ; }
public string ? Name { get ; set ; }
public Address? BillingAddress { get ; set ; }
public List< string > ContactPhones { get ; set ; } = [ ] ;
}
}
using System. Net ;
namespace Lesson8. Models
{
using System. Collections. Generic ;
public class EnterpriseCustomer : Customer
{
public decimal ? CreditLimit { get ; set ; }
public Address? RegisteredAddress { get ; set ; }
public List< Address> ShippingAddresses { get ; set ; } = new List< Address> ( ) ;
}
}
控制器设计
在项目中新增Controller文件夹,然后添加CompanyController类。该类注册于ODataController,以便拥有如下能力:
OData 路由支持 继承 ODataController 后,控制器自动支持 OData 路由(如 /odata/Shapes(1)),可以直接响应 OData 标准的 URL 路径和操作。 OData 查询参数支持 可以使用 [EnableQuery] 特性,自动支持 $filter、$select、$orderby、$expand 等 OData 查询参数,无需手动解析。 OData 响应格式 返回的数据会自动序列化为 OData 标准格式(如 JSON OData),方便前端或其他系统消费。 OData Delta 支持 支持 Delta<T>、DeltaSet<T> 等类型,便于实现 PATCH、批量 PATCH 等 OData 特有的部分更新操作。 更丰富的 OData 语义 继承后可方便实现实体集、实体、导航属性、复杂类型等 OData 语义,提升 API 的表达能力。
using Lesson8. Models ;
using Microsoft. AspNetCore. Mvc ;
using Microsoft. AspNetCore. OData. Deltas ;
using Microsoft. AspNetCore. OData. Query ;
using Microsoft. AspNetCore. OData. Routing. Controllers ;
namespace Lesson8. Controllers
{
public class CustomersController : ODataController
{
}
}
下面我们在该类中填充逻辑。
数据源
private static List< Customer> customers = new List< Customer>
{
new Customer
{
Id = 1 ,
Name = "Customer 1" ,
ContactPhones = new List< string > { "761-116-1865" } ,
BillingAddress = new Address { Street = "Street 1A" }
} ,
new Customer
{
Id = 2 ,
Name = "Customer 2" ,
ContactPhones = new List< string > { "835-791-8257" } ,
BillingAddress = new PostalAddress { Street = "2A" , PostalCode = "14030" }
} ,
new EnterpriseCustomer
{
Id = 3 ,
Name = "Customer 3" ,
ContactPhones = new List< string > { "157-575-6005" } ,
BillingAddress = new Address { Street = "Street 3A" } ,
CreditLimit = 4200 ,
RegisteredAddress = new Address { Street = "Street 3B" } ,
ShippingAddresses = new List< Address>
{
new Address { Street = "Street 3C" }
}
} ,
new EnterpriseCustomer
{
Id = 4 ,
Name = "Customer 4" ,
ContactPhones = new List< string > { "724-096-6719" } ,
BillingAddress = new Address { Street = "Street 4A" } ,
CreditLimit = 3700 ,
RegisteredAddress = new PostalAddress { Street = "Street 4B" , PostalCode = "22109" } ,
ShippingAddresses = new List< Address>
{
new Address { Street = "Street 4C" }
}
}
} ;
查询(GET)
Request Method Route Template 说明 GET ~/{entityset}/{key}/{property} 查询基类类型Entity的属性值 GET ~/{entityset}/{key}/{cast}/{property} 查询派生类型Entity的属性值 GET ~/{singleton}/{property} 查询基类类型单例的属性值 GET ~/{singleton}/{cast}/{property} 查询派生类型单例的属性值 GET ~/{entityset}/{key}/{collectionvaluedproperty}/$count 查询基类类型Entity的基础类型集合属性值的个数 GET ~/{entityset}/{key}/{cast}/{collectionvaluedproperty}/$count 查询派生类型Entity的基础类型集合属性值的个数 GET ~/{singleton}/{collectionvaluedproperty}/$count 查询基类类型单例的基础类型集合属性值的个数 GET ~/{singleton}/{cast}/{collectionvaluedproperty}/$count 查询派生类型单例的基础类型集合属性值的个数
对于基类类型Entity的属性,我们选用Customer.BillingAddress。
[ EnableQuery ]
public ActionResult< IEnumerable< string > > GetContactPhones ( [ FromRoute ] int key)
{
var customer = customers. SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( customer == null )
{
return NotFound ( ) ;
}
return customer. ContactPhones;
}
对于派生类型Entity的属性,我们选用EnterpriseCustomer.ShippingAddresses。
[ EnableQuery ]
public ActionResult< IEnumerable< Address> > GetShippingAddressesFromEnterpriseCustomer ( [ FromRoute ] int key)
{
var enterpriseCustomer = customers. OfType < EnterpriseCustomer> ( ) . SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( enterpriseCustomer == null )
{
return NotFound ( ) ;
}
return enterpriseCustomer. ShippingAddresses;
}
查询集合属性的值
Request Method Route Template 说明 GET ~/{entityset}/{key}/{property} 查询基类类型Entity的属性值 GET ~/{entityset}/{key}/{cast}/{property} 查询派生类型Entity的属性值 GET ~/{singleton}/{property} 查询基类类型单例的属性值 GET ~/{singleton}/{cast}/{property} 查询派生类型单例的属性值
查询基类类型Entity的基础类型集合属性值
Request Method Route Template 说明 GET ~/{entityset}/{key}/{property} 查询基类类型Entity的属性值 GET ~/{singleton}/{cast}/{property} 查询派生类型单例的属性值
curl --location 'http://localhost:5119/odata/Customers(1)/ContactPhones'
{
"@odata.context" : "http://localhost:5119/odata/$metadata#Collection(Edm.String)" ,
"value" : [
"761-116-1865"
]
}
查询派生类型Entity的基础类型集合属性值
Request Method Route Template 说明 GET ~/{singleton}/{property} 查询基类类型单例的属性值 GET ~/{singleton}/{cast}/{property} 查询派生类型单例的属性值
curl --location 'http://localhost:5119/odata/Customers(3)/Lesson8.Models.EnterpriseCustomer/ShippingAddresses'
{
"@odata.context" : "http://localhost:5119/odata/$metadata#Customers(3)/Lesson8.Models.EnterpriseCustomer/ShippingAddresses" ,
"value" : [
{
"Street" : "Street 3C"
}
]
}
查询集合属性数量
Request Method Route Template 说明 GET ~/{entityset}/{key}/{collectionvaluedproperty}/$count 查询基类类型Entity的基础类型集合属性值的个数 GET ~/{entityset}/{key}/{cast}/{collectionvaluedproperty}/$count 查询派生类型Entity的基础类型集合属性值的个数 GET ~/{singleton}/{collectionvaluedproperty}/$count 查询基类类型单例的基础类型集合属性值的个数 GET ~/{singleton}/{cast}/{collectionvaluedproperty}/$count 查询派生类型单例的基础类型集合属性值的个数
查询基类类型Entity的基础类型集合属性值的个数
Request Method Route Template 说明 GET ~/{entityset}/{key}/{collectionvaluedproperty}/$count 查询基类类型Entity的基础类型集合属性值的个数 GET ~/{singleton}/{collectionvaluedproperty}/$count 查询基类类型单例的基础类型集合属性值的个数
curl --location 'http://localhost:5119/odata/Customers(1)/ContactPhones/$count'
1
查询派生类型Entity的基础类型集合属性值的个数
Request Method Route Template 说明 GET ~/{entityset}/{key}/{cast}/{collectionvaluedproperty}/$count 查询派生类型Entity的基础类型集合属性值的个数 GET ~/{singleton}/{cast}/{collectionvaluedproperty}/$count 查询派生类型单例的基础类型集合属性值的个数
curl --location 'http://localhost:5119/odata/Customers(3)/Lesson8.Models.EnterpriseCustomer/ShippingAddresses/$count'
1
创建(POST)
Request Method Route Template 说明 POST ~/{entityset}/{key}/{collectionvaluedproperty} 新增基类类型Entity的基础类型集合属性值 POST ~/{entityset}/{key}/{cast}/{collectionvaluedproperty} 新增派生类型Entity的基础类型集合属性值 POST ~/{singleton}/{collectionvaluedproperty} 新增基类类型单例的基础类型集合属性值 POST ~/{singleton}/{cast}/{collectionvaluedproperty} 新增派生类型单例的基础类型集合属性值
新增基类类型Entity的基础类型集合属性值
public ActionResult PostToContactPhones ( [ FromRoute ] int key, [ FromBody ] string contactPhone)
{
var customer = customers. SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( customer == null )
{
return NotFound ( ) ;
}
customer. ContactPhones. Add ( contactPhone) ;
return Accepted ( ) ;
}
curl --location 'http://localhost:5119/odata/Customers(1)/ContactPhones' \
--header 'Content-Type: application/json' \
--data '{
"value": "798-507-2014"
}'
新增派生类型Entity的基础类型集合属性值
public ActionResult PostToShippingAddressesFromEnterpriseCustomer ( [ FromRoute ] int key, [ FromBody ] Address address)
{
var enterpriseCustomer = customers. OfType < EnterpriseCustomer> ( ) . SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( enterpriseCustomer == null )
{
return NotFound ( ) ;
}
enterpriseCustomer. ShippingAddresses. Add ( address) ;
return Accepted ( ) ;
}
curl --location 'http://localhost:5119/odata/Customers(3)/Lesson8.Models.EnterpriseCustomer/ShippingAddresses' \
--header 'Content-Type: application/json' \
--data '{
"Street": "One Microsoft Way"
}'
完整更新(PUT)
Request Method Route Template 说明 PUT ~/{entityset}/{key}/{property} 完整更新基类类型Entity的属性值 PUT ~/{entityset}/{key}/{cast}/{property} 完整更新派生类型Entity的属性值 PUT ~/{singleton}/{property} 完整更新基类类型单例的属性值 PUT ~/{singleton}/{cast}/{property} 完整更新派生类型单例的属性值
完整更新基类类型Entity的属性值
Request Method Route Template 说明 PUT ~/{entityset}/{key}/{property} 完整更新基类类型Entity的属性值 PUT ~/{singleton}/{property} 完整更新基类类型单例的属性值
public ActionResult PutToContactPhones ( [ FromRoute ] int key, [ FromBody ] IEnumerable< string > contactPhones)
{
var customer = customers. SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( customer == null )
{
return NotFound ( ) ;
}
customer. ContactPhones = contactPhones?. ToList ( ) ?? [ ] ;
return Ok ( ) ;
}
curl --location --request PUT 'http://localhost:5119/odata/Customers(1)/ContactPhones' \
--header 'Content-Type: application/json' \
--data '{
"value": ["804-855-4049", "491-9198-476"]
}'
完整更新派生类型Entity的属性值
Request Method Route Template 说明 PUT ~/{entityset}/{key}/{cast}/{property} 完整更新派生类型Entity的属性值 PUT ~/{singleton}/{cast}/{property} 完整更新派生类型单例的属性值
public ActionResult PutToShippingAddressesFromEnterpriseCustomer ( [ FromRoute ] int key, [ FromBody ] IEnumerable< Address> shippingAddresses)
{
var enterpriseCustomer = customers. OfType < EnterpriseCustomer> ( ) . SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( enterpriseCustomer == null )
{
return NotFound ( ) ;
}
enterpriseCustomer. ShippingAddresses = shippingAddresses?. ToList ( ) ?? [ ] ;
return Ok ( ) ;
}
curl --location --request PUT 'http://localhost:5119/odata/Customers(3)/Lesson8.Models.EnterpriseCustomer/ShippingAddresses' \
--header 'Content-Type: application/json' \
--data '{
"value": [
{
"Street": "One Microsoft Way"
},
{
"Street": "One Google Way"
}
]
}
'
局部更新(PATCH)
在 OData 标准中,PATCH 方法主要用于部分更新实体的单值属性(如字符串、数字、复杂类型对象),而不直接支持集合属性(collection-valued property)的部分更新。 也就是说,OData PATCH 不能像 PATCH 单个对象那样,直接对集合属性(如 List<Address>、List<string>)进行“增量”或“部分”修改。
删除(DELETE)
Request Method Route Template 说明 DELETE ~/{entityset}/{key}/{nullableproperty} 删除基类类型Entity的非空属性 DELETE ~/{entityset}/{key}/{cast}/{nullableproperty} 删除派生类型Entity的非空属性 DELETE ~/{singleton}/{nullableproperty} 删除基类类型单例的非空属性 DELETE ~/{singleton}/{cast}/{nullableproperty} 删除派生类型单例的非空属性
删除操作只能支持非空的原始(primitive) 或者复杂(complex)property。本例中删除的Customer.ContactPhones以及EnterpriseCustomer.ShippingAddresses都是List类型,其值可以是null。
不管是“删除基类类型Entity的非空属性”,还是“删除派生类型Entity的非空属性“,下面三种删除方法不可以同时存在,否则请求可以被路由到错误的地址。 只有集合属性(如 ContactPhones)才支持带 Body 的 DELETE(用于删除集合中的某项),其他类型(原始(primitive)或者单值)的删除操作不能带Body。
删除基类类型Entity的非空属性
Request Method Route Template 说明 DELETE ~/{entityset}/{key}/{nullableproperty} 删除基类类型Entity的非空属性 DELETE ~/{singleton}/{nullableproperty} 删除基类类型单例的非空属性
清空集合
public ActionResult DeleteToContactPhones ( [ FromRoute ] int key)
{
var customer = customers. SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( customer == null )
{
return NotFound ( ) ;
}
customer. ContactPhones. Clear ( ) ;
return NoContent ( ) ;
}
curl --location --request DELETE 'http://localhost:5119/odata/Customers(1)/ContactPhones'
删除一组集合值
public ActionResult DeleteToContactPhones ( [ FromRoute ] int key, [ FromBody ] IEnumerable< string > contactPhones)
{
var customer = customers. SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( customer == null )
{
return NotFound ( ) ;
}
foreach ( var contactPhone in contactPhones)
{
customer. ContactPhones. Remove ( contactPhone) ;
}
return NoContent ( ) ;
}
curl --location --request DELETE 'http://localhost:5119/odata/Customers(1)/ContactPhones' \
--header 'Content-Type: application/json' \
--data '{
"value": ["804-855-4049", "491-9198-476"]
}'
删除集合中一个值
public ActionResult DeleteToContactPhones ( [ FromRoute ] int key, [ FromBody ] string contactPhone)
{
var customer = customers. SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( customer == null )
{
return NotFound ( ) ;
}
if ( ! customer. ContactPhones. Remove ( contactPhone) )
{
return NotFound ( ) ;
}
return NoContent ( ) ;
}
curl --location --request DELETE 'http://localhost:5119/odata/Customers(1)/ContactPhones' \
--header 'Content-Type: application/json' \
--data '{
"value": "798-507-2014"
}'
删除派生类型Entity的非空属性
Request Method Route Template 说明 DELETE ~/{entityset}/{key}/{cast}/{nullableproperty} 删除派生类型Entity的非空属性 DELETE ~/{singleton}/{cast}/{nullableproperty} 删除派生类型单例的非空属性
清空集合
public ActionResult DeleteToShippingAddressesFromEnterpriseCustomer ( [ FromRoute ] int key)
{
var enterpriseCustomer = customers. OfType < EnterpriseCustomer> ( ) . SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( enterpriseCustomer == null )
{
return NotFound ( ) ;
}
enterpriseCustomer. ShippingAddresses. Clear ( ) ;
return NoContent ( ) ;
}
curl --location --request DELETE 'http://localhost:5119/odata/Customers(3)/Lesson8.Models.EnterpriseCustomer/ShippingAddresses'
删除一组集合值
public ActionResult DeleteToShippingAddressesFromEnterpriseCustomer ( [ FromRoute ] int key, [ FromBody ] IEnumerable< Address> shippingAddresses)
{
var enterpriseCustomer = customers. OfType < EnterpriseCustomer> ( ) . SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( enterpriseCustomer == null )
{
return NotFound ( ) ;
}
foreach ( var address in shippingAddresses)
{
enterpriseCustomer. ShippingAddresses. Remove ( address) ;
}
return NoContent ( ) ;
}
curl --location --request DELETE 'http://localhost:5119/odata/Customers(3)/Lesson8.Models.EnterpriseCustomer/ShippingAddresses' \
--header 'Content-Type: application/json' \
--data '{
"value": [
{
"Street": "One Microsoft Way"
},
{
"Street": "One Google Way"
}
]
}
'
删除集合中一个值
public ActionResult DeleteToShippingAddressesFromEnterpriseCustomer ( [ FromRoute ] int key, [ FromBody ] Address address)
{
var enterpriseCustomer = customers. OfType < EnterpriseCustomer> ( ) . SingleOrDefault ( d => d. Id. Equals ( key) ) ;
if ( enterpriseCustomer == null )
{
return NotFound ( ) ;
}
if ( ! enterpriseCustomer. ShippingAddresses. Remove ( address) )
{
return NotFound ( ) ;
}
return NoContent ( ) ;
}
curl --location --request DELETE 'http://localhost:5119/odata/Customers(3)/Lesson8.Models.EnterpriseCustomer/ShippingAddresses' \
--header 'Content-Type: application/json' \
--data '{
"Street": "One Microsoft Way"
}'
主程序
using Lesson8. Models ;
using Microsoft. AspNetCore. OData ;
using Microsoft. OData. ModelBuilder ;
using Microsoft. OData. Edm ;
var builder = WebApplication. CreateBuilder ( args) ;
static IEdmModel GetEdmModel ( )
{
var modelBuilder = new ODataConventionModelBuilder ( ) ;
modelBuilder. EntitySet < Customer> ( "Customers" ) ;
return modelBuilder. GetEdmModel ( ) ;
}
builder. Services. AddControllers ( ) . AddOData ( options =>
options. Select ( )
. Filter ( )
. OrderBy ( )
. Expand ( )
. Count ( )
. SetMaxTop ( null )
. AddRouteComponents ( "odata" , GetEdmModel ( ) )
) ;
var app = builder. Build ( ) ;
app. UseRouting ( ) ;
app. MapControllers ( ) ;
app. Run ( ) ;
服务文档
curl --location 'http://localhost:5119/odata'
{
"@odata.context" : "http://localhost:5119/odata/$metadata" ,
"value" : [
{
"name" : "Customers" ,
"kind" : "EntitySet" ,
"url" : "Customers"
}
]
}
模型元文档
curl --location 'http://localhost:5119/odata/$metadata'
< ? xml version= "1.0" encoding= "utf-8" ? >
< edmx: Edmx Version= "4.0" xmlns : edmx= "http://docs.oasis-open.org/odata/ns/edmx" >
< edmx: DataServices>
< Schema Namespace= "Lesson8.Models" xmlns= "http://docs.oasis-open.org/odata/ns/edm" >
< EntityType Name= "Customer" >
< Key>
< PropertyRef Name= "Id" / >
< / Key>
< Property Name= "Id" Type= "Edm.Int32" Nullable= "false" / >
< Property Name= "Name" Type= "Edm.String" Nullable= "false" / >
< Property Name= "BillingAddress" Type= "Lesson8.Models.Address" Nullable= "false" / >
< Property Name= "ContactPhones" Type= "Collection(Edm.String)" / >
< / EntityType>
< ComplexType Name= "Address" >
< Property Name= "Street" Type= "Edm.String" Nullable= "false" / >
< / ComplexType>
< ComplexType Name= "PostalAddress" BaseType= "Lesson8.Models.Address" >
< Property Name= "PostalCode" Type= "Edm.String" Nullable= "false" / >
< / ComplexType>
< EntityType Name= "EnterpriseCustomer" BaseType= "Lesson8.Models.Customer" >
< Property Name= "CreditLimit" Type= "Edm.Decimal" Nullable= "false" Scale= "variable" / >
< Property Name= "RegisteredAddress" Type= "Lesson8.Models.Address" Nullable= "false" / >
< Property Name= "ShippingAddresses" Type= "Collection(Lesson8.Models.Address)" / >
< / EntityType>
< / Schema>
< Schema Namespace= "Default" xmlns= "http://docs.oasis-open.org/odata/ns/edm" >
< EntityContainer Name= "Container" >
< EntitySet Name= "Customers" EntityType= "Lesson8.Models.Customer" / >
< / EntityContainer>
< / Schema>
< / edmx: DataServices>
< / edmx: Edmx>
代码地址
https://github.com/f304646673/odata/tree/main/csharp/Lesson/Lesson8
参考资料