Mapping Dolibarr - React

To expose Dolibarr objects to the React application, SmartMaker uses mapping classes prefixed with dm. A mapper describes which fields are published, under which name and which ones are writable.

Before writing a mapper: check that it does not already exist

SmartAuth ships a library of mappers for the Dolibarr core objects, in smartauth/dolMapping/. Do not rewrite your own for a standard object.

Domain Available mappers
Commercial documents dmInvoice, dmOrder, dmProposal, dmContract, dmSupplierInvoice, dmSupplierOrder, dmSupplierProposal, dmShipment, dmReception, dmDeliveryNote
Third parties and contacts dmThirdparty, dmContact, dmSupplier, dmUser
Catalogue and stock dmProduct, dmCategory, dmWarehouse, dmStockMovement
Production dmMo, dmBom
Members dmMember, dmMemberType, dmSubscription, dmDonation
Project and planning dmProject, dmTask, dmAgendaEvent, dmIntervention, dmExpenseReport, dmTicket
Accounting dmBankAccount, dmBank, dmCompanyBankAccount, dmMulticurrency
Dictionaries dmC*: dmCcountry, dmCstate, dmCpaymentterm, dmCunits, etc. (read only)

Important

A module only writes a mapper for its own business objects. For a core object it consumes the SmartAuth mapper, possibly by inheriting from it. A bug fixed in dolMapping/ benefits every module; a mapper duplicated locally recreates the debt that centralisation removed.

And often, no mapper at all

For the core objects, SmartAuth exposes a generic REST facade built on these mappers. A module then has no CRUD, no search and no pagination to write:

GET    objects/{objtype}                paginated list (filters, sorting, search)
GET    objects/{objtype}/describe       field schema (objectDesc)
GET    objects/{objtype}/{id}           one object
POST   objects/{objtype}                creation
PATCH  objects/{objtype}/{id}           update
DELETE objects/{objtype}/{id}           deletion

Documents with lines add objects/{objtype}/{id}/lines, workflows objects/{objtype}/{id}/actions/{action}, invoices objects/{objtype}/{id}/payments.

So write a mapper when you have your own Dolibarr class to expose. That is the case covered in the rest of this page.

Declaring a mapper

Minimal structure

<?php
namespace MyModule\Api;

// The target Dolibarr class MUST be loaded here
dol_include_once('/mymodule/class/myobject.class.php');

use SmartAuth\DolibarrMapping\dmBase;
use SmartAuth\DolibarrMapping\dmTrait;

class dmMyObject extends dmBase
{
    use dmTrait;

    // Mapper type: 'object' or 'dict'
    protected $type = 'object';

    // Dolibarr class represented: MANDATORY
    protected $dolibarrClassName = 'MyObject';

    // Element for the extrafields (elementtype column of llx_extrafields)
    protected $parentTableElementToUseForExtraFields = 'myobject';

    // Mapping: Dolibarr name => API name
    protected $listOfPublishedFields = [
        'rowid'         => 'id',
        'ref'           => 'ref',
        'label'         => 'label',
        'description'   => 'description',
        'fk_soc'        => 'thirdparty',
        'fk_statut'     => 'status',
        'date_creation' => 'created_at',
        'note_public'   => 'public_note',
        'note_private'  => 'private_note',
    ];

    // Write allowlist: DOLIBARR names, never API names
    protected $writableFields = [
        'label',
        'description',
        'note_public',
    ];

    public function __construct()
    {
        global $langs;
        $langs->load("mymodule@mymodule");
        $this->boot();
    }
}

Calling boot() at the end of the constructor is mandatory.

Recognised properties

