¿Es posible hacer una clave foránea de MySQL para una de las dos tablas posibles?

180

Bueno, aquí está mi problema, tengo tres tablas; regiones, países, estados. Los países pueden estar dentro de las regiones, los estados pueden estar dentro de las regiones. Las regiones son la parte superior de la cadena alimentaria.

Ahora estoy agregando una tabla popular_areas con dos columnas; region_id y popular_place_id. ¿Es posible hacer que popular_place_id sea una clave foránea para cualquiera de los países O estados? Probablemente voy a tener que agregar una columna popular_place_type para determinar si la identificación está describiendo un país o estado de cualquier manera.

Andrew G. Johnson
fuente

Respuestas:

282

Lo que estás describiendo se llama asociaciones polimórficas. Es decir, la columna "clave externa" contiene un valor de identificación que debe existir en uno de un conjunto de tablas de destino. Por lo general, las tablas de destino están relacionadas de alguna manera, como ser instancias de alguna superclase de datos común. También necesitaría otra columna junto a la columna de clave externa, de modo que en cada fila, pueda designar a qué tabla de destino se hace referencia.

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
);

No hay forma de modelar asociaciones polimórficas utilizando restricciones SQL. Una restricción de clave externa siempre hace referencia a una tabla de destino.

Las asociaciones polimórficas son compatibles con marcos como Rails e Hibernate. Pero dicen explícitamente que debe deshabilitar las restricciones de SQL para usar esta función. En cambio, la aplicación o el marco deben hacer un trabajo equivalente para garantizar que se cumpla la referencia. Es decir, el valor en la clave externa está presente en una de las posibles tablas de destino.

Las asociaciones polimórficas son débiles con respecto al cumplimiento de la coherencia de la base de datos. La integridad de los datos depende de que todos los clientes accedan a la base de datos con la misma lógica de integridad referencial aplicada, y también la aplicación debe estar libre de errores.

Aquí hay algunas soluciones alternativas que aprovechan la integridad referencial impuesta por la base de datos:

Crea una tabla extra por objetivo. Por ejemplo popular_statesy popular_countries, que referencia statesy countriesrespectivamente. Cada una de estas tablas "populares" también hace referencia al perfil del usuario.

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),
);

Esto significa que para obtener todos los lugares favoritos populares de un usuario debe consultar ambas tablas. Pero significa que puede confiar en la base de datos para aplicar la coherencia.

Crea una placestabla como supertable. Como Abie menciona, una segunda alternativa es que sus lugares populares hagan referencia a una tabla como places, que es un padre para ambos statesy countries. Es decir, tanto los estados como los países también tienen una clave externa para places(incluso puede hacer que esta clave externa también sea la clave principal de statesy 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)
);

Usa dos columnas. En lugar de una columna que pueda hacer referencia a cualquiera de las dos tablas de destino, use dos columnas. Estas dos columnas pueden ser NULL; de hecho solo uno de ellos debería ser no 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 términos de teoría relacional, las asociaciones polimórficas violan la primera forma normal , porque popular_place_iden realidad es una columna con dos significados: es un estado o un país. No almacenarías los de una persona agey sus phone_numberen una sola columna, y por la misma razón no deberías almacenar ambos state_idy country_iden una sola columna. El hecho de que estos dos atributos tengan tipos de datos compatibles es una coincidencia; todavía significan diferentes entidades lógicas.

Las asociaciones polimórficas también violan la tercera forma normal , porque el significado de la columna depende de la columna adicional que nombra la tabla a la que se refiere la clave externa. En la Tercera forma normal, un atributo en una tabla debe depender solo de la clave primaria de esa tabla.


Re comentario de @SavasVedova:

No estoy seguro de seguir su descripción sin ver las definiciones de la tabla o una consulta de ejemplo, pero parece que simplemente tiene varias Filterstablas, cada una con una clave externa que hace referencia a una Productstabla central .

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

Unir los productos a un tipo específico de filtro es fácil si sabe a qué tipo desea unirse:

SELECT * FROM Products
INNER JOIN FiltersType2 USING (product_id)

Si desea que el tipo de filtro sea dinámico, debe escribir el código de la aplicación para construir la consulta SQL. SQL requiere que la tabla se especifique y arregle al momento de escribir la consulta. No puede hacer que la tabla unida se elija dinámicamente en función de los valores encontrados en filas individuales de Products.

