Est-il possible de créer une clé étrangère MySQL sur l'une des deux tables possibles?

182

Eh bien, voici mon problème, j'ai trois tables; régions, pays, états. Les pays peuvent être à l'intérieur de régions, les États peuvent être à l'intérieur de régions. Les régions sont au sommet de la chaîne alimentaire.

J'ajoute maintenant une table popular_areas avec deux colonnes; region_id et popular_place_id. Est-il possible de faire de popular_place_id une clé étrangère pour les pays OU les états. Je vais probablement devoir ajouter une colonne popular_place_type pour déterminer si l'identifiant décrit un pays ou un état de toute façon.

Andrew G. Johnson
la source

Réponses:

285

Ce que vous décrivez s'appelle les associations polymorphes. Autrement dit, la colonne "clé étrangère" contient une valeur d'ID qui doit exister dans l'une des tables cible. En règle générale, les tables cibles sont liées d'une manière ou d'une autre, par exemple en tant qu'instances d'une superclasse de données commune. Vous auriez également besoin d'une autre colonne à côté de la colonne de clé étrangère, de sorte que sur chaque ligne, vous puissiez désigner la table cible référencée.

CREATE TABLE popular_places (
  user_id INT NOT NULL,
  place_id INT NOT NULL,
  place_type VARCHAR(10) -- either 'states' or 'countries'
  -- foreign key is not possible
);

Il n'y a aucun moyen de modéliser les associations polymorphes à l'aide de contraintes SQL. Une contrainte de clé étrangère fait toujours référence à une table cible.

Les associations polymorphes sont prises en charge par des frameworks tels que Rails et Hibernate. Mais ils disent explicitement que vous devez désactiver les contraintes SQL pour utiliser cette fonctionnalité. Au lieu de cela, l'application ou le cadre doit faire un travail équivalent pour s'assurer que la référence est satisfaite. Autrement dit, la valeur de la clé étrangère est présente dans l'une des tables cibles possibles.

Les associations polymorphes sont faibles en ce qui concerne l'application de la cohérence de la base de données. L'intégrité des données dépend de tous les clients accédant à la base de données avec la même logique d'intégrité référentielle appliquée, et l'application doit également être sans bogue.

Voici quelques solutions alternatives qui tirent parti de l'intégrité référentielle appliquée par la base de données:

Créez une table supplémentaire par cible. Par exemple popular_stateset popular_countries, quelle référence stateset countriesrespectivement. Chacun de ces tableaux «populaires» fait également référence au profil de l'utilisateur.

CREATE TABLE popular_states (
  state_id INT NOT NULL,
  user_id  INT NOT NULL,
  PRIMARY KEY(state_id, user_id),
  FOREIGN KEY (state_id) REFERENCES states(state_id),
  FOREIGN KEY (user_id) REFERENCES users(user_id),
);

CREATE TABLE popular_countries (
  country_id INT NOT NULL,
  user_id    INT NOT NULL,
  PRIMARY KEY(country_id, user_id),
  FOREIGN KEY (country_id) REFERENCES countries(country_id),
  FOREIGN KEY (user_id) REFERENCES users(user_id),
);

Cela signifie que pour obtenir tous les emplacements préférés d'un utilisateur, vous devez interroger ces deux tables. Mais cela signifie que vous pouvez compter sur la base de données pour garantir la cohérence.

Créez une placestable comme supertable. Comme le mentionne Abie, une deuxième alternative est que vos lieux populaires référencent une table comme places, qui est un parent à la fois stateset countries. Autrement dit, les États et les pays ont également une clé étrangère vers places(vous pouvez même faire de cette clé étrangère la clé primaire de stateset countries).

CREATE TABLE popular_areas (
  user_id INT NOT NULL,
  place_id INT NOT NULL,
  PRIMARY KEY (user_id, place_id),
  FOREIGN KEY (place_id) REFERENCES places(place_id)
);

CREATE TABLE states (
  state_id INT NOT NULL PRIMARY KEY,
  FOREIGN KEY (state_id) REFERENCES places(place_id)
);

CREATE TABLE countries (
  country_id INT NOT NULL PRIMARY KEY,
  FOREIGN KEY (country_id) REFERENCES places(place_id)
);

Utilisez deux colonnes. Au lieu d'une colonne pouvant faire référence à l'une des deux tables cibles, utilisez deux colonnes. Ces deux colonnes peuvent être NULL; en fait, un seul d'entre eux devrait être non- NULL.

