web-dev-qa-db-fra.com

Stockage d'une liste réorganisable dans une base de données

Je travaille sur un système de liste de souhaits, où les utilisateurs peuvent ajouter des articles à leurs différentes listes de souhaits, et je prévois de permettre aux utilisateurs de réorganiser les articles plus tard. Je ne suis pas vraiment sûr de la meilleure façon de stocker cela dans une base de données tout en restant rapide et en évitant les dégâts (cette application sera utilisée par une base d'utilisateurs assez importante, donc je ne veux pas qu'elle descende pour nettoyer des trucs).

J'ai d'abord essayé une colonne position, mais il semble que ce serait assez inefficace de devoir changer la valeur de position de chaque autre élément lorsque vous les déplacez.

J'ai vu des gens utiliser une auto-référence pour se référer à la valeur précédente (ou suivante), mais encore une fois, il semble que vous deviez mettre à jour beaucoup d'autres éléments de la liste.

Une autre solution que j'ai vue consiste à utiliser des nombres décimaux et à simplement coller des éléments dans les espaces entre eux, ce qui semble être la meilleure solution jusqu'à présent, mais je suis sûr qu'il doit y avoir un meilleur moyen.

Je dirais qu'une liste typique contiendrait jusqu'à une vingtaine d'articles environ, et je la limiterai probablement à 50. La réorganisation serait par glisser-déposer et se fera probablement par lots pour éviter les conditions de course et autres du demandes ajax. J'utilise des postgres (sur heroku) si cela est important.

Quelqu'un a-t-il une idée?

Bravo pour toute aide!

63
Tom Brunoli

Tout d'abord, n'essayez pas de faire quelque chose d'intelligent avec des nombres décimaux, car ils vous contrarieront. REAL et DOUBLE PRECISION sont inexacts et peuvent ne pas représenter correctement ce que vous y mettez. NUMERIC est exact, mais la bonne séquence de mouvements vous fera manquer de précision et votre implémentation se cassera mal.

Limiter les mouvements à des montées et descentes simples rend toute l'opération très facile. Pour une liste d'éléments numérotés séquentiellement, vous pouvez déplacer un élément vers le haut en décrémentant sa position et en incrémentant le numéro de position de ce que le décrément précédent a proposé. (En d'autres termes, l'élément 5 deviendrait 4 et quel était l'élément 4 devient 5, en fait un échange comme Morons l'a décrit dans sa réponse.) Le déplacer vers le bas serait le contraire. Indexez votre table en identifiant de manière unique une liste et une position et vous pouvez le faire avec deux UPDATE dans une transaction qui s'exécutera très rapidement. À moins que vos utilisateurs ne réorganisent leurs listes à des vitesses surhumaines, cela ne causera pas beaucoup de charge.

Déplacements par glisser-déposer (par exemple, déplacer l'élément 6 pour s'asseoir entre les articles 9 et 10) sont un peu plus délicats et doivent être effectués différemment selon que la nouvelle position est au-dessus ou en dessous de l'ancienne. Dans l'exemple ci-dessus, vous devez ouvrir un trou en incrémentant toutes les positions supérieures à 9, mise à jour de l'élément 6 est le nouveau 10 puis décrémenter la position de tout ce qui est supérieur à 6 pour remplir la place vacante. Avec la même indexation que j'ai décrite précédemment, ce sera rapide. Vous pouvez réellement accélérer cela un peu plus vite que je l'ai décrit en minimisant le nombre de lignes que la transaction touche, mais c'est une microoptimisation dont vous n'avez pas besoin jusqu'à ce que vous puissiez prouver qu'il y a un goulot d'étranglement.

Quoi qu'il en soit, essayer de surpasser la base de données avec une solution maison, trop intelligente par moitié ne mène généralement pas au succès. Des bases de données dignes de ce nom ont été soigneusement rédigées pour effectuer ces opérations très, très rapidement par des gens qui sont très, très bons dans ce domaine.

37
Blrfl

Même réponse d'ici https://stackoverflow.com/a/49956113/10608


