⚠ Prerequisites — load these first
- connect-ai-base skill
Before proceeding, locate and read the connect-ai-base skill. If it is not available in the current environment (not loaded, not enabled, or not installed), stop immediately. Do not proceed with the task. Tell the user that the connect-ai-base skill is required and ask them to install and enable it before continuing.
This skill provides Odoo-specific guidance for querying Odoo data through CData Connect AI. It composes on top of the connect-ai-base skill, which handles the discovery workflow, error recovery, SQL dialect, and query naming convention.
Precedence
This skill replaces getInstructions for the Odoo driver. Do not call getInstructions for Odoo, the guidance it provides is already incorporated here. Proceed directly to schema discovery (getTables / getColumns) after identifying the Odoo connection via getCatalogs.
Schema
Odoo is a single-schema driver. The schema name is Odoo.
SELECT * FROM [YourConnection].[Odoo].[res_partner] LIMIT 10
Replace [YourConnection] with your actual Odoo connection name from getCatalogs.
The table list is app-dependent
Odoo's object model is read live from the instance at connect time and reflects the apps installed there. Two Odoo connections can expose very different table sets: an instance without the Accounting app has no account_move at all, and one without Sales has no sale_order.
Treat every table named in this skill as an example to confirm with getTables, never as a guarantee. Querying a model whose app is not installed returns an error naming the table:
Could not convert table account_move to model: A table could not be found that matches account_move
That means the app is not installed on this instance, not that the query is malformed. Confirm with getTables rather than retrying, and use ir_module_module to see what is installed:
SELECT [name], [latest_version]
FROM [YourConnection].[Odoo].[ir_module_module]
WHERE [state] = 'installed'
ORDER BY [name]
Query Process
Step 1: Find the model
Odoo model names map from the dot-name to an underscore name: res.partner is queried as res_partner, sale.order as sale_order. The full list is large, so filter with getTables rather than listing everything:
{
"tableName": "%partner%"
}
Step 2: Inspect the columns
Call getColumns on the table. Field names are app-specific and customizable, so do not assume them. getColumns also reports a Readonly flag per column, which tells you what you can write before attempting an INSERT or UPDATE.
Step 3: Map the relationships
This is the Odoo-specific step. Query the FieldReferences view to learn which columns are single-valued foreign keys and which hold lists of IDs, before writing any query that touches a relationship:
SELECT [ReferenceColumn], [TargetTable], [IsMultiValued]
FROM [YourConnection].[Odoo].[FieldReferences]
WHERE [ReferenceTable] = 'res_partner'
IsMultiValued = 0is a many2one: a single ID you can JOIN directly toTargetTable.IsMultiValued = 1is a many2many or one2many: a comma-separated list of IDs to parse client-side.
IsMultiValued is a boolean column, so it displays as true / false while = 1 and = 0 work as filters.
Step 4: Sample before filtering
Preview real values before building filters, especially for relationship columns and selection fields such as state or move_type:
SELECT * FROM [YourConnection].[Odoo].[res_partner] LIMIT 5
Step 5: Build up incrementally
Start flat, read the <column>_label companion for a many2one's display name, and add a JOIN only when you need other fields from the target.
Data Model
Models, Tables, and Views
Modifiable models are exposed as tables supporting SELECT, INSERT, UPDATE, and DELETE. Read-only objects are exposed as views supporting SELECT only, as FieldReferences is.
Relationship Columns
Odoo's relational fields all live as columns on the same table. There are no separate junction tables:
- many2one: a single integer ID referencing one row in another model, the equivalent of a foreign key. Resolve it with a JOIN, or read its
_labelcompanion for the display name. - many2many: text holding a comma-separated list of IDs, for example
category_id = "1,2,3". Writable. To test for one ID, anchor the delimiters (',' + [category_id] + ',' LIKE '%,2,%'); a bareLIKE '%2%'also matches 12 and 20. Otherwise parse the list client-side. - one2many: the reverse of a many2one. Read it the same way as a many2many, but it is read-only. To change it, update the child rows' many2one column.
Every many2one column has a <column>_label companion holding the target's display name, so partner_id is the ID and partner_id_label is the name. The _label companion is read-only: write to the ID column, read the label for display.
Key Tables
Common across standard Odoo apps. Confirm each exists with getTables before relying on it.
- res_partner: contacts and companies, the central party record used by sales, invoicing, and CRM
- res_users: Odoo user accounts
- res_country / res_country_state: geography lookups referenced by contacts
- res_partner_category: partner tags
- crm_lead: CRM leads and opportunities, separated by the
typecolumn - sale_order / sale_order_line: sales orders and their line items
- product_template / product_product: product catalog
- account_move / account_move_line: accounting entries including customer invoices and vendor bills, separated by
move_type - ir_module_module: installed modules, for confirming which apps are present
- FieldReferences (view): the relationship map described above
Key Relationships
- res_partner → res_country: join on
res_partner.[country_id] = res_country.[id] - sale_order → res_partner: join on
sale_order.[partner_id] = res_partner.[id] - account_move → res_partner: join on
account_move.[partner_id] = res_partner.[id] - crm_lead → res_partner: join on
crm_lead.[partner_id] = res_partner.[id] - Any relationship: look it up in
FieldReferencesrather than guessing the target
Nearly every Odoo model has both an id and a name column, so selecting name from two joined tables returns two columns headed name with no way to tell them apart. An alias does not fix this: an alias on a plain column is not carried into the result header, though one on an aggregate such as COUNT(*) AS contact_count is. Select the _label companion instead of joining, or select only one table's name per query.
Important Columns
Conventions shared by most models
id: primary key, an integer, and the most reliable column to sort or paginate onname: the record's main label on most models, writabledisplay_name: the computed display label, read-onlycreate_date/write_date: creation and last-modification timestampsactive: whether the record is active. Odoo archives by setting this false rather than deleting, so an unfiltered query can include archived rows<column>_id: usually a many2one foreign key, writable<column>_id_label: the companion display name for that foreign key, read-only<column>_ids: usually a multi-valued relationship holding a comma-separated ID list, empty as''rather than NULL
The suffix is a hint, not a rule: category_id on res_partner is singular but multi-valued. FieldReferences.IsMultiValued is the authoritative answer, so check it rather than trusting the name.
res_partner
id,name,display_name,email,phoneis_company: true for an organization, false for an individualparent_id/parent_id_label: the company this contact belongs tochild_ids: contacts belonging to this company (multi-valued)country_id/country_id_label,state_id/state_id_label: geographyuser_id/user_id_label: the salesperson assigned to the contactcategory_id: partner tags (multi-valued)commercial_partner_id: the top-level company for the contact
sale_order
id,name: the order reference, for exampleS00001partner_id/partner_id_label: the customerpartner_invoice_id,partner_shipping_id: invoice and delivery addresses, each with a_labelcompanionuser_id/user_id_label: the salespersonamount_total,amount_untaxed: order totalsstate: order status, for exampledraftorsaledate_order: when the order was placed
crm_lead
id,nametype:leadoropportunitystage_id/stage_id_label: pipeline stagepartner_id/partner_id_label: the related customeruser_id/user_id_label: the salespersonexpected_revenue,probabilityactive: false once archived
product_template
id,namecateg_id/categ_id_label: product categorytype: product type, for exampleconsuorservicelist_price,standard_price: sales price and costsale_ok,purchase_ok: whether the product can be sold or purchased
account_move
id,name: the entry reference, for exampleINV/2025/0001move_type:out_invoice,out_refund,in_invoice,in_refund, orentrypartner_id/partner_id_label: the customer or vendorinvoice_date,dateamount_total,amount_residual: total and outstanding balancestate:draft,posted, orcancelpayment_state: settlement status
FieldReferences (view)
ReferenceTable,ReferenceColumn,TargetTable,IsMultiValued
Common Query Patterns
Contacts with resolved country and salesperson
Read the _label companions instead of joining, which avoids a JOIN per relationship.
SELECT [id], [name], [email],
[country_id_label] AS country,
[user_id_label] AS salesperson
FROM [YourConnection].[Odoo].[res_partner]
ORDER BY [name]
LIMIT 50
Contacts counted by country
Aggregate directly on the _label companion.
SELECT [country_id_label] AS country, COUNT(*) AS contact_count
FROM [YourConnection].[Odoo].[res_partner]
WHERE [country_id] IS NOT NULL
GROUP BY [country_id_label]
ORDER BY contact_count DESC, country
Companies only
SELECT [id], [name], [email], [country_id_label] AS country
FROM [YourConnection].[Odoo].[res_partner]
WHERE [is_company] = true
ORDER BY [name]
Contacts carrying at least one tag
category_id is multi-valued, so compare against an empty string rather than testing for NULL.
SELECT [id], [name], [category_id]
FROM [YourConnection].[Odoo].[res_partner]
WHERE [category_id] <> ''
LIMIT 50
Sales orders with customer and total
SELECT [id] AS order_id, [name],
[partner_id_label] AS customer,
[user_id_label] AS salesperson,
[amount_total], [state], [date_order]
FROM [YourConnection].[Odoo].[sale_order]
ORDER BY [date_order] DESC
LIMIT 50
Product catalog with category and price
SELECT [id], [name], [categ_id_label] AS category, [type], [list_price]
FROM [YourConnection].[Odoo].[product_template]
WHERE [sale_ok] = true
ORDER BY [list_price] DESC
LIMIT 50
Customer invoices
account_move holds every accounting entry, so always filter by move_type. It is frequently
the largest model in an Odoo instance, often hundreds of thousands of rows, and the sort column
decides whether a query returns at all.
Most recent invoices
Sorting on id or on a stored date column is pushed down and stays responsive:
SELECT [id], [name], [partner_id_label] AS customer,
[create_date], [amount_total], [state], [payment_state]
FROM [YourConnection].[Odoo].[account_move]
WHERE [move_type] = 'out_invoice'
ORDER BY [create_date] DESC
LIMIT 10
Prefer create_date over invoice_date for recency. invoice_date is unset on draft invoices,
which on some instances is nearly all of them, so ordering by it silently returns nothing useful.
Largest invoices by amount
Bound the scan with an id window and sort inside it. Discover the range first rather than hardcoding it, because ids differ per instance:
SELECT MIN([id]) AS lo, MAX([id]) AS hi
FROM [YourConnection].[Odoo].[account_move]
WHERE [move_type] = 'out_invoice'
Then sort inside a window bounded on both sides, repeating across non-overlapping windows to cover the range:
SELECT [id], [name], [partner_id_label] AS customer, [amount_total], [state]
FROM [YourConnection].[Odoo].[account_move]
WHERE [move_type] = 'out_invoice'
AND [id] >= 380000 AND [id] < 480000 -- one window taken from the range above
ORDER BY [amount_total] DESC
LIMIT 10
amount_total is computed and its sort is not pushed down, so every scanned row is retrieved
before ordering, and LIMIT is applied after the sort rather than before it. That is why the
same query without the id window times out while this one returns promptly: the cost tracks
rows scanned, not rows returned. To cover the whole table, walk it in id windows and merge the
top rows from each.
Opportunities still in play
crm_lead holds both leads and opportunities, separated by type. active records only whether
a row is archived, so filtering on it alone still returns won and lost opportunities, which stay
active. Whether an opportunity is still open lives on its stage: join crm_stage and filter on
is_won and fold.
SELECT l.[id], l.[name],
l.[stage_id_label] AS stage,
l.[user_id_label] AS salesperson,
l.[partner_id_label] AS customer,
l.[expected_revenue], l.[probability]
FROM [YourConnection].[Odoo].[crm_lead] l
INNER JOIN [YourConnection].[Odoo].[crm_stage] s ON l.[stage_id] = s.[id]
WHERE l.[type] = 'opportunity'
AND l.[active] = true
AND s.[is_won] = false
AND s.[fold] = false
ORDER BY l.[expected_revenue] DESC
LIMIT 50
Stored Procedures
Odoo exposes a single stored procedure, CallProcedure. It runs a raw Odoo RPC against any model method and returns JSON, and it is the escape hatch for operations plain SQL CRUD cannot express: native domain searches, workflow methods, posting records, and deletes.
Parameters:
Model(required): the Odoo dot-name, for exampleres.partner. Not the underscore table name.Operation(required): the method to call, for examplesearch_read,search_count,create, orunlink.Arguments(optional): positional arguments as a JSON list. Not supported on Odoo 19 and above, where it must be left empty.KeywordArgs(optional): named arguments as a JSON object.
Passing arguments depends on the Odoo version and the method
- Methods that do not target existing records (
search_read,search_count,create) take everything throughKeywordArgson every version. Prefer that form. - Methods that act on existing records (
unlink,write) also need the record IDs, and those are version-specific. Below Odoo 19 the IDs go inArgumentsas the first positional value, for example[[42]]. On Odoo 19 and above, whereArgumentsmust be empty, they go inKeywordArgsas{"ids": [42]}.
Using the wrong form produces a clear error. On Odoo 19 and above, supplying Arguments fails with Arguments parameter not supported in JSON API, It must be empty. Below 19, passing record IDs through KeywordArgs instead of Arguments fails inside Odoo with IndexError: tuple index out of range, because the method receives no positional recordset.
Native domain search
{
"procedure": "CallProcedure",
"parameters": {
"@Model": "res.partner",
"@Operation": "search_read",
"@KeywordArgs": "{\"domain\": [[\"is_company\", \"=\", true]], \"fields\": [\"name\", \"email\"], \"limit\": 3}"
}
}
Returns JSON, for example:
[{"id": 1, "name": "Acme Corporation", "email": "billing@acme.example"}]
Counting records
{
"procedure": "CallProcedure",
"parameters": {
"@Model": "res.partner",
"@Operation": "search_count",
"@KeywordArgs": "{\"domain\": []}"
}
}
Write Operations
Odoo supports INSERT and UPDATE through Connect AI where the connection has write access and the Odoo user's permissions allow it. Supply only the columns getColumns reports as writable.
If inserted numeric values come back wrong, check that the connection's Edition matches the deployment: yourcompany.odoo.com is Odoo Online, self-hosted is Odoo OnPremise. Edition does not change the schema, which is read from the instance and varies only by installed apps, user permissions and Odoo version. It affects only how the driver marshals float, decimal and double values on INSERT.
Create a record
INSERT INTO [YourConnection].[Odoo].[res_partner]
([name], [email], [is_company])
VALUES
('Acme Corporation', 'billing@acme.example', true)
Update a record
UPDATE [YourConnection].[Odoo].[res_partner]
SET [email] = 'new.address@acme.example',
[phone] = '+1-555-0100'
WHERE [id] = 42
Change a relationship
Set a many2one by writing its ID column. The _label companion resolves itself from that write and cannot be set directly.
UPDATE [YourConnection].[Odoo].[res_partner]
SET [country_id] = 233
WHERE [id] = 42
Writing the label instead fails with Column [country_id_label] could not be updated. This column is read-only. Look the target ID up in the table FieldReferences names for that column, here res_country.
Delete a record
There is no generic delete tool. Deletion goes through CallProcedure with Odoo's unlink operation, with the record IDs in the version-appropriate parameter described above.
Below Odoo 19:
{
"procedure": "CallProcedure",
"parameters": {
"@Model": "res.partner",
"@Operation": "unlink",
"@Arguments": "[[42]]"
}
}
On Odoo 19 and above:
{
"procedure": "CallProcedure",
"parameters": {
"@Model": "res.partner",
"@Operation": "unlink",
"@KeywordArgs": "{\"ids\": [42]}"
}
}
unlink returns true on success. Confirm any write with a follow-up SELECT rather than relying on a return value or an affected-row count alone.
If write operations are blocked, the Connect AI connection may not have write access enabled. Guide the user to their Connect AI connection settings to ensure write access is enabled.
Odoo-Specific Conventions
- Dot-name vs underscore name: tables use the underscore form (
res_partner), whileCallProceduretakes the Odoo dot-name (res.partner). Mixing them up is the most commonCallProceduremistake - Check
FieldReferencesbefore joining: it is the only reliable way to know whether a relationship column is a single ID you can JOIN or a comma-separated list you cannot - Read the
_label, write the_id:<column>_id_labelgives you the display name with no JOIN, but it is read-only. Set the relationship by writing the integer<column>_id - Joins produce ambiguous
namecolumns: almost every model has a barename, and an alias on a plain column is dropped from the result header, so a two-table join can return two columns both headedname. Prefer the_labelcompanion, or select only one table'sname - Multi-valued columns are not NULL when empty: they come back as an empty string, so
IS NOT NULLdoes not filter them. Use<> ''. This silently returns every row when you expected a filtered set - A missing table usually means a missing app: the error names the table it could not match. Confirm with
getTablesand checkir_module_module, rather than retrying - Never sort a large model by a computed, non-pushed-down column before narrowing it: ordering
account_movebyamount_totaltimes out, because the sort is not pushed down andLIMITis applied after it. Sort byidor a stored date column such ascreate_date, or filter the rows down first. This is the single most common way an Odoo query hangs. The cost is pushdown and row count rather than the column being monetary: the product and opportunity patterns sort bylist_priceandexpected_revenueon small models without trouble COUNT(DISTINCT ...)does not scale: it is accepted, but it is not pushed down, so it works on small or filtered sets and times out on large ones. If you reached for it to avoid double counting, you are usually joining two one-to-many relationships at once, so aggregate each in its own subquery instead- Archived records: Odoo archives with
active = falserather than deleting. AddWHERE [active] = truewhen you want only live records - Booleans are flexible:
true/false,1/0, and'true'/'false'all work in filters - Computed columns are read-only:
display_nameand every_labelcompanion cannot be written.getColumnsreports theReadonlyflag, so check it before composing a write - Selection columns hold codes, not labels:
move_type,state, andtypestore values likeout_invoice,draft, andopportunity. Sample the column before filtering on a guessed value create_dateis the stable time filter:write_dateis updated by many automated Odoo processes