CREATE TABLE popular_areas (
  place_id SERIAL PRIMARY KEY,
  user_id INT NOT NULL,
  state_id INT,
  country_id INT,
  CONSTRAINT UNIQUE (user_id, state_id, country_id), -- UNIQUE permits NULLs
  CONSTRAINT CHECK (state_id IS NOT NULL OR country_id IS NOT NULL),
  FOREIGN KEY (state_id) REFERENCES places(place_id),
  FOREIGN KEY (country_id) REFERENCES places(place_id)
);

En termes de théorie relationnelle, les associations polymorphes viole la première forme normale , car il popular_place_ids'agit en fait d'une colonne avec deux significations: c'est soit un état, soit un pays. Vous ne stockeriez pas une personne ageet sa phone_numberdans une seule colonne, et pour la même raison, vous ne devriez pas stocker les deux state_idet country_iddans une seule colonne. Le fait que ces deux attributs aient des types de données compatibles est une coïncidence; ils signifient encore différentes entités logiques.

Les associations polymorphes violent également la troisième forme normale , car la signification de la colonne dépend de la colonne supplémentaire qui nomme la table à laquelle la clé étrangère fait référence. Dans la troisième forme normale, un attribut dans une table doit dépendre uniquement de la clé primaire de cette table.


Re commentaire de @SavasVedova:

Je ne suis pas sûr de suivre votre description sans voir les définitions de table ou un exemple de requête, mais il semble que vous ayez simplement plusieurs Filterstables, chacune contenant une clé étrangère qui fait référence à une Productstable centrale .

CREATE TABLE Products (
  product_id INT PRIMARY KEY
);

CREATE TABLE FiltersType1 (
  filter_id INT PRIMARY KEY,
  product_id INT NOT NULL,
  FOREIGN KEY (product_id) REFERENCES Products(product_id)
);

CREATE TABLE FiltersType2 (
  filter_id INT  PRIMARY KEY,
  product_id INT NOT NULL,
  FOREIGN KEY (product_id) REFERENCES Products(product_id)
);

...and other filter tables...

Il est facile d'associer les produits à un type de filtre spécifique si vous savez à quel type vous souhaitez vous joindre:

SELECT * FROM Products
INNER JOIN FiltersType2 USING (product_id)

Si vous souhaitez que le type de filtre soit dynamique, vous devez écrire le code d'application pour construire la requête SQL. SQL exige que la table soit spécifiée et corrigée au moment où vous écrivez la requête. Vous ne pouvez pas choisir la table jointe de manière dynamique en fonction des valeurs trouvées dans des lignes individuelles de Products.

La seule autre option consiste à joindre toutes les tables de filtrage à l'aide de jointures externes. Ceux qui n'ont pas de product_id correspondant seront simplement renvoyés sous la forme d'une seule ligne de valeurs nulles. Mais vous devez toujours coder en dur toutes les tables jointes, et si vous ajoutez de nouvelles tables de filtrage, vous devez mettre à jour votre code.

SELECT * FROM Products
LEFT OUTER JOIN FiltersType1 USING (product_id)
LEFT OUTER JOIN FiltersType2 USING (product_id)
LEFT OUTER JOIN FiltersType3 USING (product_id)
...

Une autre façon de joindre toutes les tables de filtrage consiste à le faire en série:

SELECT * FROM Product
INNER JOIN FiltersType1 USING (product_id)
UNION ALL
SELECT * FROM Products
INNER JOIN FiltersType2 USING (product_id)
UNION ALL
SELECT * FROM Products
INNER JOIN FiltersType3 USING (product_id)
...

Mais ce format vous oblige toujours à écrire des références à toutes les tables. Il n'y a pas moyen de contourner cela.

