Skip to main content

OData V2/V4

ERPL-OData provides a universal OData reader that works seamlessly with both OData V2 and V4 services. Query any OData API like it's a native DuckDB table.

Overview​

OData (Open Data Protocol) is a standard for building and consuming RESTful APIs. ERPL-OData makes OData services feel like regular databases.

What you get:

  • ✅ Automatic version detection (V2 or V4)
  • ✅ ATTACH services as databases
  • ✅ Predicate pushdown ($filter, $select, $top, $skip)
  • ✅ Expand navigation properties
  • ✅ Type mapping from EDM to DuckDB
  • ✅ Automatic pagination handling

Quick Start​

-- Load the extension
LOAD erpl_odata;

-- Attach an OData service
ATTACH 'https://services.odata.org/TripPinRESTierService'
AS trippin (TYPE odata);

-- Query it like a normal table
SELECT UserName, FirstName, LastName
FROM trippin.People
WHERE FirstName = 'Russell';

That's it! The OData service is now a queryable database.


Two Ways to Use OData​

Attach the service once, query any entity set:

-- Attach OData V4 service
ATTACH 'https://services.odata.org/TripPinRESTierService'
AS trippin (TYPE odata);

-- List available tables
SHOW TABLES;

-- Query any entity
SELECT * FROM trippin.People LIMIT 10;
SELECT * FROM trippin.Airlines;
SELECT * FROM trippin.Airports;

2. Direct Read with odata_read​

Query a specific entity set directly:

SELECT UserName, FirstName, LastName
FROM odata_read('https://services.odata.org/TripPinRESTierService/People')
WHERE UserName = 'russellwhyte';
tip

Use ATTACH for exploring services or querying multiple entities. Use odata_read for one-off queries or when you know exactly what you need.


Version Support​

ERPL-OData automatically detects and handles both OData V2 and V4.

OData V4 Example​

-- TripPin V4 Service
ATTACH 'https://services.odata.org/TripPinRESTierService'
AS trippin (TYPE odata);

SELECT
UserName,
FirstName,
LastName,
Gender
FROM trippin.People
WHERE Gender = 'Female'
LIMIT 5;

OData V2 Example​

-- Northwind V2 Service
ATTACH 'https://services.odata.org/V2/Northwind/Northwind.svc'
AS northwind (TYPE odata);

SELECT
CustomerID,
CompanyName,
Country
FROM northwind.Customers
WHERE Country = 'Germany';

No configuration needed - ERPL-OData figures out the version automatically!


Predicate Pushdown​

ERPL-OData translates SQL WHERE clauses into OData $filter queries, sending the filtering to the server. This dramatically improves performance.

Automatic $filter Translation​

-- This SQL query...
SELECT * FROM trippin.People
WHERE FirstName = 'Russell' AND LastName = 'Whyte';

-- ...becomes this OData request:
-- GET /People?$filter=FirstName eq 'Russell' and LastName eq 'Whyte'

Supported Operators​

SQL OperatorOData Translation
=eq
!= or <>ne
>gt
>=ge
<lt
<=le
ANDand
ORor
NOTnot

$top and $skip (LIMIT and OFFSET)​

-- Get first 10 records
SELECT * FROM trippin.People LIMIT 10;
-- Translates to: $top=10

-- Skip first 20, get next 10
SELECT * FROM trippin.People LIMIT 10 OFFSET 20;
-- Translates to: $skip=20&$top=10

$select (Column Selection)​

-- Only fetch specific columns
SELECT UserName, FirstName FROM trippin.People;
-- Translates to: $select=UserName,FirstName

$orderby (Sorting)​

-- Sort by last name
SELECT * FROM trippin.People ORDER BY LastName;
-- Translates to: $orderby=LastName

-- Sort descending
SELECT * FROM trippin.People ORDER BY LastName DESC;
-- Translates to: $orderby=LastName desc

Expand Navigation Properties​

OData services often have related entities. Use expand to fetch them in one query.

Basic Expand​

-- Fetch people with their trips
SELECT
UserName,
FirstName,
Trips
FROM odata_read(
'https://services.odata.org/TripPinRESTierService/People',
expand := 'Trips'
);

Nested Expand​

-- Expand multiple levels
SELECT UserName, Friends
FROM odata_read(
'https://services.odata.org/TripPinRESTierService/People',
expand := 'Friends($expand=Trips)'
);

Expand with Filter​

-- Expand with filtering
SELECT UserName, Trips
FROM odata_read(
'https://services.odata.org/TripPinRESTierService/People',
expand := 'Trips($filter=Name eq ''Trip to France'')'
);

Authentication​

Using Secrets​

Most production OData services require authentication:

-- Create a secret for Basic Auth
CREATE SECRET sap_odata (
TYPE http_basic,
username 'your_username',
password 'your_password'
);

-- Attach with authentication (secret is auto-used)
ATTACH 'https://your-sap-server:port/sap/opu/odata/sap/SERVICE_NAME'
AS sap_service (TYPE odata);

SELECT * FROM sap_service.EntitySet;

Bearer Token​

-- Create Bearer token secret
CREATE SECRET api_token (
TYPE http_bearer,
token 'your-bearer-token'
);

