β

ObjectQL v4.0 is currently in Beta.

ObjectStack LogoObjectQL
API ReferenceAPI Reference

OData V4 API

OData V4 Protocol API

ObjectQL provides a OData V4 interface for standardized, RESTful data access with powerful query capabilities. OData (Open Data Protocol) is an ISO/IEC approved OASIS standard that defines best practices for building and consuming RESTful APIs.

Overview

The OData V4 API provides:

  • Standardized URL conventions for querying, filtering, and manipulating data
  • Automatic metadata generation ($metadata endpoint)
  • Advanced query features including $filter, $select, $orderby, $top, $skip
  • Nested entity expansion with $expand for related data
  • Batch operations with $batch for multiple requests
  • Full-text search with $search
  • ETags for optimistic concurrency control
  • Standards-compliant OData V4 implementation

Endpoints

GET  /odata                          # Service document
GET  /odata/$metadata                # Metadata document (EDMX)
GET  /odata/{EntitySet}              # Query entity set
GET  /odata/{EntitySet}({id})        # Get single entity by ID
POST /odata/{EntitySet}              # Create new entity
PATCH/odata/{EntitySet}({id})        # Update entity
DELETE /odata/{EntitySet}({id})      # Delete entity
POST /odata/$batch                   # Batch operations

Getting Started

Installation

The OData V4 protocol is available as a plugin:

import { ObjectStackKernel } from '@objectstack/runtime';
import { ODataV4Plugin } from '@objectql/protocol-odata-v4';

const kernel = new ObjectStackKernel([
  myDriver,
  new ODataV4Plugin({
    port: 8080,
    basePath: '/odata',
    maxExpandDepth: 3,
    enableBatch: true,
    enableSearch: true,
    enableETags: true
  })
]);

await kernel.start();
// Access OData endpoint: http://localhost:8080/odata

Service Document

Get the list of available entity sets:

GET /odata

Response:

{
  "@odata.context": "http://localhost:8080/odata/$metadata",
  "value": [
    {
      "name": "users",
      "kind": "EntitySet",
      "url": "users"
    },
    {
      "name": "projects",
      "kind": "EntitySet",
      "url": "projects"
    }
  ]
}

Metadata Document

Get the full schema definition in EDMX format:

GET /odata/$metadata

Response (XML):

<?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="ObjectStack" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityType Name="user">
        <Key>
          <PropertyRef Name="id"/>
        </Key>
        <Property Name="id" Type="Edm.String" Nullable="false"/>
        <Property Name="name" Type="Edm.String"/>
        <Property Name="email" Type="Edm.String"/>
        <Property Name="role" Type="Edm.String"/>
      </EntityType>
      <EntityContainer Name="Container">
        <EntitySet Name="users" EntityType="ObjectStack.user"/>
      </EntityContainer>
    </Schema>
  </edmx:DataServices>
</edmx:Edmx>

Query Operations

Get All Entities

GET /odata/users

Response:

{
  "@odata.context": "http://localhost:8080/odata/$metadata#users",
  "value": [
    {
      "id": "user_123",
      "name": "Alice",
      "email": "alice@example.com",
      "role": "admin"
    },
    {
      "id": "user_456",
      "name": "Bob",
      "email": "bob@example.com",
      "role": "user"
    }
  ]
}

Get Single Entity

Get an entity by its ID:

GET /odata/users('user_123')

Response:

{
  "@odata.context": "http://localhost:8080/odata/$metadata#users/$entity",
  "id": "user_123",
  "name": "Alice",
  "email": "alice@example.com",
  "role": "admin"
}

Filtering with $filter

Filter entities using OData query expressions:

GET /odata/users?$filter=role eq 'admin'

Comparison Operators

OperatorDescriptionExample
eqEqualrole eq 'admin'
neNot equalstatus ne 'inactive'
gtGreater thanage gt 30
geGreater than or equalage ge 18
ltLess thanprice lt 100
leLess than or equalquantity le 10

Logical Operators

# AND
GET /odata/users?$filter=role eq 'admin' and age gt 25

# OR
GET /odata/users?$filter=role eq 'admin' or role eq 'manager'

# NOT
GET /odata/users?$filter=not (role eq 'guest')

String Functions

# Contains
GET /odata/users?$filter=contains(name, 'Alice')

# Starts with
GET /odata/users?$filter=startswith(email, 'admin')