Bill Karwin
la source
Lequel suggéreriez-vous à Bill? Je suis en train de concevoir une base de données mais je suis perdu. J'ai essentiellement besoin d'associer des filtres à un produit et les valeurs des filtres seront renseignées dans différentes tables. Mais le problème est que les filtres seront générés par les administrateurs, donc en fonction du type de filtre, les données peuvent varier et donc la joincible changera également ...... Est-ce que je complique trop ou quoi? Aidez-moi!
Savas Vedova
+1 merci pour une solution géniale. Une question que j'ai avec la première / deuxième solution est: y a-t-il une violation de normalisation avec le fait que plusieurs tables peuvent faire référence à la même clé primaire dans cette méta-table? Je sais que vous pouvez résoudre cela avec la logique, mais je ne vois aucun moyen pour la base de données de l'appliquer, à moins que je ne manque quelque chose.
Rob
5
J'aime vraiment l'approche avec "CONTRAINT CHECK". Mais il peut être amélioré si nous changeons "OU" en "XOR". De cette façon, nous nous assurons qu'une seule colonne de l'ensemble n'est PAS NULL
alex_b
1
@alex_b, oui, c'est bien, mais le XOR logique n'est pas SQL standard et n'est pas pris en charge par toutes les marques SQL. MySQL l'a, mais pas PostgreSQL. Oracle l'a, mais Microsoft ne l'a pas avant 2016. Et ainsi de suite.
Bill Karwin
1
« Ces deux colonnes peuvent être NULL, en fait seul d'entre eux devrait être non NULL » - ce serait une violation 1FN!
jour du
11

Ce n'est pas la solution la plus élégante au monde, mais vous pouvez utiliser l' héritage de table concret pour que cela fonctionne.