Solution: faites de index une chaîne (car les chaînes ont, par essence, une "précision arbitraire" infinie). Ou si vous utilisez un entier, incrémentez index de 100 au lieu de 1.

Le problème (performances/complexité) étant résolu par cette solution: il n'y a pas de valeurs "intermédiaires" entre deux éléments triés.

item      index
-----------------
gizmo     1
              <<------ Oh no! no room between 1 and 2.
                       This requires incrementing _every_ item after it
gadget    2
gear      3
toolkit   4
box       5

Au lieu de cela, faites quelque chose comme ça (avec une meilleure solution ci-dessous):

item      index
-----------------
gizmo     100
              <<------ Sweet :). I can re-order 99 (!) items here
                       without having to change anything else
gadget    200
gear      300
toolkit   400
box       500

Encore mieux: voici comment Jira résout ce problème. Leur "rang" (ce que vous appelez index) est une valeur de chaîne qui permet une tonne de marge de manœuvre entre les éléments classés.

Voici un exemple réel d'une base de données jira avec laquelle je travaille

   id    | jira_rank
---------+------------
 AP-2405 | 0|hzztxk:
 ES-213  | 0|hzztxs:
 AP-2660 | 0|hzztzc:
 AP-2688 | 0|hzztzk:
 AP-2643 | 0|hzztzs:
 AP-2208 | 0|hzztzw:
 AP-2700 | 0|hzztzy:
 AP-2702 | 0|hzztzz:
 AP-2411 | 0|hzztzz:i
 AP-2440 | 0|hzztzz:r

Notez cet exemple hzztzz:i. L'avantage d'un rang de chaîne est que vous manquez d'espace entre deux éléments, vous toujours n'avez pas à re-classer autre chose. Vous commencez simplement à ajouter plus de caractères à la chaîne pour affiner le focus.

29
Alexander Bird

"mais il semble que ce serait assez inefficace"

Avez-vous mesure cela? Ou est-ce juste une supposition? Ne faites pas de telles hypothèses sans aucune preuve.

"20 à 50 articles par liste"

Honnêtement, ce n'est pas "beaucoup d'articles", pour moi, cela semble très peu.

Je vous suggère de vous en tenir à l'approche de la "colonne de position" (si c'est la mise en œuvre la plus simple pour vous). Pour ces petites tailles de liste, ne commencez pas l'optimisation inutile avant de rencontrer de vrais problèmes de performances

16
Doc Brown

J'ai vu des gens utiliser une auto-référence pour se référer à la valeur précédente (ou suivante), mais encore une fois, il semble que vous deviez mettre à jour beaucoup d'autres éléments de la liste.

Pourquoi? Supposons que vous adoptiez une approche de table de liste liée avec des colonnes (listID, itemID, nextItemID).

L'insertion d'un nouvel élément dans une liste coûte une insertion et une ligne modifiée.

Le repositionnement d'un élément coûte trois modifications de ligne (l'élément en cours de déplacement, l'élément avant lui et l'élément avant son nouvel emplacement).

La suppression d'un élément coûte une suppression et une ligne modifiée.

Ces coûts restent les mêmes, que la liste comporte 10 articles ou 10 000 articles. Dans les trois cas, il y a une modification de moins si la ligne cible est le premier élément de la liste. Si vous utilisez plus souvent l'élément de liste last, il peut être avantageux de stocker prevItemID plutôt que next.

14
sqweek

C'est vraiment une question d'échelle et de cas d'utilisation ..

Combien d'articles attendez-vous dans une liste? Si des millions, je pense que la voie décimale est la plus évidente.