Property Mandatory Role
$type yes object or dict
$dolibarrClassName yes for object exact name of the Dolibarr class represented
$listOfPublishedFields yes map of Dolibarr name to API name
$writableFields no, defaults to [] write allowlist, in Dolibarr names
$listOfDerivedFields no computed fields with no source column
$parentTableElementToUseForExtraFields no extrafields attachment
$parentClassName no only for a sub-object or line mapper
$parentClassNameForLines no Dolibarr class of the lines
$listOfPublishedFieldsForLines no map of the line fields
$parentLabelForLines no API key under which the lines are published
$parentFieldsOverride no patch of a field definition (type, required...)

Pitfall: $dolibarrClassName is not derived from the mapper name

Warning

$dolibarrClassName is mandatory on every mapper of type object. It is not derived from the class name, and for good reason: the derivation would be wrong in most cases.

Mapper Actual Dolibarr class What a derivation would give
dmThirdparty Societe Thirdparty
dmInvoice Facture Invoice
dmOrder Commande Order
dmProposal Propal Proposal
dmIntervention Fichinter Intervention
dmWarehouse Entrepot Warehouse
dmMember Adherent Member
dmShipment Expedition Shipment

The mapper validates its declaration at boot() and raises an explicit LogicException if $dolibarrClassName is missing, or if the announced class does not exist (typically, a forgotten dol_include_once).

Pitfall: never use $parentClassName on a top-level object

$parentClassName is only for a sub-object or line mapper, to reach back to its parent. A header mapper must not declare it.

// WRONG: Product has no parent, and the property is the wrong one
class dmProduct extends dmBase
{
    use dmTrait;
    protected $parentClassName = 'Product';
}

// CORRECT
class dmProduct extends dmBase
{
    use dmTrait;
    protected $type = 'object';
    protected $dolibarrClassName = 'Product';
}

// CORRECT: line mapper, with a real parent
class dmFichinterLigne extends dmBase
{
    use dmTrait;
    protected $type = 'object';
    protected $dolibarrClassName = 'FichinterLigne';
    protected $parentClassName   = 'Fichinter';
}

Remember the distinction in one sentence: $dolibarrClassName answers "who am I?", $parentClassName answers "who is my parent?". Declaring both identical raises a LogicException at boot.

Reading: exportMappedData()

exportMappedData() converts a Dolibarr object into a JSON object using the API field names.

$object = new \MyObject($db);
$object->fetch($id);
$object->fetch_optionals();  // extrafields
$object->fetch_lines();      // lines, if the object has any

$mapper = new dmMyObject();
$payload = $mapper->exportMappedData($object);

What the method does along the way:

  • it resolves the foreign keys declared in the Dolibarr $fields, over two levels of depth at most
  • it includes the extrafields declared as options_xxx
  • it includes the associated categories when the object has some (product, third party, contact, member)
  • it exposes nb_linked_files, and the full file list if $mapper->withFiles = true

Describing the schema to the front

objectDesc() returns the structural description of the object: fields, API types, translated labels, display position. This is what lets the front generate forms and lists without hard-coding the fields. The result is computed once at boot and cached.

$mapper = new dmMyObject();
$schema = $mapper->objectDesc();

Writing: writableFields and importMappedData()

importMappedData() is the inverse of exportMappedData(): it takes an API payload and returns a stdClass with Dolibarr names, ready to be applied to the object.

$mapper = new dmMyObject();

try {
    $sanitized = $mapper->importMappedData($payload);
} catch (\SmartAuth\DolibarrMapping\MapperValidationException $e) {
    return [['errors' => $e->getErrors()], 400];
}

$object = new \MyObject($db);
$object->fetch($id);
foreach (get_object_vars($sanitized) as $field => $value) {
    $object->$field = $value;
}
$object->update($user);

The contract

  • any field absent from $writableFields is rejected, not ignored
  • all the rejections are collected and reported in a single exception, not on the first one met
  • API names are automatically mapped back to Dolibarr names
  • each value is cast according to the type declared in the Dolibarr $fields: integer to int, price or double to float, bool* to 0 or 1, date to a timestamp
  • the lines key always raises an exception: lines do not go through importMappedData()