# Ends with
GET /odata/users?$filter=endswith(email, '@example.com')

# Length
GET /odata/users?$filter=length(name) gt 5

Field Selection with $select

Request only specific fields:

GET /odata/users?$select=id,name,email

Response:

{
  "@odata.context": "http://localhost:8080/odata/$metadata#users(id,name,email)",
  "value": [
    {
      "id": "user_123",
      "name": "Alice",
      "email": "alice@example.com"
    }
  ]
}

Sorting with $orderby

Sort results by one or more fields:

# Ascending (default)
GET /odata/users?$orderby=name

# Descending
GET /odata/users?$orderby=created_at desc

# Multiple fields
GET /odata/users?$orderby=role,name desc

Pagination

Using $top and $skip

# Get first 10 records
GET /odata/users?$top=10

# Skip first 20 records, then get 10
GET /odata/users?$top=10&$skip=20

# Page 3 (20 records per page)
GET /odata/users?$top=20&$skip=40

Count Total Records

Get the total count with $count:

GET /odata/users?$count=true&$top=10

Response:

{
  "@odata.context": "http://localhost:8080/odata/$metadata#users",
  "@odata.count": 150,
  "value": [
    // ... first 10 records
  ]
}

Combining Query Options

Combine multiple query options for complex queries:

GET /odata/users?$filter=role eq 'admin'&$select=id,name&$orderby=name&$top=5

$expand - Nested Entities

The $expand system query option allows you to include related entities inline with the response. This is one of OData's most powerful features for reducing round trips.

Basic Expansion

Expand a single navigation property:

GET /odata/projects?$expand=owner

Response:

{
  "@odata.context": "http://localhost:8080/odata/$metadata#projects",
  "value": [
    {
      "id": "proj_1",
      "name": "Project Alpha",
      "owner_id": "user_123",
      "owner": {
        "id": "user_123",
        "name": "Alice",
        "email": "alice@example.com"
      }
    }
  ]
}

Multiple Expansions

Expand multiple navigation properties:

GET /odata/projects?$expand=owner,department

Nested Expansion

Expand related entities multiple levels deep:

GET /odata/projects?$expand=owner($expand=department)

Response:

{
  "id": "proj_1",
  "name": "Project Alpha",
  "owner": {
    "id": "user_123",
    "name": "Alice",
    "department": {
      "id": "dept_1",
      "name": "Engineering"
    }
  }
}

Expansion with Query Options

Apply query options to expanded entities:

# Filter expanded entities
GET /odata/users?$expand=orders($filter=status eq 'active')

# Select fields in expanded entities
GET /odata/users?$expand=orders($select=id,total)

# Order expanded entities
GET /odata/users?$expand=orders($orderby=created_at desc)

# Limit expanded entities
GET /odata/users?$expand=orders($top=5)

# Combine options
GET /odata/users?$expand=orders($filter=status eq 'active';$orderby=created_at desc;$top=10)

Depth Limiting

ObjectQL limits expansion depth to prevent performance issues:

new ODataV4Plugin({
  maxExpandDepth: 3  // Default: 3 levels
})

Exceeding the depth limit returns an error:

{
  "error": {
    "code": "EXPAND_DEPTH_EXCEEDED",
    "message": "Maximum expand depth of 3 exceeded"
  }
}

$batch - Batch Operations

The $batch endpoint allows you to submit multiple operations in a single HTTP request, reducing network overhead and enabling transactional operations.

Batch Request Format

Batch requests use multipart/mixed content type:

POST /odata/$batch
Content-Type: multipart/mixed; boundary=batch_123

--batch_123
Content-Type: application/http

GET /odata/users HTTP/1.1

--batch_123
Content-Type: application/http

GET /odata/projects HTTP/1.1

--batch_123--

Batch with Changesets

Changesets allow transactional write operations:

POST /odata/$batch
Content-Type: multipart/mixed; boundary=batch_abc

--batch_abc
Content-Type: multipart/mixed; boundary=changeset_xyz

--changeset_xyz
Content-Type: application/http
Content-Transfer-Encoding: binary

POST /odata/users HTTP/1.1
Content-Type: application/json

{"name":"Alice","email":"alice@example.com"}

--changeset_xyz
Content-Type: application/http
Content-Transfer-Encoding: binary