Si 6, la renumérotation des nombres entiers est le choix évident. s Aussi, les questions sont comment les listes ou réorganisées. Si vous utilisez des flèches vers le haut et vers le bas (vous déplacez vers le haut ou vers le bas d'un emplacement à la fois), le i utilise des entiers, puis échange avec le précédent (ou le suivant) en mouvement.

De plus, à quelle fréquence vous engagez-vous, si l'utilisateur peut apporter 250 modifications, puis validez à la fois, alors je dis des entiers avec une nouvelle numérotation ...

tl; dr: Besoin de plus d'informations.


Edit: "Listes de souhaits" ressemble à beaucoup de petites listes (hypothèse, cela peut être faux) .. Je dis donc Entier avec renumérotation. (Chaque liste contient sa propre position)

6
Morons

OK, j'ai récemment fait face à ce problème délicat, et toutes les réponses dans ce post de questions et réponses m'ont beaucoup inspiré. Selon moi, chaque solution a ses avantages et ses inconvénients.

  • Si le champ position doit être séquentiel sans lacunes, vous devrez essentiellement réorganiser la liste entière. Il s'agit d'une opération O(N). L'avantage est que le côté client n'aurait pas besoin de logique particulière pour obtenir la commande.

  • Si nous voulons éviter l'opération O(N) MAIS TOUJOURS maintenir une séquence précise, l'une des approches consiste à utiliser "auto-référence pour faire référence à la valeur précédente (ou suivante)". Il s'agit d'un scénario de liste liée de manuels scolaires. De par sa conception, il n'engendre PAS "beaucoup d'autres éléments de la liste". Cependant, cela nécessite que le côté client (un service Web ou peut-être une application mobile) implémente le lien liste la logique de travesal pour dériver l'ordre.

  • Certaines variantes n'utilisent pas la référence, c'est-à-dire la liste chaînée. Ils choisissent de représenter la totalité de la commande comme un blob autonome, tel qu'un tableau JSON-dans-une-chaîne [5,2,1,3,...]; cette commande sera ensuite stockée dans un endroit séparé. Cette approche a également pour effet secondaire d'exiger que le code côté client maintienne ce blob d'ordre séparé.

  • Dans de nombreux cas, nous n'avons pas vraiment besoin de stocker l'ordre exact, nous avons juste besoin de maintenir un rang relatif parmi chaque enregistrement. Par conséquent, nous pouvons permettre des écarts entre les enregistrements séquentiels. Les variations comprennent: (1) l'utilisation d'un entier avec des lacunes telles que 100, 200, 300 ... mais vous manquerez rapidement de lacunes et aurez alors besoin du processus de récupération; (2) en utilisant décimal qui vient avec des lacunes naturelles, mais vous devrez décider si vous pouvez vivre avec la limitation de précision éventuelle; (3) en utilisant un classement basé sur des chaînes comme décrit dans cette réponse mais attention aux pièges d'implémentation délicats .

  • La vraie réponse peut être "ça dépend". Revoyez vos besoins commerciaux. Par exemple, s'il s'agit d'un système de liste de souhaits, personnellement, j'utiliserais volontiers un système organisé par seulement quelques rangs en tant que "must-have", "good-to-have", "peut-être plus tard", puis présenter des éléments sans particulier ordre à l'intérieur de chaque rang. S'il s'agit d'un système de livraison, vous pouvez très bien utiliser le délai de livraison comme un rang approximatif qui vient avec un écart naturel (et la prévention des conflits naturels car aucune livraison ne se produirait en même temps). Votre kilométrage peut varier.

5
RayLuo

Si l'objectif est de minimiser le nombre d'opérations de base de données par opération de réorganisation:

En admettant que

  • Tous les articles d'achat peuvent être énumérés avec des entiers 32 bits.
  • Il existe une limite de taille maximale pour la liste de souhaits d'un utilisateur. (J'ai vu certains sites Web populaires utiliser 20 à 40 éléments comme limite)

Stockez la liste de souhaits triée de l'utilisateur sous la forme d'une séquence compactée d'entiers (tableaux d'entiers) dans une colonne. Chaque fois que la liste de souhaits est réorganisée, l'ensemble du tableau (une seule ligne; une seule colonne) est mis à jour - ce qui doit être effectué avec une seule mise à jour SQL.

https://www.postgresql.org/docs/current/static/arrays.html


Si l'objectif est différent, respectez l'approche de la "colonne de position".


En ce qui concerne la "vitesse", assurez-vous de comparer l'approche de procédure stockée. Bien que l'émission de 20+ mises à jour distinctes pour un mélange de liste de souhaits puisse être lente, il peut y avoir un moyen rapide d'utiliser la procédure stockée.

3
rwong