A mapper that does not declare $writableFields is entirely read only. That is the default behaviour, and it is deliberate.

Pitfall: writableFields holds Dolibarr names

Warning

Each entry of $writableFields must be a key of $listOfPublishedFields (the Dolibarr name), never a value (the API name). This is a silent bug: the field is rejected, no error is visible on the client side, and the object is never updated.

protected $listOfPublishedFields = [
    'nom'   => 'name',
    'email' => 'email',
];

// CORRECT
protected $writableFields = ['nom', 'email'];

// WRONG: 'name' is the API name, not the Dolibarr name
protected $writableFields = ['name'];

This pitfall was hit three times in production before being locked down. The boot now raises a LogicException listing the offending entries.

What importMappedData() does not do

  • no business validation: the mapper sanitises and casts, Dolibarr validates on persistence
  • no line writing: go through addline() and updateline()
  • best-effort casting: a string "abc" in an integer field becomes 0, with no warning

Transforming a value: fieldFilterValueXxx()

To transform a field before export, declare a public method fieldFilterValue followed by the Dolibarr name of the field in CamelCase. The method name is the contract: no annotation, no registration.

/**
 * Returns a signed URL instead of the raw file name.
 */
public function fieldFilterValueLogo($object, $value)
{
    return '/upload/societe/' . $object->id . '/' . urlencode($value);
}

/**
 * Fetches the associated contacts.
 */
public function fieldFilterValueContacts($object, $value)
{
    return $object->liste_contact(-1, 'external');
}

Typical use cases: converting a timestamp, translating a code into a label, computing a value derived from another field.

Derived fields without a Dolibarr column

To publish a key that is not backed by any column, declare it in $listOfDerivedFields and not in $listOfPublishedFields. The fieldFilterValueXxx() method is then called without first checking that the source field is present.

Warning

Do not return base64 images in a list field. A list of 200 third parties with their logo inline saturates the response and the local database of the PWA. The convention is to publish a media URL and, for lists, a logo_mini thumbnail.

Extrafields

Extrafields are published like ordinary fields, by prefixing their name with options_:

protected $listOfPublishedFields = [
    'options_mymodule_address'   => 'intervention_address',
    'options_mymodule_date_inter' => 'date_intervention',
];

The attachment is done through $parentTableElementToUseForExtraFields, which must be exactly the elementtype column of llx_extrafields for that object.

Extrafields configurable by the administrator

The SmartBoot skeleton shows the pattern: two constants list the extrafields to expose, read only and read-write, and the constructor adds them to the mapping.

public function __construct()
{
    global $db;
    $this->db = $db;

    $extRO = getDolGlobalString('MYMODULE_SMARTMAKER_EXTRAFIELDS_RO');
    if (!empty($extRO)) {
        foreach (explode(',', $extRO) as $field) {
            $field = trim($field);
            if (!empty($field)) {
                $key = 'options_' . $field;
                $this->listOfPublishedFields[$key] = $key;
            }
        }
    }

    $extRW = getDolGlobalString('MYMODULE_SMARTMAKER_EXTRAFIELDS_RW');
    if (!empty($extRW)) {
        foreach (explode(',', $extRW) as $field) {
            $field = trim($field);
            if (!empty($field)) {
                $key = 'options_' . $field;
                $this->listOfPublishedFields[$key] = $key;
                $this->writableFields[] = $key;
            }
        }
    }

    $this->boot();
}

Note

The extrafields themselves are never created in SQL. They are declared through $extrafields->addExtraField(...) in the init() of the module descriptor.

Objects with lines

// Dolibarr class of the lines
protected $parentClassNameForLines = 'MyObjectLine';