-- Use it automatically
ATTACH 'https://api.example.com/odata'
AS myservice (TYPE odata);

Selecting a Secret​

odata_read() uses DuckDB's automatic secret lookup — the secret whose SCOPE best matches the request URL is chosen. To force a specific secret, use ATTACH:

ATTACH 'https://your-server/odata' AS my_service (TYPE odata, SECRET sap_odata);
SELECT * FROM my_service.EntitySet;

Type Mapping​

ERPL-OData maps OData EDM types to DuckDB types automatically.

EDM TypeDuckDB Type
Edm.StringVARCHAR
Edm.Int16SMALLINT
Edm.Int32INTEGER
Edm.Int64BIGINT
Edm.DecimalDECIMAL(p,s) or DOUBLE — see below
Edm.DoubleDOUBLE
Edm.BooleanBOOLEAN
Edm.DateTimeTIMESTAMP
Edm.DateTimeOffsetTIMESTAMP WITH TIME ZONE
Edm.GuidVARCHAR
Edm.BinaryBLOB

Complex types and collections are mapped to DuckDB STRUCT and LIST types.

Decimals: Precision and Scale​

Edm.Decimal is the one type whose mapping depends on more than the type name. ERPL-OData reads the Precision and Scale facets a service declares in its $metadata:

<Property Name="GrossAmount" Type="Edm.Decimal" Precision="16" Scale="3"/>
Declared facetsDuckDB typeWhy
Precision="16" Scale="3"DECIMAL(16,3)Both facets honoured as declared.
Scale="0"DECIMAL(p,0)An explicit zero scale is a real statement about the data.
No Scale, or Scale="variable"DOUBLESee below.
Precision absentDECIMAL(38,s)An absent Precision is unbounded in CSDL, so it widens to DuckDB's maximum.
Precision ≤ ScaleDECIMAL(38,s)Facets that leave no integral digits cannot hold a value ≥ 1; the scale is kept and the precision widened.
Precision > 38DECIMAL(38,s)DuckDB's widest DECIMAL is 38 digits.
Scale ≥ 38DOUBLENo DuckDB DECIMAL leaves any integral digits at that scale.

Why a missing Scale becomes DOUBLE. CSDL says an absent Scale defaults to 0, which would make DECIMAL(p,0) conformant — and would silently round every 19.99 to 20. SAP CDS and Dataverse both publish money columns that way. Losing the cents is a worse failure than losing exactness, so ERPL-OData maps those columns to DOUBLE instead.

To see what a column actually became:

DESCRIBE SELECT * FROM my_service.MyEntitySet;

Inspecting a Service with odata_describe()​

odata_describe(url) reads a service's $metadata and reports its shape without fetching any data. Point it at a service root to list entity sets and functions, or at an entity set to list its properties and navigation properties.

SELECT p.name, p.duckdb_type, p.edm_type, p.precision, p.scale
FROM odata_describe('https://services.odata.org/V4/Northwind/Northwind.svc/Products'),
UNNEST(properties) AS t(p);
ColumnDescription
url, resource_typeThe URL described, and service or entity_set
entity_set_name, entity_type_nameEmpty for a service root
propertiesList of structs: name, duckdb_type, edm_type, is_nullable, is_key, max_length, precision, scale
navigation_propertiesList of structs: name, target_entity, target_entity_type (name, property_count, nav_property_count), is_collection, partner, referential_constraints
entity_setsService root only. List of structs: name, entity_type, url
functionsService root only. List of structs: name, return_type, parameters

duckdb_type is the type the reader will actually bind — the same DECIMAL(p,s)/DOUBLE/VARCHAR[]/STRUCT(...) you get from odata_read() or an attached table, facets included.

precision, scale and max_length report the facets as declared, and are NULL when the service declares none. That distinction matters: Scale="0" reports 0 and yields DECIMAL(p,0), while an absent Scale reports NULL and yields DOUBLE.

It takes the same secret named parameter as odata_read():

SELECT * FROM odata_describe('https://your-server/odata/Products', secret => 'my_secret');

Pagination​

ERPL-OData handles pagination automatically. If a service returns paginated results, the extension follows the @odata.nextLink to fetch all pages.

-- This might fetch multiple pages automatically
SELECT COUNT(*) FROM trippin.People;

Manual Pagination Control​

-- Fetch specific page size
SELECT * FROM trippin.People LIMIT 100;

-- Skip to specific offset
SELECT * FROM trippin.People LIMIT 50 OFFSET 200;

Advanced Examples​

SAP OData Services​

-- Connect to SAP Gateway OData service
CREATE SECRET sap_gateway (
TYPE http_basic,
username 'SAP_USER',
password 'SAP_PASS'
);

ATTACH 'https://sap-server:port/sap/opu/odata/sap/ZSERVICE_SRV'
AS sap (TYPE odata);

-- Query SAP data
SELECT * FROM sap.Customers WHERE Country = 'US';

Complex Filters​

-- Multiple conditions
SELECT * FROM northwind.Orders
WHERE
ShipCountry = 'USA'
AND OrderDate >= '1997-01-01'
AND Freight > 100
ORDER BY OrderDate DESC;