Utilisez un nombre à virgule flottante pour la colonne de position.

Vous pouvez ensuite réorganiser la liste en modifiant uniquement la colonne de position dans la ligne "déplacée".

Fondamentalement, si votre utilisateur veut positionner "rouge" après "bleu" mais avant "jaune"

Ensuite, il vous suffit de calculer

red.position = ((yellow.position - blue.position) / 2) + blue.position

Après quelques millions de repositionnements, vous pouvez obtenir des nombres à virgule flottante si petits qu'il n'y a pas "entre" - mais c'est à peu près aussi probable que de voir une licorne.

Vous pouvez implémenter cela en utilisant un champ entier avec un écart initial de disons 1000. Ainsi, votre oredring initial serait 1000-> bleu, 2000-> jaune, 3000-> rouge. Après avoir "déplacé" le rouge après le bleu, vous auriez 1000-> bleu, 1500-> rouge, 2000-> jaune.

Le problème est qu'avec un écart initial apparemment grand de 1000, aussi peu que 10 coups vous mettront dans une situation comme 1000-> bleu, 1001-puce, 1004-> biege ...... où vous ne pourrez plus pour insérer quoi que ce soit après "bleu" sans renuméroter toute la liste. En utilisant des nombres à virgule flottante, il y aura toujours un point "à mi-chemin" entre les deux positions.

3
James Anderson

Oui, la question est plutôt ancienne et contient déjà quelques réponses. Pourtant, toutes les solutions proposées ici sont assez complexes. Et des plus simples?

La question initiale concerne la liste de souhaits - quelque chose qui a probablement un nombre d'articles en dizaines, peut-être en centaines - mais pas en milliers, généralement. Pourquoi ne pas alors stocker l'ordre de tri dans un seul champ de texte sous la forme tableau sérialisé? Toute insertion, mise à jour et suppression n'affecterait qu'un seul enregistrement supplémentaire de cette façon.

Si un tableau sérialisé n'est pas assez sophistiqué, vous pouvez toujours en faire un champ JSON, mais ce ne sera toujours qu'une cellule en cours de modification dans la base de données.

2
Michal J. Figurski

Bien que l'OP ait brièvement abordé la notion d'utilisation d'une liste liée pour stocker l'ordre de tri, il présente de nombreux avantages pour les cas où les articles seront réorganisés fréquemment.

J'ai vu des gens utiliser une auto-référence pour se référer à la valeur précédente (ou suivante), mais encore une fois, il semble que vous deviez mettre à jour beaucoup d'autres éléments de la liste.

La chose est - vous ne le faites pas! Lorsque vous utilisez une liste chaînée, l'insertion, la suppression et le réordonnancement sont des opérations O(1) et l'intégrité référentielle imposée par la base de données garantit qu'il n'y a pas de références cassées, d'enregistrements orphelins ou de boucles.

Voici un exemple:

CREATE TABLE Wishlists (
  WishlistId int           NOT NULL IDENTITY(1,1) PRIMARY KEY,
  [Name]     nvarchar(200) NOT NULL
);

CREATE TABLE WishlistItems (
  ItemId     int           NOT NULL IDENTITY(1,1),
  WishlistId int           NOT NULL,
  Text       nvarchar(200) NOT NULL,
  SortAfter  int               NULL,

  CONSTRAINT PK_WishlistItem PRIMARY KEY ( ItemId, WishlistId ),
  CONSTRAINT FK_Wishlist_WishlistItem FOREIGN KEY ( WishlistId ) REFERENCES Wishlists ( WishlistId ),
  CONSTRAINT FK_Sorting FOREIGN KEY ( SortAfter, WishlistId ) REFERENCES WishlistItems ( ItemId, WishlistId )
);

CREATE UNIQUE INDEX UX_Sorting ON WishlistItems ( SortAfter, WishlistId );

 -----

SET IDENTITY_INSERT Wishlists ON;

INSERT INTO Wishlists ( WishlistId, [Name] ) VALUES
  ( 1, 'Wishlist 1' ),
  ( 2, 'Wishlist 2' );

SET IDENTITY_INSERT Wishlists OFF;