La única otra opción es unirse a todas las tablas de filtro utilizando combinaciones externas. Aquellos que no tienen product_id coincidente serán devueltos como una sola fila de nulos. Pero aún tiene que codificar todas las tablas unidas, y si agrega nuevas tablas de filtro, debe actualizar su código.

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

Otra forma de unirse a todas las tablas de filtro es hacerlo en serie:

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

Pero este formato aún requiere que escriba referencias a todas las tablas. No hay forma de evitar eso.

Bill Karwin
fuente
¿Cuál le sugerirías a Bill? Estoy diseñando una base de datos pero estoy perdido. Básicamente, necesito asociar filtros a un producto y los valores de los filtros se completarán en diferentes tablas. Pero el problema es que los administradores generarán filtros, por lo que, dependiendo del tipo de filtro, los datos pueden variar y, por lo tanto, el joinobjetivo también cambiará ... ¿Estoy complicando demasiado o qué? ¡Ayuda!
Savas Vedova
+1 gracias por una solución increíble. Una pregunta que tengo con la primera / segunda solución es: ¿hay alguna violación de normalización con el hecho de que varias tablas pueden referirse a la misma clave primaria en esa metatabla? Sé que puedes resolver esto con lógica, pero no veo ninguna forma de que la base de datos lo haga cumplir, a menos que me falte algo.
Rob
55
Me gusta mucho el enfoque con "CHEQUEO DE RESTRICCIONES". Pero se puede mejorar si cambiamos "OR" a "XOR". De esa manera nos aseguramos de que solo una columna del conjunto NO sea NULL
alex_b
1
@alex_b, sí, eso es bueno, pero el XOR lógico no es SQL estándar y no es compatible con todas las marcas de SQL. MySQL lo tiene, pero PostgreSQL no. Oracle lo tiene, pero Microsoft no lo hace hasta 2016. Y así sucesivamente.
Bill Karwin
1
"Estas dos columnas pueden ser NULL; de hecho, sólo uno de ellos debe ser no nulo" - esto podría violar 1NF!
cuando el
10

Esta no es la solución más elegante del mundo, pero podría usar la herencia de tablas concretas para que esto funcione.

Conceptualmente, está proponiendo una noción de una clase de "cosas que pueden ser áreas populares" de las cuales heredan sus tres tipos de lugares. Se podría representar esto como una tabla llamada, por ejemplo, placesdonde cada fila tiene una relación uno-a-uno con una fila regions, countrieso states. (Los atributos que se comparten entre regiones, países o estados, si los hubiera, podrían insertarse en esta tabla de lugares). popular_place_idEntonces, sería una referencia de clave externa a una fila en la tabla de lugares que luego lo conduciría a una región, país o estado.

La solución que propone con una segunda columna para describir el tipo de asociación es cómo Rails maneja las asociaciones polimórficas, pero no soy fanático de eso en general. Bill explica con excelente detalle por qué las asociaciones polimórficas no son tus amigos.

Abie
fuente
1
también conocido como "el patrón de
supertipo
También este artículo ampliará bien el concepto duhallowgreygeek.com/polymorphic-association-bad-sql-smell
Marco Staffoli
5

Aquí hay una corrección al enfoque "supertable" de Bill Karwin, utilizando una clave compuesta ( place_type, place_id )para resolver las violaciones de forma normal percibidas:

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 )
);

Lo que este diseño no puede garantizar es que para cada fila placesexiste una fila en stateso countries(pero no en ambas). Esta es una limitación de las claves foráneas en SQL. En un DBMS completo que cumpla con los estándares SQL-92, podría definir restricciones diferibles entre tablas que le permitirían lograr lo mismo, pero es torpe, implica transacciones y dicho DBMS aún no ha llegado al mercado.

un día cuando
fuente
0

Me doy cuenta de que este hilo es viejo, pero lo vi y se me ocurrió una solución y pensé en tirarlo.

Regiones, países y estados son ubicaciones geográficas que viven en una jerarquía.

Podrías evitar tu problema por completo creando una tabla de dominio llamada geográfica_tipo_ubicación que llenarías con tres filas (Región, País, Estado).

A continuación, en lugar de las tres tablas de ubicación, cree una sola tabla de ubicación geográfica que tenga una clave foránea de tipo_de_ubicación_geográfica (para que sepa si la instancia es una Región, País o Estado).

