Découvrir l'API Dojo Data

Accéder aux sources de données à l'aide du framework Dojo

Emmanuel REMY

JDBC, ADO.NET, PDO, autant d'acronymes pour des couches de connexions aux données en Java, .NET ou PHP. Aujourd'hui chaque langage ou Framework propose sa couche « Data ». Et ce qui était réservé, il y a encore quelques années, à une démarche Server devient indispensable côté Client, dans nos navigateurs.

Consommer des données. Pas spécialement des données issues des SGBD, mais plutôt tout type de données, à partir du moment où on dispose… d'une source de données !

Rapidement chaque framework JavaScript a proposé son API Data pour unifier l'interrogation, la lecture, l'écriture et la sauvegarde de ces données, puis leur liaison avec les composants graphiques. Dojo n'y échappe pas, son API se nomme dojo.data, et je vous propose de la découvrir dans ce premier article d'une série de deux. Vous découvrirez progressivement toutes les possibilités des stores, et sous son aspect généraliste cet article se veut complet. Ainsi à l'issue de sa lecture chacun pourra porter un jugement sur cette API et pourra la manipuler sans encombre. ?

Télécharger les sources de l'application : ftp (ou http)

Télécharger le PDF de cet article : ftp (ou http)

- Titre : Découvrir l'API Dojo Data

- Auteur : Emmanuel REMY

- Parution : 1 janvier 2009

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2010 Emmanuel REMY. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Présentation

- III. L'API en bref

- IV. Chargement du store

- V. Le store en lecture

- V-A. Sélectionner les données

- V-A-1. fetch et fetchByIdentity

- V-A-2. Autres options de la fonction fetch

- V-A-3. Où est située la fonction fetch ?

- V-B. Les autres stores disponibles

- V-C. Données référencées

- V-D. Données hiérarchiques

- V-E. Gestion des types de données

- V-F. Cas du Lazy loading

- V-G. Liaison avec les composants (widgets)

- VI. Les stores en écriture

- VI-A. Ajout d'un item

- VI-B. Modification d'un item

- VI-C. Sauvegarde du store

- VI-D. Suppression d'un item

- VI-E. État modifié et annulation

- VI-F. Les notifications générées

- VI-F-1. Première approche : redéfinissons les fonctions

- VI-F-2. Seconde approche : dojo.connect

- VI-F-3. Cas des composants gérant des stores en écriture

- VII. Et maintenant ?

- VIII. Remerciements

I - Introduction

Que ce soit un jeu de résultats renvoyé par l'interrogation d'une page dynamique (PHP, Servlet, etc.), ou encore de Google, d'une liste d'images fournies par Flicker, d'un ensemble d'articles Wikipedia ou un flux RSS, nous avons aujourd'hui la possibilité de manipuler et d'alimenter des grilles, des listes, des galeries multimédias au sein des pages HTML en interrogeant ces sources de données. L'API dojo.data offre un accès unifié à sources de données, que celles-ci résident sur un server (données JSON par exemple) ou dans le document HTML (données JavaScript, table HTML par exemple). Le schéma général peut être celui-ci :

II - Présentation

L'API dojo.data définit des objectifs de lecture et d'écritures de sources de données (des « store » dans le vocabulaire Dojo), et des mécanismes événementiels ; on peut assimiler ces définitions à des interfaces en programmation objet. On peut aussi noter la prise en compte du lazy loading qui permet de ne pas charger l'ensemble des données en une seule fois, mais plutôt de les charger quand on cherche à y accéder.

Techniquement, tout type de store est valide, que ce soit un accès à un SGBD ou à un flux XML.

Par défaut Dojo fournit une implémentation « standard » qui s'appuie sur la manipulation de données brutes au format JSON.

III - L'API en bref

L'API de dojo.data est définie dans les sources de dojo :

Elle est constituée des éléments suivants :

- identity.js définit comment identifier de manière unique un item d'un store ; peut être apprécié comme l'équivalent de la clé primaire d'une table. Tout store qui est constitué de données « repérables » par une clé doit implémenter les fonctions suivantes :

Nom

Rôle

getIdentity: function ( /* item */ item)

renvoie la clé de l'item, sous une forme quelconque (String, Array…). Cette clé est unique et le store génère une erreur en cas de doublon

fetchItemByIdentity: function ( /* Object */ keywordArgs)

renvoie un item selon sa clé. À noter que la clé est passée comme attribut de l'objet keywordArgs (identity), ainsi qu'une fonction de callback onItem, voire onError

- notification.js définit la signature des événements qui peuvent être générés par le store (insertion de ligne, modification ou suppression) et de là supervisés par l'intermédiaire du gestionnaire dojo.connect. Les événements sont :

Nom

Rôle

onSet: function ( /* item */ item, / * attribute-name-string */ attribute,
/* object | array */ oldValue,
/* object | array */ newValue)

déclenché en cas de mise à jour de l'attribut de l'item

onNew: function ( /* item */ newItem, /*object?*/ parentInfo)

déclenché en cas d'insertion d'un item dans le store. Si l'item réside dans une sous-arborescence (ex. : arbre) parentInfo référence l'élément parent (structure particulière)

onDelete: function ( /* item */ deletedItem)

déclenché en cas de suppression de l'item
Note : au moment de l'appel item n'est plus dans le store

- read.js définit les fonctions de lectures des valeurs et autres attributs des éléments du store ; nous reviendrons en détail sur ces fonctions.

Nom

Rôle