Conceptuellement, vous proposez une notion d'une classe de «choses qui peuvent être des zones populaires» dont vos trois types d'endroits héritent. Vous pouvez représenter cela comme une table appelée, par exemple, placesoù chaque ligne a une à une relation avec une ligne regions, countriesou states. (Les attributs qui sont partagés entre les régions, les pays ou les états, le cas échéant, pourraient être poussés dans cette table d'endroits.) Votre popular_place_idserait alors une référence de clé étrangère à une ligne dans la table d'endroits qui vous mènerait alors à une région, un pays , ou état.

La solution que vous proposez avec une deuxième colonne pour décrire le type d'association se trouve être la façon dont Rails gère les associations polymorphes, mais je ne suis pas fan de cela en général. Bill explique en détail pourquoi les associations polymorphes ne sont pas vos amis.

Abie
la source
1
aka "the supertype-
subtype
1
Aussi cet article expose bien le concept duhallowgreygeek.com/polymorphic-association-bad-sql-smell
Marco Staffoli
6

Voici une correction à l'approche «supertable» de Bill Karwin, utilisant une clé composée ( place_type, place_id )pour résoudre les violations de forme normales perçues:

CREATE TABLE places (
  place_id INT NOT NULL UNIQUE,
  place_type VARCHAR(10) NOT NULL
     CHECK ( place_type = 'state', 'country' ),
  UNIQUE ( place_type, place_id )
);

CREATE TABLE states (
  place_id INT NOT NULL UNIQUE,
  place_type VARCHAR(10) DEFAULT 'state' NOT NULL
     CHECK ( place_type = 'state' ),
  FOREIGN KEY ( place_type, place_id ) 
     REFERENCES places ( place_type, place_id )
  -- attributes specific to states go here
);

CREATE TABLE countries (
  place_id INT NOT NULL UNIQUE,
  place_type VARCHAR(10) DEFAULT 'country' NOT NULL
     CHECK ( place_type = 'country' ),
  FOREIGN KEY ( place_type, place_id ) 
     REFERENCES places ( place_type, place_id )
  -- attributes specific to country go here
);

CREATE TABLE popular_areas (
  user_id INT NOT NULL,
  place_id INT NOT NULL,
  UNIQUE ( user_id, place_id ),
  FOREIGN KEY ( place_type, place_id ) 
     REFERENCES places ( place_type, place_id )
);

Ce que cette conception ne peut pas garantir que pour chaque ligne, placesil existe une ligne dans statesou countries(mais pas les deux). Il s'agit d'une limitation des clés étrangères en SQL. Dans un SGBD entièrement conforme aux normes SQL-92, vous pouvez définir des contraintes inter-tables reportables qui vous permettraient d'atteindre la même chose, mais c'est maladroit, implique des transactions et un tel SGBD n'a pas encore été commercialisé.

un jour quand
la source
0

Je me rends compte que ce fil est vieux, mais j'ai vu cela et une solution m'est venue à l'esprit et j'ai pensé que je la lancerais là-bas.

Les régions, les pays et les États sont des lieux géographiques qui vivent dans une hiérarchie.

Vous pourriez éviter complètement votre problème en créant une table de domaine appelée Geographic_location_type que vous rempliriez avec trois lignes (Region, Country, State).

Ensuite, au lieu des trois tables de localisation, créez une seule table de localisation géographique qui a une clé étrangère géographique_location_type_id (afin que vous sachiez si l'instance est une région, un pays ou un État).

Modélisez la hiérarchie en rendant cette table auto-référencée afin qu'une instance State contienne la fKey à son instance de Country parente, qui à son tour détient la fKey à son instance de Region parente. Les instances de région contiendraient NULL dans cette fKey. Ce n'est pas différent de ce que vous auriez fait avec les trois tableaux (vous auriez 1 - beaucoup de relations entre la région et le pays et entre le pays et l'état) sauf que maintenant, tout est dans un seul tableau.

La table popular_user_location serait une table de résolution de portée entre l'utilisateur et georgraphical_location (donc de nombreux utilisateurs pourraient aimer de nombreux endroits).

Soooo…

entrez la description de l'image ici

CREATE TABLE [geographical_location_type] (
    [geographical_location_type_id] INTEGER NOT NULL,
    [name] VARCHAR(25) NOT NULL,
    CONSTRAINT [PK_geographical_location_type] PRIMARY KEY ([geographical_location_type_id])
)

-- Add 'Region', 'Country' and 'State' instances to the above table


CREATE TABLE [geographical_location] (
   [geographical_location_id] BIGINT IDENTITY(0,1) NOT NULL,
    [name] VARCHAR(1024) NOT NULL,
    [geographical_location_type_id] INTEGER NOT NULL,
    [geographical_location_parent] BIGINT,  -- self referencing; can be null for top-level instances
    CONSTRAINT [PK_geographical_location] PRIMARY KEY ([geographical_location_id])
)

CREATE TABLE [user] (
    [user_id] BIGINT NOT NULL,
    [login_id] VARCHAR(30) NOT NULL,
    [password] VARCHAR(512) NOT NULL,
    CONSTRAINT [PK_user] PRIMARY KEY ([user_id])
)


CREATE TABLE [popular_user_location] (
    [popular_user_location_id] BIGINT NOT NULL,
    [user_id] BIGINT NOT NULL,
    [geographical_location_id] BIGINT NOT NULL,
    CONSTRAINT [PK_popular_user_location] PRIMARY KEY ([popular_user_location_id])
)

ALTER TABLE [geographical_location] ADD CONSTRAINT [geographical_location_type_geographical_location] 
    FOREIGN KEY ([geographical_location_type_id]) REFERENCES [geographical_location_type] ([geographical_location_type_id])



ALTER TABLE [geographical_location] ADD CONSTRAINT [geographical_location_geographical_location] 
    FOREIGN KEY ([geographical_location_parent]) REFERENCES [geographical_location] ([geographical_location_id])



ALTER TABLE [popular_user_location] ADD CONSTRAINT [user_popular_user_location] 
    FOREIGN KEY ([user_id]) REFERENCES [user] ([user_id])



ALTER TABLE [popular_user_location] ADD CONSTRAINT [geographical_location_popular_user_location] 
    FOREIGN KEY ([geographical_location_id]) REFERENCES [geographical_location] ([geographical_location_id])

Je ne savais pas quelle était la base de données cible; ce qui précède est MS SQL Server.

Outilsmythe
la source
0

Eh bien, j'ai deux tableaux:

  1. Chansons

a) Numéro du morceau b) Titre du morceau ...

  1. listes de lecture a) Numéro de liste de lecture b) Titre de la liste de lecture ...

et j'en ai un troisième

  1. chansons_to_playlist_relation

Le problème est que certains types de listes de lecture ont des liens vers d'autres listes de lecture. Mais dans mysql, nous n'avons pas de clé étrangère associée à deux tables.

Ma solution: je vais mettre une troisième colonne dans Songs_to_playlist_relation. Cette colonne sera booléenne. Si 1 alors chanson, sinon sera lié à la table de liste de lecture.

Donc:

  1. chansons_to_playlist_relation

