1. 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 fournie 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 multimedia 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ée Javascript, table HTML par exemple). Le schéma général peut être celui-ci:

Image non disponible

2. 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.

3. L'API en bref

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

Image non disponible

Elle est constitué 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é. A 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 terme 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 coeur 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 chargé 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éclecnhé pour chaque item rapatrié, onComplete déclenché un fois tous les item 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 tableaux 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 elle 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.

4. 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,
 
Sélectionnez
var paysStore = new dojo.data.ItemFileReadStore({url: 'pays.json'});
  • soit en chargeant directement les données:
 
Sélectionnez
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
 
Sélectionnez
    <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 terme 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.

5. Le store en lecture

5-A. Sélectionner les données

5-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 . A 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
 
Sélectionnez
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); 
        }
        });
Image non disponible
  • Fetch, exemple 1 : Récupération de tous les pays commençant pas "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
 
Sélectionnez
    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
    });
Image non disponible
  • 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. A noter enfin que dojo.forEach utilise cette même notion de scope.
 
Sélectionnez
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
});
Image non disponible
  • Recharger un store ou, rafraichir les données: même si chaque store peut avoir sa propre implémentation, on peut donner les grande lignes pour recharger un store:
  1. 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és.
  2. affecter les nouveaux attributs (url, datas...)
  3. accéder aux données
 
Sélectionnez
     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:
 
Sélectionnez
    //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); 
        }
 
    });
Image non disponible
  • FlickrStore: lance une requête vers le site d'images flicker et obtenir en retour une liste d'images correspondantes.
 
Sélectionnez
    /**
    * 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); 
                        }
                    });            
Image non disponible
  • WikipediaStore: interroge wikipedia. A 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)
 
Sélectionnez
    //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);}
    });
Image non disponible
  • 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:
 
Sélectionnez
<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éder sous la forme d'un store, en précisant les données et l'id la TABLE à traiter:

 
Sélectionnez
    /**
    * 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); 
                        }
                    });            
Image non disponible

5-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:

 
Sélectionnez
    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'item à 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 tel 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'}]

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

A 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 oeuvre 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)

5-B. Les autres stores disponibles

dojox.data.AtomReadStore Accès aux flux d'information 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éthode 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 service de données Amazon S3: échnages 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. A 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 écriture via les méthdes 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/

5-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:

 
Sélectionnez
{ 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 FRONTIERES
       */
    //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 effectura alors:

 
Sélectionnez
       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); 
        }
    }
    );
Image non disponible

Ayant accéder aux données du pays, il nous faut obtenir la liste des frontière 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.

5-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 »).

5-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:

 
Sélectionnez
{ 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.

 
Sélectionnez
    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); 
        }
    }
    );
Image non disponible

Si cette conversion de données peut sembler interessante 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'autre 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)

5-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):

 
Sélectionnez
{  
. . .
    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:

 
Sélectionnez
    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 oeuvre 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...

5-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:
 
Sélectionnez
    <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:
 
Sélectionnez
    <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:
 
Sélectionnez
    <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>

6. 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.

6-A. Ajout d'un item

Ajoutons la Suisse à notre liste de pays:

 
Sélectionnez
    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); }
    });
Image non disponible

Dans le cas de store hierarchique (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é.

6-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'atttribut 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:

 
Sélectionnez
    //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"));
Image non disponible

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

 
Sélectionnez
    //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 frontieres
    var frontieres = paysStore.getValues(france, "frontieres");
    //on ajoute la Suisse aux frontieres
    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); }
    }
    );
Image non disponible

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'):

 
Sélectionnez
<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>

6-C. Sauvegarde du store

La sauvegarde du store valide toutes les modifications en suspend. 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:

 
Sélectionnez
    //sauvegarde
    paysStore.save();

On peut ajouter des fonctions de callback onComplete et onError.

 
Sélectionnez
    //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 2 autres fonctions (hors API), mais qui ne sont pas définies comme des fonctions de callback mais plutôt comme de simples fonctions pour le store courant (donc pas besoin d'étendre la class ItemFileWriteSore). Tehniquement je recommande de ne pas utiliser les deux fonctions ensembles.

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

Ex:

 
Sélectionnez
    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); }
    }
    );
Image non disponible
Image non disponible

6-D. Suppression d'un item

La suppression suit les règles habituelles:

 
Sélectionnez
    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);
Image non disponible

6-E. Etat modifié et annulation

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

 
Sélectionnez
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.

 
Sélectionnez
    //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 rerouvé 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);
Image non disponible

6-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 .

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

 
Sélectionnez
    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);
    };
Image non disponible

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 oeuvre dojo.connect .

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

 
Sélectionnez
    //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);

6-F-3. Cas des composants gérant des store 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 .

7. 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 hierarchiques, 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.

8. Remerciements

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