Modele la jerarquía haciendo que esta tabla haga referencia a sí misma de modo que una instancia de Estado mantenga la fKey en su instancia de País principal, que a su vez mantiene la fKey en su instancia de Región principal. Las instancias de región contendrían NULL en esa fKey. Esto no es diferente de lo que hubiera hecho con las tres tablas (tendría 1: muchas relaciones entre región y país y entre país y estado), excepto que ahora todo está en una tabla.

La tabla popular_user_location sería una tabla de resolución de alcance entre user y georgraphical_location (a muchos usuarios les pueden gustar muchos lugares).

Soooo ...

ingrese la descripción de la imagen aquí

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])

No estaba seguro de cuál era el DB objetivo; lo anterior es MS SQL Server.

Toolsmythe
fuente
0

Bueno, tengo dos mesas:

  1. canciones

a) Número de canción b) Título de la canción ...

  1. listas de reproducción a) Número de lista de reproducción b) Título de la lista de reproducción ...

y tengo un tercero

  1. songs_to_playlist_relation

El problema es que algunos tipos de listas de reproducción tienen enlaces a otras listas de reproducción. Pero en mysql no tenemos una clave externa asociada con dos tablas.

Mi solución: pondré una tercera columna en songs_to_playlist_relation. Esa columna será booleana. Si es 1, entonces la canción, de lo contrario se vinculará a la tabla de la lista de reproducción.

Entonces:

  1. songs_to_playlist_relation

a) Playlist_number (int) b) Is song (boolean) c) Número relativo (número de canción o número de lista de reproducción) (int) ( clave no ajena a ninguna tabla)

 # # crear canciones de mesa 
    consultas . anexar ( "SET SQL_MODE = NO_AUTO_VALUE_ON_ZERO;" ) 
    consultas . append ( "CREATE TABLE songs( NUMBERint (11) NOT NULL, SONG POSITIONint (11) NOT NULL, PLAY SONGtinyint (1) NOT NULL DEFAULT '1', SONG TITLEvarchar (255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, DESCRIPTIONvarchar (1000) CHARACTER SET utf8 INTERCALAR utf8_general_ci NO NULO, ARTISTvarchar (255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT 'Άγνωστος καλλιτέχνης', AUTHORvarchar (255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT 'Άγνωστος στιχουργός', COMPOSERvarchar (255) CHARACTER SET utf8 clasificación no utf8_general_ci NULL POR DEFECTO 'Άγνωστος συνθέτης',ALBUMvarchar (255) CONJUNTO DE CARACTERES utf8 COLLATE utf8_general_ci NO NULL DEFAULT 'Άγνωστο άλμπουμ', YEARint (11) NOT NULL DEFAULT '33', RATINGint (11) NOT NULL DEFAULT '5', );") 
    consultasIMAGEvarchar (600) SET DE CARACTERES utf8 COLLATE utf8_general_ci NO NULL, SONG PATHvarchar (500) CHARACTER SET utf8 COLLATE utf8_general_ci NO NULL, SONG REPEATint (11) NO NULL DEFAULT '0', VOLUMEfloat NOT NULL DEFAULT '1', SPEEDfloat NOT NULL DEFAULT '1' ) ENGINE = InnoDB DEFAULT CHARSET = utf8; " ) 
    consultas . Anexar ( " ALTER TABLE songsADD PRIMARY KEY ( NUMBER), ADD UNIQUE KEY POSITION( SONG POSITION), ADD UNIQUE KEY TITLE( SONG TITLE), ADD UNIQUE KEY PATH(SONG PATH. append ( "ALTER TABLE songsMODIFY NUMBERint (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")

¡Eso es todo!

playlists_query = "SELECCIONE s1. *, s3. *, s4. * DESDE canciones como s1 INNER JOIN` canciones de la lista de reproducción` como s2 ON s1.`NUMBER` = s2.`NÚMERO RELATIVO` INNER JOIN `playlists` como s3 ON s3 .`NUMBER` = s2.`PLAYLIST NUMBER` INNER ÚNETE a `playlists` como s4 ON s4.`NUMBER` = s2.`RELATIVE NUMBER` ORDER BY s3.`PLAYLIST POSITION`,` s1`.`SONG POSITION` "
Chris P
fuente