POST /odata/projects HTTP/1.1
Content-Type: application/json

{"name":"Project Alpha","owner_id":"$1"}

--changeset_xyz--
--batch_abc--

Batch Response Format

HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=batchresponse_123

--batchresponse_123
Content-Type: application/http

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "user_789",
  "name": "Alice",
  "email": "alice@example.com"
}

--batchresponse_123
Content-Type: application/http

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "proj_456",
  "name": "Project Alpha",
  "owner_id": "user_789"
}

--batchresponse_123--

Error Handling in Batch

If any operation in a changeset fails, all operations in that changeset are rolled back:

--batchresponse_123
Content-Type: multipart/mixed; boundary=changesetresponse_xyz

--changesetresponse_xyz
Content-Type: application/http

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required"
  }
}

--changesetresponse_xyz--

CRUD Operations

Create Entity

POST /odata/users
Content-Type: application/json

{
  "name": "Charlie",
  "email": "charlie@example.com",
  "role": "user"
}

Response:

HTTP/1.1 201 Created
Location: /odata/users('user_789')
Content-Type: application/json

{
  "@odata.context": "http://localhost:8080/odata/$metadata#users/$entity",
  "id": "user_789",
  "name": "Charlie",
  "email": "charlie@example.com",
  "role": "user",
  "created_at": "2026-02-02T10:30:00Z"
}

Update Entity

PATCH /odata/users('user_123')
Content-Type: application/json

{
  "name": "Alice Updated",
  "role": "admin"
}

Response:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "@odata.context": "http://localhost:8080/odata/$metadata#users/$entity",
  "id": "user_123",
  "name": "Alice Updated",
  "email": "alice@example.com",
  "role": "admin",
  "updated_at": "2026-02-02T10:35:00Z"
}

Delete Entity

DELETE /odata/users('user_123')

Response:

HTTP/1.1 204 No Content

Use $search for full-text search across multiple fields:

GET /odata/users?$search="Alice"

Search is case-insensitive and searches across all text fields:

# Search in multiple entity sets
GET /odata/users?$search="admin" OR "manager"

# Combine with filters
GET /odata/users?$search="Alice"&$filter=role eq 'admin'

ETags for Concurrency Control

ETags enable optimistic concurrency control to prevent lost updates:

Get Entity with ETag

GET /odata/users('user_123')

Response:

HTTP/1.1 200 OK
ETag: W/"2026-02-02T10:30:00Z"
Content-Type: application/json

{
  "id": "user_123",
  "name": "Alice",
  "email": "alice@example.com"
}

Update with ETag

PATCH /odata/users('user_123')
If-Match: W/"2026-02-02T10:30:00Z"
Content-Type: application/json

{
  "name": "Alice Updated"
}

If the entity was modified by another client:

HTTP/1.1 412 Precondition Failed
Content-Type: application/json

{
  "error": {
    "code": "PRECONDITION_FAILED",
    "message": "Entity has been modified by another client"
  }
}

Error Handling

OData errors follow the standard format:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "details": [
      {
        "code": "REQUIRED_FIELD",
        "message": "The 'email' field is required",
        "target": "email"
      }
    ]
  }
}

Common Error Codes

HTTP StatusError CodeDescription
400VALIDATION_ERRORRequest validation failed
404NOT_FOUNDEntity not found
409CONFLICTEntity already exists
412PRECONDITION_FAILEDETag mismatch
500INTERNAL_ERRORServer error

Type Mapping

ObjectQL field types are mapped to OData EDM types:

ObjectQL TypeOData EDM TypeNotes
text, textarea, email, url, phoneEdm.StringText fields
number, currency, percentEdm.DoubleNumeric fields
auto_numberEdm.Int32Integer fields
booleanEdm.BooleanTrue/false
dateEdm.DateDate only
datetimeEdm.DateTimeOffsetDate and time with timezone
timeEdm.TimeOfDayTime only
selectEdm.StringString enum values
lookup, master_detailEdm.StringReference by ID

Client Integration

JavaScript/TypeScript

const response = await fetch('/odata/users?$filter=role eq \'admin\'&$top=10', {
  headers: {
    'Accept': 'application/json',
    'Authorization': 'Bearer ' + token
  }
});

const data = await response.json();
console.log(data.value); // Array of users

OData Client Libraries

JavaScript: o-data

import { o } from 'odata';