SET IDENTITY_INSERT WishlistItems ON;

INSERT INTO WishlistItems ( ItemId, WishlistId, [Text], SortAfter ) VALUES
( 1, 1, 'One', NULL ),
( 2, 1, 'Two', 1 ),
( 3, 1, 'Three', 2 ),
( 4, 1, 'Four', 3 ),
( 5, 1, 'Five', 4 ),
( 6, 1, 'Six', 5 ),
( 7, 1, 'Seven', 6 ),
( 8, 1, 'Eight', 7 );

SET IDENTITY_INSERT WishlistItems OFF;

Notez les points suivants:

  • Utilisation d'une clé primaire et d'une clé étrangère composites dans FK_Sorting Pour empêcher les éléments de se référer accidentellement au mauvais élément parent.
  • Le UNIQUE INDEX UX_Sorting Joue deux rôles:
    • Comme il autorise une seule valeur NULL, chaque liste ne peut avoir qu'un seul élément "head".
    • Il empêche deux ou plusieurs éléments de prétendre être au même endroit de tri (en empêchant les valeurs SortAfter en double).

Les principaux avantages de cette approche:

  • Ne nécessite jamais de rééquilibrage ou de maintenance - comme avec les ordres de tri basés sur int ou real qui finissent par manquer d'espace entre les éléments après de fréquentes réorganisations.
  • Seuls les articles qui sont réorganisés (et leurs frères et sœurs) doivent être mis à jour.

Cette approche présente cependant des inconvénients:

  • Vous pouvez uniquement trier cette liste en SQL à l'aide d'un CTE récursif car vous ne pouvez pas faire un simple ORDER BY.
    • Comme solution de contournement, vous pouvez créer un wrapper VIEW ou TVF qui utilise un CTE pour ajouter un dérivé contenant un ordre de tri incrémenté - mais cela coûterait cher à utiliser dans de grandes opérations.
  • Vous devez charger la liste entière dans votre programme afin de l'afficher - vous ne pouvez pas opérer sur un sous-ensemble des lignes car la colonne SortAfter fera référence aux éléments qui ne sont pas chargés dans votre programme.
    • Cependant, le chargement de tous les éléments d'une liste est facile en raison de la clé primaire composite (c'est-à-dire, faites simplement SELECT * FROM WishlistItems WHERE WishlistId = @wishlistToLoad).
  • L'exécution de toute opération alors que UX_Sorting Est activé nécessite la prise en charge du SGBD pour les contraintes différées.
    • c'est-à-dire l'implémentation idéale de cette approche ne fonctionnera pas dans SQL Server jusqu'à ce qu'ils ajoutent la prise en charge des contraintes et index déférables.
    • Une solution de contournement consiste à faire de l'index unique un index filtré qui autorise plusieurs valeurs NULL dans la colonne - ce qui signifie malheureusement qu'une liste pourrait avoir plusieurs HEAD items.
      • Une solution de contournement pour cette solution consiste à ajouter une troisième colonne State qui est un simple indicateur pour déclarer si un élément de liste est "actif" ou non - et l'index unique ignore les éléments inactifs.
    • C'est quelque chose que SQL Server avait l'habitude de prendre en charge dans les années 1990, puis ils ont inexplicablement supprimé la prise en charge.

Solution de contournement 1: vous devez pouvoir effectuer un ORDER BY Trivial.

Voici une VUE utilisant un CTE récursif qui ajoute une colonne SortOrder:

CREATE VIEW OrderableWishlistItems AS 

    WITH c ( ItemId, WishlistId, [Text], SortAfter, SortOrder )
    AS
    (
        SELECT
              ItemId, WishlistId, [Text], SortAfter, 1 AS SortOrder
        FROM
              WishlistItems
        WHERE
              SortAfter IS NULL

        UNION ALL

        SELECT
              i.ItemId, i.WishlistId, i.[Text], i.SortAfter, c.SortOrder + 1
        FROM
              WishlistItems AS i
              INNER JOIN c ON
                  i.WishlistId = c.WishlistId
                  AND
                  i.SortAfter = c.ItemId
    )
    SELECT
        ItemId, WishlistId, [Text], SortAfter, SortOrder
    FROM
        c;