a) Playlist_number (int) b) Is song (boolean) c) Numéro relatif (numéro de morceau ou numéro de playlist) (int) ( pas de clé étrangère à une table)

 #create table chansons 
    queries.append (" SET SQL_MODE = NO_AUTO_VALUE_ON_ZERO;")
    queries.append (" CREATE  TABLE songs ( NUMBER int ( 11 ) NOT  NULL , int ( 11 ) NOT NULL , tinyint ( 1 ) NOT NULL DEFAULT '1' , varchar ( 255 ) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL , varchar ( 1000 ) CHARACTER SET utf8 COLLATE utf8_general_ci NOTSONG POSITION  PLAY SONG    SONG TITLE   DESCRIPTION   NULL , ARTIST varchar ( 255 ) CARACTÈRE  SET utf8 COLLATE utf8_general_ci NOT  NULL  DEFAULT  'Άγνωστος καλλιτέχνης' , AUTHOR varchar ( 255 ) CARACTÈRE  SET utf8 COLLATE utf8_general_ci NOT  NULL  DEFAULT  'Άγνωστος στιχουργός' , COMPOSER varchar ( 255 ) CARACTÈRE  SET utf8 COLLATE utf8_general_ci NOT  NULL  DEFAULT  « Άγνωστος συνθέτης ' ,ALBUM varchar ( 255 ) CHARACTER  SET utf8 COLLATE utf8_general_ci NOT  NULL  DEFAULT  'Άγνωστο άλμπουμ' , YEAR int ( 11 ) NOT  NULL  DEFAULT  '33' , RATING int ( 11 ) NOT  NULL  DEFAULT  '5' , IMAGE varchar ( 600 ) CHARACTER  SET utf8 COLLATE utf8_general_ci NOT  NULL , varchar ( 500 )SONG PATH CHARACTER  SET utf8 COLLATE utf8_general_ci NOT  NULL , int ( 11 ) NOT NULL DEFAULT '0' , float NOT NULL DEFAULT '1' , float NOT NULL DEFAULT '1' ) ENGINE = InnoDB DEFAULT CHARSET = utf8; ")SONG REPEAT    VOLUME     SPEED       
    queries.append (" ALTER  TABLE  songs ADD PRIMARY KEY ( NUMBER), ADD  UNIQUE  KEY POSITION ( ), ADD UNIQUE KEY ( ), ADD UNIQUE KEY ( );")SONG POSITION   TITLESONG TITLE   PATHSONG PATH
    queries.append (" ALTER  TABLE  songs MODIFY  NUMBER int ( 11 ) NOT  NULL AUTO_INCREMENT;")

#create table playlists
queries.append("CREATE TABLE `playlists` (`NUMBER` int(11) NOT NULL,`PLAYLIST POSITION` int(11) NOT NULL,`PLAYLIST TITLE` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,`PLAYLIST PATH` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8;")
queries.append("ALTER TABLE `playlists` ADD PRIMARY KEY (`NUMBER`),ADD UNIQUE KEY `POSITION` (`PLAYLIST POSITION`),ADD UNIQUE KEY `TITLE` (`PLAYLIST TITLE`),ADD UNIQUE KEY `PATH` (`PLAYLIST PATH`);")
queries.append("ALTER TABLE `playlists` MODIFY `NUMBER` int(11) NOT NULL AUTO_INCREMENT;")

#create table for songs to playlist relation
queries.append("CREATE TABLE `songs of playlist` (`PLAYLIST NUMBER` int(11) NOT NULL,`SONG OR PLAYLIST` tinyint(1) NOT NULL DEFAULT '1',`RELATIVE NUMBER` int(11) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8;")
queries.append("ALTER TABLE `songs of playlist` ADD KEY `PLAYLIST NUMBER` (`PLAYLIST NUMBER`) USING BTREE;")
queries.append("ALTER TABLE `songs of playlist` ADD CONSTRAINT `playlist of playlist_ibfk_1` FOREIGN KEY (`PLAYLIST NUMBER`) REFERENCES `playlists` (`NUMBER`) ON DELETE RESTRICT ON UPDATE RESTRICT")

C'est tout!

playlists_query = "SELECT s1. *, s3. *, s4. * FROM chansons comme s1 INNER JOIN` chansons de la liste de lecture` comme s2 ON s1.`NUMBER` = s2.`RELATIVE NUMBER` INNER JOIN `playlists` comme s3 SUR s3 .`NUMBER` = s2.`PLAYLIST NUMBER` INNER JOIN `playlists` as s4 ON s4.`NUMBER` = s2.`RELATIVE NUMBER` ORDER BY s3.`PLAYLIST POSITION`,` s1`.`SONG POSITION` "
Chris P
la source