getValue: function (/ * item */ item,
/* attribute-name-string */ attribute,
/* value? */ defaultValue)

Renvoie la valeur d'un attribut de l'item

getValues: function ( /* item */ item,
/* attribute-name-string */ attribute)

Idem, mais retourne la valeur sous la forme d'un tableau

getAttributes: function ( /* item */ item)

Renvoie une liste des attributs de l'item (pas les valeurs…)

hasAttribute: function ( /* item */ item, /* attribute-name-string */ attribute)

Teste l'existence d'un attribut pour l'item

containsValue: function ( /* item */ item, /* attribute-name-string */ attribute, /* anything */ value)

Teste l'existence d'une valeur pour un attribut donné (l'attribut peut être multivalué) d'un item

isItem: function ( /* anything */ something){

Vérifie que something est bien un item du store (en termes de référence). Un objet du store est considéré comme « managé » par celui-ci. On peut donc être amené à tester l'appartenance d'une variable (un objet) à un store

isItemLoaded: function ( /* anything */ something)

Au cœur du lazy loading renseigne si l'élément something est complètement chargé ou pas. si ce n'est pas le cas, alors on aura la possibilité de le charger le moment venu

loadItem: function ( /* object */ keywordArgs

Charge un item (donc isItemLoaded renverra true); keywordArgs est un objet précisant l'item à charger et des fonctions de callback telles que onItem et onError

fetch: function ( /* Object */ keywordArgs)

C'est LA fonction la plus utilisée : elle lance le chargement des données et/ou leur filtre. On peut la voir comme le résultat d'une requête SQL sur le store. On lui passe donc un objet keywordArgs qui comprend la description d'une requête (query + tri, plage…) et les fonctions de callback possibles pour suivre l'évolution du fetch. Notons onBegin déclenchée avant la mise à disposition du premier item, onItem déclenché pour chaque item rapatrié, onComplete déclenché une fois tous les items chargés, et onError

close: function ( /*dojo.data.api.Request || keywordArgs || null */ request)

ferme le store (note : très rarement implanté)

getLabel: function ( /* item */ item)

Renvoie le texte caractérisant l'item pouvant servir pour affichage

getLabelAttributes: function ( /* item */ item)

Renvoie un tableau des attributs utilisés pour afficher le label

- write.js définit les fonctions utilisées pour mettre à jour un store. Les stores qui implémentent cette API implémentent aussi l'API Read, car l'API Write hérite de l'API Read.

Nom

Rôle

newItem: function ( /* Object? */ keywordArgs, /*Object?*/ parentInfo){

Insère un item dans le store. Si l'item réside dans une sous-arborescence (ex. : arbre) parentInfo référence l'élément parent (structure particulière)

deleteItem: function ( /* item */ item)

Supprime l'item du store

setValue: function ( /* item */ item,
/* string */ attribute,
/* almost anything */ value)

Modifie un attribut de l'item, on pourrait imaginer modifier directement l'objet item, mais dans ce cas cela ne permettrait pas à Dojo de générer l'événement de notification onSet et de gérer une annulation des modifications (équivalent d'un rollback)

setValues: function ( /* item */ item,
/* string */ attribute,
/* array */ values)

Modifie un attribut multivalué de l'item

unsetAttribute: function ( /* item */ item,
/* string */ attribute){

Supprime la/les valeurs d'un attribut

save: function ( /* object */ keywordArgs)

déclenche la sauvegarde des éléments modifiés dans le store (ajout, modfication, suppression). Note: dans la majorité des cas la sauvegarde n'est pas automatique, mais doit être implantée sur le server. Cela est généralement réalisé de manière asynchrone (Ajax) et une fonction de callback onComplete permet d'informer l'utilisateur de la fin de ce traitement. Le save fait office de Commit

revert: function ()

annule toutes les modifications (fait office de Rollback)

isDirty: function ( /* item? */ item)

indique si l'item a été modifié depuis le dernier appel à save()

Par défaut dojo fournit une implémentation de l'API qui manipule soit en local des tableaux d'objets javascript, soit à distance des structures JSON (fichiers de données, script PHP délivrant le code JSON…). Ce sont les class dojo.data.ItemFileReadStore et dojo.data.ItemFileWriteStore ; nous les utiliserons dans les exemples suivants, mais on ne doit pas oublier que si ce sont les class les plus couramment utilisées elles ne sont finalement que des implémentations de l'API et à ce titre n'ont rien de « standard » dans leur construction. Chaque implémentation expose ses propres caractéristiques.

IV - Chargement du store

Le constructeur de la class dojo.data.ItemFileReadStore (ou dojo.data.ItemFileWriteStore , le fonctionnement est identique) obtient les données JSON dont le format intègre la clé (attribut « identifier »optionnel), une étiquette caractérisant la donnée (attribut « label » optionnel), et les items :

- soit à partir d'un fichier :

var paysStore = new dojo.data.ItemFileReadStore({url: 'pays.json'});

- soit en chargeant directement les données :

var datas = {identifier: 'abbr',
label: 'nom',
items: [
{ abbr:'fr', nom:'France',             capitale:'Paris' },
{ abbr:'eg', nom:'Egypte',             capitale:'Le Caire' },
{ abbr:'sv', nom:'San Salvador',       capitale:'San Salvador' },
{ abbr:'de', nom:'Allemagne',          capitale:'Berlin' }
]};
var paysStore = new dojo.data.ItemFileReadStore({data: datas});

- soit en inscrivant du code HTML qui sera parsé par dojo ;

<div dojoType="dojo.data.ItemFileReadStore" jsId="paysStore"
url="pays.json">
</div>

La tentation est grande d'abuser de chargements de fichiers de données (code rendu plus simple, habitudes, etc.). Il ne faut pas oublier que chaque chargement implique une connexion asynchrone (ajax) avec le server et donc autant de ressources à monopoliser et de temps d'attente. En termes d'efficacité, au moment de la génération de la page, si vous connaissez vos données composant le store alors injectez-les directement. En bref, préférez nettement:

Contrairement à ce qu'on peut imaginer, les données ne sont pas chargées au moment de la construction du store. Pour ce faire, il faut effectuer une requête de sélection : un fetch.

V - Le store en lecture

V-A - Sélectionner les données

V-A-1 - fetch et fetchByIdentity

Les fonctions fetchItemByIdentity et fetch sont couramment utilisées. Chaque fonction prend comme paramètre un objet incorporant attributs et fonctions de callback (onItem, onError…). Dans l'API cet objet est nommé keywordArgs. À prendre en considération : les appels peuvent être asynchrones (la première fois) si les données doivent être récupérées depuis une URL.

- fetchItemByIdentity

var paysStore = new dojo.data.ItemFileReadStore({url: 'pays.json'});
console.log("Pays cherché: identity=fi");
paysStore.fetchItemByIdentity({
identity:"fi",
onItem: function(item) {
//on obtient le nom du pays concerné
var nom = paysStore.getValue(item,"nom","inconnu");
console.info("Le pays est:", nom);
}
onError: function(err) {
console.info(err.message);
}
});

- Fetch, exemple 1 : Récupération de tous les pays commençant par « A » ; d'abord la fonction de callback qui traite séparément chaque item récupéré, ensuite celle d'erreur, et enfin le fetch

var onItem = function(item, request) {
var nom = paysStore.getValue(item,"nom","inconnu");
console.info("Pays trouvé:", nom);
};

var onError = function(errorData, request){
console.error("Erreur: ", errorData);
};

console.log("Pays commençant par A");
paysStore.fetch({ query: {nom: "A*"},
onItem: onItem,
onError: onError
});

- Fetch, exemple 2 : récupération de tous les pays, mais avec un traitement au lancement de la requête (onBegin) et un autre global (onComplete) pour accéder à tous les items récupérés. On observe l'utilisation du paramètre scope qui indique dans quel contexte s'exécutent les fonctions de callback. Autrement dit, ici l'utilisation du mot clé this dans les fonctions onBegin et onComplete fait référence à paysStore. On peut faire l'analogie avec les fonctions javascript call et apply. À noter enfin que dojo.forEach utilise cette même notion de scope.

paysStore.fetch({ query: {nom: "*"},
scope: paysStore, //le scope du fetch est paysStore
onBegin: function(size, request) {
console.group("Tous les pays");
console.info("Nombre de pays:", size);
console.info("Le scope de la fonction: " ,this)
},
onComplete: function(items, request) {
dojo.forEach(items, function(item) {
//this vaut donc ici paysStore
console.info("Pays:", this.getValue(item,"nom","inconnu"),
"Capitale:", this.getValue(item,"capitale","inconnu"));
},this); //on transmet le scope à la fonction forEach

console.groupEnd();
} //onComplete
});

- Recharger un store ou, rafraichir les données : même si chaque store peut avoir sa propre implémentation, on peut donner les grandes lignes pour recharger un store :

- fermer le store (fonction close): cette fonction permet de réinitialiser le store dans son état d'origine et de vider les caches, tableaux internes et autres variables utilisées ;

- affecter les nouveaux attributs (URL, datas…) ;

- accéder aux données.

paysStore.clearOnClose=true; //demande le vidage de toutes les données
paysStore.close();
paysStore.url="pays_asie.json";
paysStore.fetch({ query: {nom: "*"},
//suite du code ici
});

Après avoir concentré nos exemples sur le store ItemFileReadStore il est intéressant de découvrir quelques autres exemples de store un peu plus exotiques, mais non moins utiles :

- GoogleSearchStore : effectue une recherche dans Google et renvoie des items dont chacun représente un résultat :

//dojo.require("dojox.data.GoogleSearchStore");
var googleStore = new dojox.data.GoogleSearchStore();
googleStore.fetch({
query: {
text: "dojotoolkit"
},
count: 10, //le nombre de résultats attendus
start: 11,  //si on souhaite paginer, par exemple à partir du 11e résultat
onItem: function(item) {
console.info(item);
console.info(item.title, item.url, item.content);
},
onError: function(err) {
console.info(err.message);
}

});

- FlickrStore : lance une requête vers le site d'images flicker et obtenir en retour une liste d'images correspondantes.

/**
* Utilisation d'un store effectuant une requête vers Flicker
*/
//dojo.require("dojox.data.FlickrStore");
var flickrStore = new dojox.data.FlickrStore();
function onComplete(items, request){
dojo.forEach(items, "console.info(item.link, item.title)");
}
flickrStore.fetch({ query: {tags: "zidane"},
count: 5,
onComplete: onComplete,
onError: function(err) {
console.info(err.message);
}
});

- WikipediaStore : interroge Wikipedia. À noter ici qu'on s'assure que l'item est réellement chargé en appelant la fonction loadItem (cela n'est pas assurément nécessaire dans notre cas, mais montre comment fonctionne dans le cas général le lazy loading)

//dojo.require("dojox.data.WikipediaStore");
var ws = new dojox.data.WikipediaStore();
ws.fetch({
query: { action: "query", text: "Saturn 1B" },
count: 5,
onComplete: function(items, request){
//note: items[0] est déjà chargé et exploitable
console.warn("Item chargé:", items[0]);
//et si on souhaitait le compléter en cas de lazy loading
ws.loadItem({
item: items[0],
onItem: function(loadedItem, loadedRequest){
console.info(loadedItem);
console.info(loadedItem.title,loadedItem.images);
}
});
},
onError: function(err) {console.info(err.message);}
});

- HtmlTableStore : permet d'accéder à une table HTML standard en considérant que les TH de la table sont les noms de colonnes et que chaque TR concerne une ligne du store. Ainsi une telle structure :

<table id="livres">
<thead>
<tr><th>isbn</th><th>titre</th><th>auteur</th></tr>
</thead>
<tbody>
<tr>
<td>A978-2253099185</td>
<td>Le fond de l'enfer </td>
<td>Ian Rankin</td>
</tr>
...

peut être accédé sous la forme d'un store, en précisant les données et l'id la TABLE à traiter :

/**
* Le store HtmlTable
*
*/
//dojo.require("dojox.data.HtmlTableStore");
var livresStore = new dojox.data.HtmlTableStore({url: "livres.html", tableId: "livres"});
function onComplete(items, request){
dojo.forEach(items, function(item) {
console.info(
livresStore.getValue(item,'titre'),
livresStore.getValue(item,'auteur'),
livresStore.getValue(item,'isbn'));
});
}
livresStore.fetch({query:{isbn:"*"},
onComplete: onComplete,
onError: function(err) {
console.info(err.message);
}
});

V-A-2 - Autres options de la fonction fetch

L'API de la fonction fetch définit exactement la structure de l'objet keywordArgs utilisé comme son unique paramètre :

keywordArgs:
{
query: l'objet requête ou la String requête,
queryOptions: les options possibles de la requête,
onBegin: fonction appelée au démarrage,
onItem: fonction appelée à chaque item ramené,
onComplete: fonction appelée en fin de fetch, et qui propose un tableau de TOUS les items ramenés,
onError: fonction appelée en cas d'erreur,
scope: un objet précisant le contexte d'exécution du fetch (le contexte du « this »),
start: le nombre d'items à sauter avant de commencer à en ramener,
count: le nombre d'item à ramener,
sort: un tableau d'objets renseignant le tri à appliquer
}

Détaillons quelques caractéristiques, les autres ayant été abordées lors des précédents exemples :

- query : il est recommandé d'utiliser un objet comme paramètre, et non une simple chaine comme cela est autorisé. En effet certains composant dijit tels que la combobox chercheront exclusivement un objet. L'objet peut contenir plusieurs attributs à filtrer et l' « * » et le « ? » sont utilisables (caractères joker). Par exemple :
query : {nom: 'dupon?', ville: 'gren*' } ;

- queryOptions : chaque store peut accepter des options spécifiques ; « normalement » tous les stores devraient au moins implanter l'option ignoreCase: true/false indiquant si on doit tenir compte des majuscules/minuscules lors des recherches. Concrètement, si effectivement ItemFileReadStore le fait, beaucoup font l'impasse… D'un autre côté d'autres stores proposent des options complémentaires (pagination sur le server, etc.) non prévues ; il est donc important de se documenter avant d'utiliser un store au risque de manquer des spécificités importantes.

- Sort : le fetch peut spécifier le type de tri à appliquer aux données. On fournit un tableau de couple attribut/sens du tri. C'est le store qui effectue alors le tri. Là encore, on peut noter que le tri peut avoir lieu sur le poste client ou sur le server selon le type d'implémentation retenu. Par défaut le tri est croissant
Ex. : . . . sort : [{attribute: 'nom', descending:true}, {attribute: 'prenom'}]

V-A-3 - Où est située la fonction fetch ?

À l'étude de différents stores on s'aperçoit que la fonction fetch n'existe pas dans la class !

Techniquement dojo fournit une fonction fetch « standard » (dans le fichier simpleFetch.js), qui met en œuvre les fonctions de callback (onBegin…) et la logique associée. Ainsi beaucoup de stores ajoutent cette fonction directement dans leur code via une simple extension :

Ex. : dojo.extend(dojo.data.ItemFileReadStore,dojo.data.util.simpleFetch).

Le souci de cette démarche est que le code de cette fonction est très peu malléable (mal conçu ?) et certains stores n'ont pas d'autres choix que de réécrire la fonction… (cas du QueryReadStore).

V-B - Les autres stores disponibles

dojox.data.AtomReadStore

Accès aux flux d'informations Atom

dojox.data.CouchDBRestStore

Accès à la base de données Apache CouchDB (base de données de type clé/valeur, particulièrement adaptée dans le cadre d'échange JSON,
http://couchdb.apache.org/

dojox.data.CsvStore

Accède à des données au format CSV

dojox.data.FileStore.js

Offre une exploration d'un Filesystem côté server. Nécessite bien sûr la mise en place de code server décrivant les répertoires et fichiers, sous la forme d'échanges JSON

dojox.data.FlickrRestStore

Idem que le store Flicker, mais par un accès JSON

dojox.data.KeyValueStore

Fournit un accès aux données inscrites sous la forme clé:valeur

dojox.data.PicasaStore

Idem Flicker, mais pour Google Picasa

dojox.data.QueryReadStore

Store orienté Server : lors d'un fetch il obtient les données en interrogeant le server vers une une URL passée lors de sa construction
var store = new dojox.data.QueryReadStore({url:'/fetch.php'});
store.fetch({serverQuery:{nom:'a%'});
Ce store est parfait pour les gros volumes de données.
Voir ci-dessous « volume des données »

dojox.data.OpenSearchStore

Implémente les méthodes pour accéder à tout moteur de recherche qui implémente l'interface OpenSearch

dojox.data.OpmlStore

Accès aux données XML de la norme OPML (partage de fils RSS…)

dojox.data.PersevereStore

Même type de store que CouchDB et soutenu par la fondation Dojo (http://www.persvr.org/)

dojox.data.S3Store

Accès aux services de données Amazon S3: échanges JSON

dojox.data.StoreExplorer

Un store qui explore les données des… stores !

dojox.data.XmlStore

Décompose un flux XML pour offrir un accès aux données par l'API dojo.Data. À noter qu'on peut passer des requêtes Xpath comme query.
Ex. :
store.fetchItemByIdentity({identity: « //livres », onItem: onItem, onError: onError});
Note : si vous souhaitez sélectionner des données XML, retournez-vous prioritairement vers dojo.query ou dojox.xml.*

dojox.data.ServiceStore

Un store qui offre une interface vers des services RPC au sens Dojo (voir dojox.rpc.Service pour plus d'informations)
Ce store est surtout connu pour son « héritier », JsonRestStore.

dojox.data.JsonRestStore (étend ServiceStore)

Interface JSON HTTP/REST web qui prend en charge les lectures et écritures via les méthodes HTTP GET, PUT, POST, et DELETE. Ce store supporte le Lazy Loading, et devrait être couramment utilisé, même s'il est d'un abord difficile.
Voir ci-dessous (« volume des données »)

dojox.data.SnapLogicStore

Accès à des structures d'intégration de données SnapLogic,http://www.snaplogic.org/

V-C - Données référencées

L'API ne précise rien quant à l'organisation des données au sein d'un store ni de leur contenu. Chaque store peut proposer ses « extensions ». Ainsi le store le plus utilisé (notre store de démonstration dojo.data.ItemFileReadStore ) autorise des liens entre données, en référençant une donnée par sa clé (identity). Par exemple, pour spécifier les pays frontaliers d'un pays, on peut présenter le store ainsi :

{ identifier: 'abbr',
label: 'nom',
items: [
{ abbr:'su',
nom:'Suede',
capitale:'Stockholm',
frontieres:[{_reference:'da'},
{_reference:'fi'},
{_reference:'no'}]
},
/* . . .
ICI D'AUTRES DESCRIPTIONS DE PAYS AVEC
LEURS FRONTIÈRES
*/
//enfin la liste complémentaire des pays pouvant être référencés
{ abbr:'it', nom:'Italie',             capitale:'Rome' },
{ abbr:'eg', nom:'Egypte',             capitale:'Le Caire' },
{ abbr:'de', nom:'Allemagne',          capitale:'Berlin' },
{ abbr:'es', nom:'Espagne',            capitale:'Madrid' },
{ abbr:'da', nom:'Danemark',           capitale:'Copennagh' },
{ abbr:'pb', nom:'Pays-Bas',           capitale:'Amsterdam' }
/* . . . ICI LA SUITE DES PAYS */
]}

Pour naviguer dans ce store et obtenir les informations concernant la Suède, on effectuera alors :

var paysStore = new dojo.data.ItemFileReadStore({url: 'pays_references.json'});
console.log("On cherche la Suède: identity=su");
paysStore.fetchItemByIdentity({identity:"su",
onItem: function(item) {
//on obtient le nom du pays concerné
var nom = paysStore.getValue(item,"nom","inconnu");
var capitale = paysStore.getValue(item,"capitale","inconnue");
console.info("Le pays est:", nom, " sa capitale:", capitale);
//on cherche ses frontières, notez l'utilisation de getvalues
dojo.forEach(paysStore.getValues(item, "frontieres"),                     function(frontiere) {
console.log("Pays frontalier: ",                                   paysStore.getValue(frontiere, "nom"));
});
},
onError: function(err) {
console.info(err.message);
}
}
);

Ayant accédé aux données du pays, il nous faut obtenir la liste des frontières et donc passer par la lecture de l'attribut multivalué « frontières »

D'autres stores (dont JsonRest) autorisent aussi les références, parfois de manière encore plus complète.

V-D - Données hiérarchiques

L'API n'introduit aucune notion de données « hiérarchiques », telles qu'un arbre. Mais techniquement on a vu dans l'exemple précédent des pays que cette représentation est possible quand on a introduit la notion de liste de « frontieres ». Le composant dijit.Tree tire profit de cette caractéristique pour afficher son arborescence (à noter que l'attribut par défaut attendu par le tree est « children »).

V-E - Gestion des types de données

L'API ne dit rien quant aux types de données des attributs d'un item. Ce qui peut s'avérer génant quand on souhaite manipuler une donnée sous la forme d'une date ou d'un nombre. Là encore chaque store est libre de fournir une extension pour prendre en charge cette fonctionnalité. Par exemple JsonRestStore peut s'appuyer sur des schémas JSON quand notre store de démonstration dojo.data.ItemFileReadStore accepte des informations complémentaires passées dans le constructeur.

Ex. : introduisons la date de mise à jour (maj) au format français, qu'on souhaite manipuler dans le store directement au format Date :

{ identifier: 'abbr',
label: 'nom',
items: [
{ abbr:'su',
nom:'Suede',
maj:{_type:'DateFR', _value:'05/12/2009'},
capitale:'Stockholm',
frontieres:[{_reference:'da'},
{_reference:'fi'},
{_reference:'no'}]
},
. . .
}

et effectuons le mapping entre le type personnel « DateFR » et le type standard « Date » : on ajoute un attribut typeMap dans l'objet passé au constructeur.

var paysStore = new dojo.data.ItemFileReadStore({url: 'pays_type.json',
typeMap:{'DateFR': {type: Date,
deserialize: function(value){
return dojo.date.locale.parse(value,{selector:'date',datePattern:'dd/MM/yyyy'});
}}
}
});
console.log("On cherche la Suède: identity=su");
paysStore.fetchItemByIdentity({identity:"su",
onItem: function(item) {
//on obtient le nom du pays concerné
var nom = paysStore.getValue(item,"nom","inconnu");
console.info("Le pays est:", nom);
//on obtient la date de maj, convertit en objet JS Date
var maj = paysStore.getValue(item,"maj","01/01/2010");
console.info("La date de maj est un objet Date:", maj instanceof Date ? "oui" : "non", maj);

},
onError: function(err) {
console.info(err.message);
}
}
);

Si cette conversion de données peut sembler intéressante, elle est très contraignante dans la construction de la structure JSON qu'on imagine souvent comme la sérialization d'un objet sur le server. Néanmoins étant spécifique à ce store, on peut imaginer voir ajoutée cette fonctionnalité de typage des données de manière plus souple dans d'autres stores…

Notons enfin que le mapping peut aussi fournir une fonction serialize qui est symétrique de deserialize et sera utile dans les stores en écriture (par exemple ItemFileWriteStore).

V-F - Cas du Lazy loading

Pour rappel on entend par « lazy loading » la capacité de charger certaines données au moment de leur premier accès et non lors du chargement du store.

Rien dans l'API data ne formalise le lazy loading. Néanmoins certaines fonctions permettent de réaliser cette fonctionnalité: isItemLoaded et loadItem.

Ainsi une technique utilisée est de charger un item en indiquant dans un attribut qu'il est incomplet, puis de définir les fonctions précédentes pour qu'elles chargent ses données complètes. Peu de stores implémentent le « lazy loading » (ItemFileReadStore ne l'implémente pas). JsonRestStore est certainement le plus complet et dispose de fonctionnalités supplémentaires que nous détaillerons dans un second article.

Un autre exemple de store est donné dans la documentation dojo (dojox/datas/demos/stores/LazyLoadJSIStore.js) :

{
. . .
items: [
{ name:'Africa', type:'continent',
children:[{_reference:'Egypt'}, ...] },
{ name:'Egypt', type:'stub', parent: 'geography'},
{ name:'Kenya', type:'stub', parent: 'geography'},
...

Ici, si l'item contient un attribut type qui vaut stub alors on n'est pas en présence de l'item complet et on doit charger ses données. Soit la fonction isItemLoaded :

isItemLoaded: function(item) {
if(this.getValue(item, "type") === "stub"){
return false;
}
return true;
},

Ensuite si cette fonction renvoie true, alors dojo appellera loadItem pour le complément des données. Reste donc à coder cette fonction.

Notons aussi que la mise en œuvre du « lazy loading » doit être réfléchie; en effet chaque requête complémentaire peut se traduire dans un aller-retour vers le server et de là être consommatrice de ressources…

V-G - Liaison avec les composants (widgets)

Les composants Dojo (dijit ou widgets) peuvent accepter un store comme source de données. Dans ces conditions il suffit généralement de créer le store et de le lier sous la forme d'un attribut du composant.

Ex. :

- FilteringSelect :

<div  dojoType="dojo.data.ItemFileReadStore"
jsId="clientStore"
url="datas.php?action=prestaff/clients&sort=nom">
</div>
<div  dojoType="dijit.form.FilteringSelect"
value=""
promptMessage="Choix du client..."
store="clientStore"
searchAttr="nom"
id="client"></div>

- Tree :

<div dojoType="dojo.data.ItemFileReadStore" jsId="paysStore"
url="_data/pays_references.json"></div>
<div dojoType="dijit.tree.ForestStoreModel" jsId="paysModel"
store="paysStore" query="{capitale:'*'}"
rootId="pays" rootLabel="Pays" childrenAttrs="frontieres"></div>
<div dojoType="dijit.Tree" id="mytree"
model="paysModel"></div>

- Grid :

<span dojoType="dojo.data.ItemFileReadStore"
jsId="paysStore" url="pays.json">
</span>
<table dojoType="dojox.grid.DataGrid"
jsid="grid" id="grid"
store="paysStore" query="{ nom: '*' }" >
<thead>
<tr>
<th field="nom" width="200px">Pays</th>
<th field="capitale" width="auto">Capitale</th>
</tr>
</thead>
</table>

VI - Les stores en écriture

L'API dojo.data.Write hérite de l'API de dojo.data.Read. Autrement dit les fonctionnalités d'un store en écriture complètent celles d'un store en lecture. Peu de stores proposent une API en écriture. Le store « standard » est dojo.data.ItemFileWriteStore qui complète dojo.data.ItemFileReadStore. Un autre store important est JsonRestStore.

Les tableaux 2 et 4 vus plus haut présentent les fonctions de l'API écriture/notification.

Fonctionnellement, on peut créer (newItem), modifier (setValue, setValues), supprimer (deleteItem) des items dans le store, chaque opération pouvant recevoir une notification (onSet, onNew, onDelete). Enfin les données peuvent être sauvegardées (save) et on peut savoir si un item a été modifié depuis la dernière sauvegarde (isDirty).

Si la fonction save existe dans le store dojo.data.ItemFileWriteStore elle ne fait pas la sauvegarde des données JSON sur le server !

Pour la suite de nos exemples, nous utiliserons la même structure vue précédemment, avec les pays et leurs frontières.

VI-A - Ajout d'un item

Ajoutons la Suisse à notre liste de pays :

var paysStore = new dojo.data.ItemFileWriteStore({url:'pays_save.json'});
/**
* on ajoute la Suisse dans le store
* on fournit une structure correcte
*/
var newPays = paysStore.newItem({
abbr: "sw",
nom : "Suisse",
capitale: "Geneve"
});

//1: vérification
console.log("newPays est dans le store ? ", paysStore.isItem(newPays));

//2: autre vérification: l'ajout est effectif
paysStore.fetchItemByIdentity({identity:"sw",
onItem: function(item) {
var nom = paysStore.getValue(item,"nom","inconnu");
console.info("Le pays est:", nom);
},
onError: function(err) { console.info(err.message); }
});

Dans le cas de store hiérarchique (type arbre) il est possible d'ajouter un second paramètre à la fonction newItem, renseignant le parent de l'élément inséré et l'attribut du parent concerné.

VI-B - Modification d'un item

La modification d'un item reprend la même logique que la lecture d'un item. On affecte un attribut du store: soit l'attribut est simple et on utilise la fonction setValue soit l'attribut est multivalué (comme dans l'exemple des frontières) et alors on utilise setValues.

Modifions la capitale Suisse :

//Oups ...
paysStore.setValue(newPays,"capitale","Berne");
//1: vérification
console.info("La capitale Suisse: ", paysStore.getValue(newPays, "capitale"));
//2: seconde vérification
paysStore.fetchItemByIdentity({identity:"sw",
onItem: function(item) {
var capitale = paysStore.getValue(item,"capitale");
console.info("La capitale Suisse est:", capitale);
},
onError: function(err) { console.info(err.message); }
});

//on remet Geneve en place
paysStore.setValue(newPays,"capitale","Geneve");
console.info("De nouveau la capitale Suisse: ",     paysStore.getValue(newPays, "capitale"));

Ajoutons la Suisse à la liste des frontières de la France en effectuant une mise à jour multivaluée (setValues).

//On modifie les frontières françaises pour ajouter la Suisse
var france;
var suisse = newPays;
paysStore.fetchItemByIdentity({identity:"fr",
onItem: function(item) {
france = item;
},
onError: function(err) { console.info(err.message); }
});
//on obtient les frontières
var frontieres = paysStore.getValues(france, "frontieres");
//on ajoute la Suisse aux frontières
paysStore.setValues(france, "frontieres",frontieres.concat(suisse));
//Verification
paysStore.fetchItemByIdentity({identity:"fr",
onItem: function(item) {
console.group("Frontieres de la France");
//on affiche les frontières
dojo.forEach(paysStore.getValues(item, "frontieres"), function(frontiere) {
console.log(paysStore.getValue(frontiere, "nom"));
});
console.groupEnd();
},
onError: function(err) { console.info(err.message); }
}
);

Cas des composants: certains composants peuvent mettre à jour le store qu'ils manipulent. C'est le cas de la grille (dojox.Grid) quand les colonnes sont configurées en édition (editable='true') :

<table dojoType="dojox.grid.DataGrid"
id="grid" store="paysStore" query="{ name: '*' }">
<thead>
<tr>
<th field="name" width="300px">Country/Continent Name</th>
<th field="type" editable="true"
width="auto" cellType="dojox.grid.cells.Select"
options="country,city,continent">Type</th>
</tr>
</thead>
</table>

VI-C - Sauvegarde du store

La sauvegarde du store valide toutes les modifications en suspens. On peut la comparer à un commit en SQL. Selon le type de store, cette sauvegarde est « locale » (en mémoire, cas de ItemFileWriteStore) et/ou distante (cas de JsonRestStore). Validons la création de la Suisse :

//sauvegarde
paysStore.save();

On peut ajouter des fonctions de callback onComplete et onError.

//sauvegarde
paysStore.save({
onComplete: function() {
console.info("Sauvegarde efectuée");
}, //onComplete
onError: function(err) { console.info(err.message); }
}
);

Notons aussi que ItemFileWriteSore propose aussi d'appeler deux autres fonctions (hors API), mais qui ne sont pas définies comme des fonctions de colback, mais plutôt comme de simples fonctions pour le store courant (donc pas besoin d'étendre la class ItemFileWriteSore). Techniquement je recommande de ne pas utiliser les deux fonctions ensemble.

- _saveEveryThing, en passant notamment comme paramètre la nouvelle structure JSON

- _saveCustom

Ex. :

paysStore._saveEverything = function(fnSaveOK, fnSaveNOK, json) {
console.group("sauvegarde personnelle");
//json contient une serialization du store modifié
console.log(json);
console.groupEnd();

}

paysStore._saveCustom = function(fnSaveOK, fnSaveNOK) {
console.info("sauvegarde personnelle");
//ATT !! nécessité d'appeler une des deux fonctions
fnSaveOK();
}

//sauvegarde
paysStore.save({
onComplete: function() {
console.info("Sauvegarde effectuée");
}, //onComplete
onError: function(err) { console.info(err.message); }
}
);

VI-D - Suppression d'un item

La suppression suit les règles habituelles :

var suede;
//on cherche le pays
paysStore.fetchItemByIdentity({identity:"su",
onItem: function(item) { suede = item;},
onError: function(err) { console.info(err.message); }
});
//on le supprime
paysStore.deleteItem(suede);
console.log("La Suède est-elle marquée comme modifiée ? ", paysStore.isDirty(suede));

//on verifie
suede=null;
//s'il n'est plus présent, la variable suede n'est pas affectée
paysStore.fetchItemByIdentity({identity:"su",
onItem: function(item) { suede = item;},
onError: function(err) { console.info(err.message); }
});

console.log("La Suède est encore présente ?" , suede!=null);

VI-E - État modifié et annulation

On peut tester si un élément est en cours de modification (au sens large : création, modification, suppression) :

paysStore.deleteItem(suede);
console.log("La variable suede est-elle modifiée ?",paysStore.isDirty(suede));

Enfin il est possible d'annuler les modifications effectuées depuis la dernière sauvegarde ( revert() ) comme le fait un rollback en SQL.

//on le supprime
paysStore.deleteItem(suede);
console.log("La variable suede est-elle modifiée ?",                             paysStore.isDirty(suede));
//on verifie
suede=null;
console.log("La Suède est encore présente ?" , suede!=null);
//on annule les modifications
console.info("On annule les modifications");
paysStore.revert();
//A-t-on retrouvé la Suède ?
paysStore.fetchItemByIdentity({identity:"su",
onItem: function(item) { suede = item;},
onError: function(err) { console.info(err.message); }
});
console.log("La Suède est de nouveau présente ?" , suede!=null);

VI-F - Les notifications générées

L'API de notification indique qu'à chaque modification de données dans le store un événement est généré (voir tableau 2) : onSet en cas de modification, onNew en cas d'ajout et onDelete en cas de suppression. Ces fonctions peuvent donc être redéfinies ou scrutées via une fonction dojo.connect.

VI-F-1 - Première approche : redéfinissons les fonctions

var paysStore = new dojo.data.ItemFileWriteStore({url: 'pays_save.json'});

paysStore.onNew = function(newItem, parentItem){
var pays = paysStore.getValue(newItem, "nom");
console.log("ONNEW> Pays ajouté:", pays);
};

paysStore.onDelete = function(deletedItem){
console.log("ONDELETE> Pays supprimé:", deletedItem.nom);
};

paysStore.onSet = function(item,attribut,oldValue,newValue){
var pays = paysStore.getValue(item, "nom");
console.log("ONSET> Pays modifié:", pays);
console.log("ONSET> attribut:", attribut, " old:" , oldValue, " , new:", newValue);
};

L'inconvénient de ce type d'approche est que nous écrasons les fonctions originales ou celles redéfinies par une tierce partie. Il est donc bien plus sage de mettre en œuvre dojo.connect.

VI-F-2 - Seconde approche : dojo.connect

//on crée les fonctions
var onNew = function(newItem, parentItem){
var pays = paysStore.getValue(newItem, "nom");
console.log("ONNEW> Pays ajouté:", pays);
};

var onDelete = function(deletedItem){
console.log("ONDELETE> Pays supprimé:", deletedItem.nom);
};

var onSet = function(item,attribut,oldValue,newValue){
var pays = paysStore.getValue(item, "nom");
console.log("ONSET> Pays modifié:", pays);
console.log("ONSET> attribut:", attribut, " old:" , oldValue, " , new:", newValue);
};

//on ajoute les connections
dojo.connect(paysStore, "onNew",    onNew);
dojo.connect(paysStore, "onSet",    onSet);
dojo.connect(paysStore, "onDelete", onDelete);

VI-F-3 - Cas des composants gérant des stores en écriture

Quand un composant gère un store en écriture, il peut aussi se connecter sur ces événements. C'est le cas de la grille. Ainsi lors d'une mise à jour (au sens large) d'un item du store, le composant peut en être notifié et la répercuter graphiquement (ajout/suppression de lignes dans la grille, changement de valeurs…). C'est pourquoi le store est l'élément central de ces composants et devrait être la cible privilégiée des modifications plutôt que passer par des méthodes du composant.

VII - Et maintenant ?

Ce premier article concernant l'API dojo.data présente son fonctionnement général et vous répondra à la majorité de vos besoins.

Si l'API peut sembler par moment déroutante, voire inutilement complexe (l'accès à une donnée par exemple), elle a le mérite de prendre en considération le caractère particulier de la programmation WEB (données hiérarchiques, fonctions de callback permettant des appels asynchrones, etc.).

Nous avons manipulé deux principaux stores ItemFileReadStore et ItemFileWriteStore et on note leurs limitations fonctionnelles dans le cas du lazy loading et la mise à jour vers le server. Alors, même si ces stores peuvent facilement être modifiés et adaptés à nos besoins, il sera intéressant d'étudier en profondeur un store qui gomme ces aspects et qui est dans l'air du temps : JsonRestStore. Ce sera l'objet du prochain article.

VIII - Remerciements

Merci à Kerod qui a su être patient… Merci aussi à Bovino pour ses conseils et sa disponibilité.