const users = await o('/odata')
  .get('users')
  .filter({ role: 'admin' })
  .select('id', 'name', 'email')
  .top(10)
  .query();

C#: Simple.OData.Client

var client = new ODataClient("http://localhost:8080/odata");

var users = await client
  .For<User>("users")
  .Filter(u => u.Role == "admin")
  .Select(u => new { u.Id, u.Name, u.Email })
  .Top(10)
  .FindEntriesAsync();

Python: pyodata

import requests
from pyodata import Client

session = requests.Session()
session.headers.update({'Authorization': f'Bearer {token}'})

client = Client('http://localhost:8080/odata', session)
users = client.entity_sets.users.get_entities().filter("role eq 'admin'").top(10).execute()

Best Practices

1. Use $select to Limit Fields

Don't request all fields:

GET /odata/users

Do request specific fields:

GET /odata/users?$select=id,name,email

Don't make multiple requests:

GET /odata/projects
GET /odata/users('user_123')
GET /odata/users('user_456')

Do use $expand:

GET /odata/projects?$expand=owner

3. Implement Pagination

Don't fetch all records:

GET /odata/users

Do use pagination:

GET /odata/users?$top=20&$skip=0

4. Use $count for Total Records

GET /odata/users?$count=true&$top=20

5. Leverage $batch for Multiple Operations

Don't make separate requests:

POST /odata/users (user 1)
POST /odata/users (user 2)
POST /odata/users (user 3)

Do use batch:

POST /odata/$batch (all 3 users in one request)

Comparison with Other APIs

OData vs REST

FeatureOData V4REST
FilteringStandardized $filterCustom query params
ExpansionBuilt-in $expandManual joins or separate requests
MetadataAutomatic $metadataManual documentation
BatchStandardized $batchCustom implementation
StandardsISO/IEC approvedBest practices
Learning CurveModerateLow

OData vs GraphQL

FeatureOData V4GraphQL
ProtocolRESTful HTTPPOST-based
Query LanguageURL-basedQuery strings
Nested Data$expandNative support
Batch$batchNative support
CachingHTTP cachingCustom caching
ToolingExcellentExcellent

When to Use OData

Use OData when:

  • Building enterprise applications with complex queries
  • Need standardized API conventions
  • Working with Microsoft ecosystem (.NET, Power BI)
  • Client needs flexible query capabilities
  • Metadata-driven clients required

Use REST when:

  • Simple CRUD operations
  • Custom API design needed
  • Team prefers flexibility over standards

Use GraphQL when:

  • Building complex UIs with nested data
  • Client needs maximum flexibility
  • Working with modern frontend frameworks

Performance Considerations

Query Optimization

  1. Use Indexes: Index fields used in $filter and $orderby
  2. Limit $expand Depth: Keep maxExpandDepth low (default: 3)
  3. Use $select: Request only needed fields
  4. Implement Pagination: Always use $top and $skip
  5. Monitor $batch: Large batches can impact performance

Caching

OData responses are HTTP-cacheable:

GET /odata/users
Cache-Control: max-age=300
ETag: W/"2026-02-02T10:30:00Z"

Security

Authentication

OData uses the same authentication as other ObjectQL APIs:

GET /odata/users
Authorization: Bearer eyJhbGc...

Authorization

ObjectQL's permission system works with OData:

  • Object-level permissions
  • Field-level permissions (via $select)
  • Record-level permissions (via $filter)

Best Practices

  1. Always Authenticate: Require authentication for all endpoints
  2. Validate Input: OData query injection prevention
  3. Rate Limit: Prevent abuse of $batch and complex queries
  4. Sanitize Errors: Don't expose internal details in production
  5. Use HTTPS: Always in production
  6. Limit $expand Depth: Prevent performance attacks

Troubleshooting

Query Returns Empty

Check that:

  • Entity set name is correct (case-sensitive)
  • $filter syntax is valid
  • User has permission
  • Records exist in database

$expand Not Working

Ensure:

  • Navigation property exists in metadata
  • maxExpandDepth is not exceeded
  • Related entity exists

$batch Fails

Solutions:

  • Check multipart boundary format
  • Ensure Content-Type headers are correct
  • Verify all operations in changeset are valid

Further Reading


Last Updated: February 2026
API Version: 1.0.0
OData Version: 4.0
New in v4.0.3: Full $expand and $batch support

On this page