Vous pouvez utiliser cette VUE dans d'autres requêtes où vous devez trier les valeurs à l'aide de ORDER BY:

Query:

    SELECT * FROM OrderableWishlistItems

Results:

    ItemId  WishlistId  Text        SortAfter   SortOrder
    1       1           One         (null)      1
    2       1           Two             1       2
    3       1           Three           2       3
    4       1           Four            3       4
    5       1           Five            4       5
    6       1           Six             5       6
    7       1           Seven           6       7
    8       1           Eight           7       8

Solution de contournement 2: prévention des contraintes de violation de UNIQUE INDEX Lors de l'exécution des opérations:

Ajoutez une colonne State à la table WishlistItems. La colonne est marquée comme HIDDEN donc la plupart des outils ORM (comme Entity Framework) ne l'incluent pas lors de la génération de modèles, par exemple.

CREATE TABLE WishlistItems (
  ItemId     int           NOT NULL IDENTITY(1,1),
  WishlistId int           NOT NULL,
  Text       nvarchar(200) NOT NULL,
  SortAfter  int               NULL,
  [State]    bit           NOT NULL HIDDEN,

  CONSTRAINT PK_WishlistItem PRIMARY KEY ( ItemId, WishlistId ),
  CONSTRAINT FK_Wishlist_WishlistItem FOREIGN KEY ( WishlistId ) REFERENCES Wishlists ( WishlistId ),
  CONSTRAINT FK_Sorting FOREIGN KEY ( SortAfter, WishlistId ) REFERENCES WishlistItems ( ItemId, WishlistId )
);

CREATE UNIQUE INDEX UX_Sorting ON WishlistItems ( SortAfter, WishlistId ) WHERE [State] = 1;

Opérations:

Ajout d'un nouvel élément à la fin de la liste:

  1. Chargez d'abord la liste pour déterminer le ItemId du dernier élément en cours dans la liste et enregistrez-le dans @tailItemId - ou utilisez SELECT MAX( SortOrder ) FROM OrderableWishlistItems WHERE WishlistId = @listId.
  2. INSERT INTO WishlistItems ( WishlistId, [Text], SortAfter ) VALUES ( @listId, @text, @tailItemId ).

Réorganiser l'élément 4 en dessous de l'élément 7

BEGIN TRANSACTION

    DECLARE @itemIdToMove int = 4
    DECLARE @itemIdToMoveAfter int = 7

    DECLARE @prev int = ( SELECT SortAfter FROM WishlistItems WHERE ItemId = @itemIdToMove )

    UPDATE WishlistItems SET [State] = 0 WHERE ItemId IN ( @itemIdToMove , @itemIdToMoveAfter )

    UPDATE WishlistItems SET [SortAfter] = @itemIdToMove WHERE ItemId = @itemIdToMoveAfter 

    UPDATE WishlistItems SET [SortAfter] = @prev WHERE SortAfter = @itemIdToMove 

    UPDATE WishlistItems SET [State] = 1 WHERE ItemId IN ( @itemIdToMove, @itemIdToMoveAfter )

COMMIT;

Suppression de l'élément 4 du milieu de la liste:

Si un élément se trouve à la fin de la liste (c'est-à-dire où NOT EXISTS ( SELECT 1 FROM WishlistItems WHERE SortAfter = @itemId )), vous pouvez faire un seul DELETE.

Si un élément est trié après un élément, vous effectuez les mêmes étapes que la réorganisation d'un élément, sauf que vous le DELETE après au lieu de définir State = 1;.

BEGIN TRANSACTION

    DECLARE @itemIdToRemove int = 4

    DECLARE @prev int = ( SELECT SortAfter FROM WishlistItems WHERE ItemId = @itemIdToRemove )

    UPDATE WishlistItems SET [State] = 0 WHERE ItemId = @itemIdToRemove

    UPDATE WishlistItems SET [SortAfter] = @prev WHERE SortAfter = @itemIdToRemove

    DELETE FROM WishlistItems WHERE ItemId = @itemIdToRemove

COMMIT;
1
Dai