Joins Across Entity Sets​

-- Join customers and orders
SELECT
c.CompanyName,
o.OrderID,
o.OrderDate
FROM northwind.Customers c
JOIN northwind.Orders o ON c.CustomerID = o.CustomerID
WHERE c.Country = 'Germany'
LIMIT 10;

Export to Parquet​

-- Extract OData to Parquet file
COPY (
SELECT * FROM trippin.People
) TO 'people.parquet' (FORMAT PARQUET);

Use with DuckDB Features​

-- Aggregate OData data
SELECT
Country,
COUNT(*) as customer_count,
AVG(Freight) as avg_freight
FROM northwind.Orders o
JOIN northwind.Customers c ON o.CustomerID = c.CustomerID
GROUP BY Country
ORDER BY customer_count DESC;

Performance Tips​

1. Use Predicate Pushdown​

-- Good: Filter is pushed to OData service
SELECT * FROM trippin.People WHERE Gender = 'Female';

-- Bad: Fetches all data, then filters locally
-- (Avoid if possible)

2. Select Only Needed Columns​

-- Good: Only fetches 2 columns
SELECT UserName, FirstName FROM trippin.People;

-- Bad: Fetches all columns
SELECT * FROM trippin.People;

3. Use LIMIT for Exploration​

-- Good: Quick data sample
SELECT * FROM trippin.People LIMIT 10;

-- Be careful with large datasets
-- SELECT * FROM large_entity_set; -- Could take a long time

4. Cache Results for Repeated Queries​

-- Cache OData results in a table
CREATE TABLE people_cache AS
SELECT * FROM trippin.People;

-- Query the cache
SELECT * FROM people_cache WHERE FirstName = 'Russell';

Troubleshooting​

Service Not Found​

Error: OData service not found

Fixes:

  • Verify the service URL is correct
  • Check if service requires authentication
  • Ensure the service is accessible from your network

Entity Set Not Found​

Error: Entity set 'XYZ' not found

Fixes:

  • Use SHOW TABLES to list available entity sets
  • Check capitalization (OData is case-sensitive)
  • Verify you're using the correct version (V2 vs V4)

Authentication Failed​

Error: 401 Unauthorized

Fixes:

  • Verify credentials in your secret
  • Check if credentials have expired
  • Ensure user has permissions to access the service

Decimal Values Come Back NULL​

erpl_odata warning: 2 values in 1 column could not be
converted and were returned as NULL (odata_read).
column 'serviceagreementcost': 2 failures;
first offending value: 490.0;
first error: Could not cast value 490.000000
to DECIMAL(18,18)

A value did not fit the DuckDB type derived from the service's $metadata. The giveaway is a target type whose scale equals its precision, such as DECIMAL(18,18): that type has no integral digits at all, so no value of 1 or more can be stored in it. ERPL-OData now widens such declarations to DECIMAL(38,s) — see Decimals: Precision and Scale.

Fixes:

  • Check the column type with DESCRIBE SELECT * FROM my_service.MyEntitySet
  • Make sure you are on a build that includes the widened decimal mapping
  • Pass strict_typing=true to odata_read() to fail on the first bad value instead of collecting NULLs, when you would rather the query stop than return incomplete data

Slow Queries​

Solutions:

  • Use WHERE clauses to push filters to server
  • Select only needed columns
  • Use LIMIT for large datasets
  • Check OData service performance

Enable Tracing​

-- Enable tracing to see what's happening
SET erpl_trace_enabled = TRUE;
SET erpl_trace_level = 'DEBUG';

-- Run your query
SELECT * FROM trippin.People LIMIT 5;

-- Check trace output for details

See Tracing & Diagnostics for more details.


Known Limitations​

  • Write operations: ERPL-OData currently supports read-only operations (GET). POST/PUT/PATCH/DELETE are not yet supported.
  • Function imports: Custom OData functions may have limited support.
  • Batch requests: Not currently supported.

Examples by Use Case​

Data Integration​

-- Sync OData service to local DuckDB
CREATE TABLE local_customers AS
SELECT * FROM odata_read('https://api.example.com/odata/Customers');

-- Incremental updates
INSERT INTO local_customers
SELECT * FROM odata_read('https://api.example.com/odata/Customers')
WHERE ModifiedDate > (SELECT MAX(ModifiedDate) FROM local_customers);

Analytics​

-- Analyze OData data with DuckDB
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', OrderDate) as month,
SUM(Freight) as total_freight
FROM northwind.Orders
GROUP BY month
)
SELECT * FROM monthly_sales
ORDER BY month;

Data Export​

-- Export filtered data to CSV
COPY (
SELECT * FROM trippin.People
WHERE Gender = 'Female'
) TO 'female_travelers.csv' (HEADER, DELIMITER ',');

Next Steps​


Summary​

ERPL-OData's OData integration gives you:

✅ Universal - Works with V2 and V4 automatically
✅ Powerful - Predicate pushdown and expand support
✅ Simple - ATTACH and query like normal SQL
✅ Fast - Server-side filtering and pagination

Turn any OData service into a DuckDB table in seconds!