// Description of the line fields, to generate the form
protected $parentFieldsForLines = [
    'id'   => ['type' => 'integer',  'label' => 'ID',          'visible' => -1, 'position' => 10],
    'date' => ['type' => 'datetime', 'label' => 'Date',        'visible' => 1,  'position' => 50],
    'desc' => ['type' => 'html',     'label' => 'Description', 'visible' => 1,  'position' => 105],
    'qty'  => ['type' => 'integer',  'label' => 'Quantity',    'visible' => 1,  'position' => 110],
];

// Mapping of the line fields
protected $listOfPublishedFieldsForLines = [
    'id'       => 'id',
    'date'     => 'date',
    'desc'     => 'description',
    'qty'      => 'quantity',
    'subprice' => 'unit_price',
    'total_ht' => 'total',
];

// API key under which the lines are published
protected $parentLabelForLines = "linesDetail";

Lines are exposed for reading through this mechanism. For writing they go through the native Dolibarr methods addline(), updateline() and deleteline(), or through the facade routes objects/{objtype}/{id}/lines for the core objects.

Note

fetch() does not always load the lines. Call fetch_lines() before the export, otherwise the object comes out without its lines.

Adjusting a field description

$parentFieldsOverride patches the definition of a field coming up from Dolibarr, without touching the upstream class.

protected $parentFieldsOverride = [
    'duree'    => ['type' => 'duration', 'required' => 'required'],
    'contacts' => ['type' => 'array'],
    'fk_user'  => ['type' => 'select'],
];

Typically: rendering a duration stored in seconds as a duration field on the front side, or making mandatory a field that Dolibarr considers optional.

Naming convention for the API fields

The mapper is where the Dolibarr vocabulary is left behind. Follow the common convention, otherwise two modules will publish the same object in two different shapes.

Dolibarr API
rowid id
ref_client customer_ref
nom name
town city
fk_pays country
fk_departement state
phone_mobile mobile
url website
datec or date_creation created_at
tms updated_at
note_public public_note
note_private private_note
statut or status status

A mapper that publishes a status field also exposes status_label, the localised label, when the client explicitly asks for it.

Warning

On the PWA side, store in the local database only the name published by the API. Keeping the Dolibarr alias "just in case" builds a database where the same data lives under two keys depending on where it came from. Real case: a third-party list displayed "Unnamed" on every row because one screen read nom where the mapper publishes name, while another screen displayed the same third parties correctly.

Usage in a Controller

public function show($payload = null)
{
    global $db;

    $id = (int) $payload['id'];

    $object = new \MyObject($db);
    if ($object->fetch($id) <= 0) {
        dol_syslog(__METHOD__ . ' fetch failed for id=' . $id, LOG_ERR);
        return [['error' => 'not found'], 404];
    }
    $object->fetch_optionals();
    $object->fetch_lines();

    $mapper = new dmMyObject();

    return [$mapper->exportMappedData($object), 200];
}

public function update($payload = null)
{
    global $db, $user;

    $mapper = new dmMyObject();

    try {
        $sanitized = $mapper->importMappedData($payload);
    } catch (\SmartAuth\DolibarrMapping\MapperValidationException $e) {
        dol_syslog(__METHOD__ . ' rejected fields: ' . $e->getMessage(), LOG_WARNING);
        return [['errors' => $e->getErrors()], 400];
    }

    $object = new \MyObject($db);
    if ($object->fetch((int) $payload['id']) <= 0) {
        dol_syslog(__METHOD__ . ' fetch failed', LOG_ERR);
        return [['error' => 'not found'], 404];
    }

    foreach (get_object_vars($sanitized) as $field => $value) {
        $object->$field = $value;
    }

    if ($object->update($user) <= 0) {
        dol_syslog(__METHOD__ . ' update failed: ' . $object->error, LOG_ERR);
        return [['error' => $object->error], 500];
    }

    return [$mapper->exportMappedData($object), 200];
}

Dictionaries

