Justavian
Justavian

Reputation: 143

SQL Server not checking all foreign key pieces - is this normal?

I was just testing some data import routines in an application, and I was discovering that it was allowing data that I would have thought was invalid. Specifically, a multi-column foreign key was allowing the second piece to import without the first, which clearly results in data that is not valid for the reference table.

Consider an example: I have a country table, a state / province table, and a person table. State references country, Person references both. If I try to insert a person with a bad country, it complains as expected. If I insert a person with a VALID country, but a bad state, it complains as expected.

BUT: if I insert a person with a NULL country and a string NOTREAL for state, it's permitting it. How can this be? There is not a record in the state table that has a NULL, nor is there a state that uses NOTREAL as the code.

Here is my example that creates all three tables, populates a single country and state, and then tries to INSERT a person a couple different ways.

DROP TABLE IF EXISTS person;
DROP TABLE IF EXISTS stateprov;
DROP TABLE IF EXISTS country;
GO

/* CREATE a country table. */
CREATE TABLE [dbo].[country]
(
    [country_code] [nvarchar](32) NOT NULL,
    [caption] [nvarchar](50) NULL

    CONSTRAINT [PK_country] 
        PRIMARY KEY CLUSTERED ([country_code] ASC)
                WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, 
                      IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, 
                      ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO

/* CREATE a stateprov table. Key is both country and state. */
CREATE TABLE [dbo].[stateprov]
(
    [country_code] [nvarchar](32) NOT NULL,
    [stateprov_code] [nvarchar](32) NOT NULL,
    [caption] [nvarchar](256) NULL

    CONSTRAINT [PK_stateprov] 
        PRIMARY KEY CLUSTERED ([country_code] ASC, [stateprov_code] ASC)
                WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, 
                      IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, 
                      ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO

/* Single part key to reference the country table. */
ALTER TABLE [dbo].[stateprov] WITH CHECK 
    ADD CONSTRAINT [FK_stateprov_country] 
        FOREIGN KEY([country_code]) REFERENCES [dbo].[country] ([country_code])
GO

ALTER TABLE [dbo].[stateprov] CHECK CONSTRAINT [FK_stateprov_country]
GO

/* Finally, our person table. */
CREATE TABLE [dbo].[person]
(
    [contact_code] [int] IDENTITY(1,1) NOT NULL,
    [country_code] [nvarchar](32) NULL,
    [stateprov_code] [nvarchar](32) NULL

    CONSTRAINT [PK_person] 
        PRIMARY KEY CLUSTERED ([contact_code] ASC)
                WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, 
                      IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, 
                      ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] 
GO

/* Single part key to reference the country table. */
ALTER TABLE [dbo].[person]  WITH CHECK 
    ADD CONSTRAINT [FK_person_country] 
        FOREIGN KEY([country_code]) REFERENCES [dbo].[country] ([country_code])
GO

ALTER TABLE [dbo].[person] CHECK CONSTRAINT [FK_person_country]
GO

/* Two part key that points back to the stateprov table, including country and state columns. */
ALTER TABLE [dbo].[person] WITH CHECK 
    ADD CONSTRAINT [FK_person_stateprov] 
        FOREIGN KEY([country_code], [stateprov_code])
            REFERENCES [dbo].[stateprov] ([country_code], [stateprov_code])
GO

ALTER TABLE [dbo].[person] CHECK CONSTRAINT [FK_person_stateprov]
GO

/* Insert a single valid country, and a single value state. */
INSERT INTO country (country_code) VALUES ('US');
INSERT INTO stateprov (country_code, stateprov_code) VALUES ('US', 'CA');
GO

/* This will fail. The country is not real. */
INSERT INTO person (country_code) VALUES ('NOTREAL')
GO

/* This will fail; the country is real, but the state is not. */
INSERT INTO person (country_code, stateprov_code) 
VALUES ('US', 'NOTREAL')
GO

/* I would expect this to fail - BUT IT DOES NOT FAIL. THIS IS ALLOWED!!?? */
INSERT INTO person (stateprov_code) 
VALUES ('NOTREAL')

I've tested this on two different servers using new databases, and it keeps allowing that last one. Is this how SQL Server is supposed to work? Is there something else I need to do to make sure that SQL Server enforces this as I would expect?

EDIT: To get SQL Server to simulate a MATCH FULL: Charlieface in the comments suggests using a check constraint. For the example above with Country / Stateprov tables, the following constraint will work.

ALTER TABLE [dbo].[person] WITH CHECK
    ADD CONSTRAINT [CHK_country_state_multipart]
        CHECK (
                (country_code IS NULL AND stateprov_code IS NULL) OR 
                (country_code IS NOT NULL AND stateprov_code IS NOT NULL) OR 
                (country_code IS NOT NULL AND stateprov_code IS NULL)
            );

Upvotes: 4

Views: 70

Answers (0)

Related Questions