A dictionary mapper describes one row of a llx_c_* table. Conventions:

  • protected $type = 'dict'; (the term dictionary is obsolete)
  • $dolibarrClassName declared if Dolibarr provides a dedicated class (Ccountry, Cstate, PaymentTerm, CUnits...), absent otherwise
  • $writableFields stays empty: dictionaries are managed from the Dolibarr administration
  • expose at least code and label

The mapper and offline synchronization

A point often discovered too late: offline synchronization has no mapping layer of its own. It goes through your Dm mappers, the very same ones the synchronous REST facade uses.

The syncable object registry

ObjectRegistry.php, on the SmartAuth side, is the single source of truth. For each of the 26 built-in types it declares the Dolibarr class, the table, the required rights and the mapper:

'thirdparty' => [
    'class'   => 'Societe',
    'table'   => 'societe',
    'mapper'  => '\\SmartAuth\\DolibarrMapping\\dmThirdparty',
    'rights'  => [ /* ... */ ],
],

That same registry serves the REST facade and the sync engine. There is therefore no risk of an object behaving differently depending on the entry door.

What the mapper decides for synchronization

Step What applies
Pull The SQL row is reloaded then passed through exportMappedData(). The client receives the published API field names, not the Dolibarr columns
Push The write allowlist is $writableFields. There is no second list specific to synchronization
Isolation For a table without an entity column, multi-company isolation comes from the mapper's isolationWhereSql()

Three practical consequences:

  • A mapper without $writableFields is read-only, therefore not writable through synchronization. The pull will work, the push will reject everything.
  • A field missing from $writableFields is silently rejected on push, exactly as on the REST facade. The client sees no error and the object is not updated.
  • If no mapper resolves for a type without an entity column, the engine pulls nothing and refuses any write. This fail-closed behaviour is deliberate: better to synchronize nothing than to leak another entity's data.

Declaring a custom syncable object

A module adds its own types through the smartmaker_registerSyncableObjects hook. The mapper is declared exactly as for a built-in type:

public function smartmaker_registerSyncableObjects($parameters, &$object, &$action, $hookmanager)
{
    $object['mymodule_product'] = [
        'class'      => 'Product',
        'file'       => DOL_DOCUMENT_ROOT.'/product/class/product.class.php',
        'table'      => 'product',
        'element'    => 'product',
        'module'     => 'product',
        'mapper'     => '\\SmartAuth\\DolibarrMapping\\dmProduct',
        'pull_where' => 'tosell = 1',
        'rights'     => [
            'read'   => ['produit', 'lire'],
            'create' => ['produit', 'creer'],
            'update' => ['produit', 'creer'],
            'delete' => ['produit', 'supprimer'],
        ],
    ];

    return 0;
}

The point of a dedicated type rather than the native product is the pull_where: it narrows the embedded dataset without affecting the other consumers of product. The server also publishes the exclusions in the delete list, which saves the client from having to clean up its orphans.

A client discovers the available types, and its effective rights on each of them, through GET /sync/objects. Hard-coding a synchronization scope on the PWA side is therefore unnecessary.

SmartAuth's document controller, however, only knows the native types (product, category, thirdparty, project, intervention). A custom type applies to entities, not to attached file downloads. See Synchronization - reference catalog.

Pitfalls to know about

Symptom Cause
LogicException on the first new dmXxx() $dolibarrClassName missing, or class not loaded by dol_include_once
LogicException mentioning the parent $parentClassName declared on a header mapper, or equal to $dolibarrClassName
a writable field is ignored with no error $writableFields holds the API name instead of the Dolibarr name
the object comes out without its lines fetch_lines() not called before the export
the extrafields are missing fetch_optionals() not called, or $parentTableElementToUseForExtraFields incorrect
huge response and slow PWA base64 images inline in a list field
empty front list or "Unnamed" the PWA reads the Dolibarr name instead of the published API name
the offline push writes nothing, with no error $writableFields empty, or holding the API name instead of the Dolibarr name
a syncable type returns no data no mapper resolves for a type without an entity column: the engine is fail-closed

See also