47

How could I set a constraint on a table so that only one of the records has its isDefault bit field set to 1?

The constraint is not table scope, but one default per set of rows, specified by a FormID.

ProfK
  • 49,207
  • 121
  • 399
  • 775
  • Is the default mandatory? There must be a default for each FormID? Or could some IDs be without a default? – David Balažic Mar 18 '17 at 17:31
  • @DavidBalažic I can no longer remember. – ProfK Mar 19 '17 at 04:24
  • check out this one https://stackoverflow.com/questions/4810615/sql-server-bit-column-constraint-1-row-1-all-others-0?noredirect=1&lq=1#4810654 – Joe B Aug 28 '17 at 13:19
  • Has anyone solved the problem of how to enforce having that one mandatory default (i.e. if any records exist, then there must be a default. If only one record exists, then it must be the one marked as default)? – Reversed Engineer Aug 06 '18 at 16:52

10 Answers10

59

Use a unique filtered index

On SQL Server 2008 or higher you can simply use a unique filtered index

CREATE UNIQUE INDEX IX_TableName_FormID_isDefault
    ON TableName(FormID)
    WHERE isDefault = 1

Where the table is

CREATE TABLE TableName(
    FormID INT NOT NULL,
    isDefault BIT NOT NULL
)

For example if you try to insert many rows with the same FormID and isDefault set to 1 you will have this error:

Cannot insert duplicate key row in object 'dbo.TableName' with unique index 'IX_TableName_FormID_isDefault'. The duplicate key value is (1).

Source: http://technet.microsoft.com/en-us/library/cc280372.aspx

Yves M.
  • 29,855
  • 23
  • 108
  • 144
14

Here's a modification of Damien_The_Unbeliever's solution that allows one default per FormID.

CREATE VIEW form_defaults
AS
SELECT FormID
FROM whatever
WHERE isDefault = 1
GO
CREATE UNIQUE CLUSTERED INDEX ix_form_defaults on form_defaults (FormID)
GO

But the serious relational folks will tell you this information should just be in another table.

CREATE TABLE form
FormID int NOT NULL PRIMARY KEY
DefaultWhateverID int FOREIGN KEY REFERENCES Whatever(ID)
Andriy M
  • 76,112
  • 17
  • 94
  • 154
Tom Future
  • 1,900
  • 2
  • 15
  • 15
7

From a normalization perspective, this would be an inefficient way of storing a single fact.

I would opt to hold this information at a higher level, by storing (in a different table) a foreign key to the identifier of the row which is considered to be the default.

CREATE TABLE [dbo].[Foo](
    [Id] [int] NOT NULL,
 CONSTRAINT [PK_Foo] PRIMARY KEY CLUSTERED 
(
    [Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]

GO

CREATE TABLE [dbo].[DefaultSettings](
    [DefaultFoo] [int] NULL
) ON [PRIMARY]

GO

ALTER TABLE [dbo].[DefaultSettings]  WITH CHECK ADD  CONSTRAINT [FK_DefaultSettings_Foo] FOREIGN KEY([DefaultFoo])
REFERENCES [dbo].[Foo] ([Id])
GO

ALTER TABLE [dbo].[DefaultSettings] CHECK CONSTRAINT [FK_DefaultSettings_Foo]
GO
Ian Nelson
  • 57,123
  • 20
  • 76
  • 103
  • This method will not ensure that only one row has isDefault = 1 – Andy Jones Mar 12 '09 at 14:00
  • 1
    @Andy Jones, you're right, it won't. I wouldn't have an isDefault column at all. I'm suggesting an alternative solution. – Ian Nelson Mar 12 '09 at 14:03
  • 7
    Yes, but this alternative solution still allows for multiple defaults by inserting many rows into dbo.DefaultSettings. Could maybe add a trigger to dbo.DefaultSettings to only allow 1 row in there if you chose that method. – Andy Jones Mar 12 '09 at 14:12
  • 1
    @AndyJones In theory, it does allow more than one default. However - if you are using this table as a '1 row table that stores a setting or row of settings' - then you wouldn't ever be inserting rows into that table. I think this is a far clearer, simpler solution less prone to misunderstanding and errors later. – niico Nov 21 '16 at 16:09
3

You could use an insert/update trigger.

Within the trigger after an insert or update, if the count of rows with isDefault = 1 is more than 1, then rollback the transaction.

Andy Jones
  • 1,472
  • 9
  • 15
2

The question implies to me that you have a primary table that has some child records and one of those child records will be the default record. Using address and a separate default table here is an example of how to make that happen using third normal form. Of course I don't know if it's valuable to answer something that is so old but it struck my fancy.

--drop table dev.defaultAddress;
--drop table dev.addresses;
--drop table dev.people;

CREATE TABLE [dev].[people](
    [Id] [int] identity primary key,
    name char(20)
)
GO

CREATE TABLE [dev].[Addresses](
    id int identity primary key,
    peopleId int foreign key references dev.people(id),
    address varchar(100)
) ON [PRIMARY]

GO
CREATE TABLE [dev].[defaultAddress](
    id int identity primary key,
    peopleId int foreign key references dev.people(id),
    addressesId int foreign key references dev.addresses(id))
go
create unique index defaultAddress on dev.defaultAddress (peopleId)
go
create unique index idx_addr_id_person on dev.addresses(peopleid,id);
go
ALTER TABLE dev.defaultAddress
   ADD CONSTRAINT FK_Def_People_Address
   FOREIGN KEY(peopleID, addressesID)
   REFERENCES dev.Addresses(peopleId, id)
go
insert into dev.people (name) 
    select 'Bill' union 
    select 'John' union 
    select 'Harry'
insert into dev.Addresses (peopleid, address) 
    select 1, '123 someplace' union 
    select 1,'work place' union
    select 2,'home address' union
    select 3,'some address'
insert into dev.defaultaddress (peopleId, addressesid)
    select 1,1 union
    select 2,3 
-- so two home addresses are default now
-- try adding another default address to Bill and you get an error
select * from dev.people 
    join dev.addresses on people.id = addresses.peopleid
    left join dev.defaultAddress on defaultAddress.peopleid = people.id and defaultaddress.addressesid = addresses.id 
insert into dev.defaultaddress (peopleId, addressesId)
    select 1,2 

GO
billpennock
  • 441
  • 1
  • 6
  • 14
  • What if you insert into `defaultAddress` an address that belongs to someone else? This won't catch it. – Kiran Jonnalagadda Aug 09 '17 at 06:34
  • How would it programmatically know that the address you entered wasn't the address of the person? If you are saying that the rule you need to enforce is no person can have the same default address as another person you can add the addressId to the unique address but that would assume no married couples/roommates/etc. in the database – billpennock Aug 10 '17 at 13:24
  • I meant database constraint. `Addresses.peopleId` links an address to a person. The same row clearly can't link to another person. Therefore `defaultAddress` needs to validate that the address marked as default for a given `peopleId` also has a matching `peopleId`. (I just implemented this in PostgreSQL and addressed it with a trigger validator.) – Kiran Jonnalagadda Aug 16 '17 at 10:53
  • Sorry it had been awhile since I answered this and I should have spent more time understanding your comment. It's a good catch. I had alot of trouble getting code in this comment, maybe it's not possible. Anyway I added a multi-column foreign key to the code above to answer the very excellent concern. I think this will do what you identified without a trigger. – billpennock Aug 17 '17 at 15:43
2

I don't know about SQLServer.But if it supports Function-Based Indexes like in Oracle, I hope this can be translated, if not, sorry.

You can do an index like this on suposed that default value is 1234, the column is DEFAULT_COLUMN and ID_COLUMN is the primary key:

CREATE 
UNIQUE 
 INDEX only_one_default 
    ON my_table
     ( DECODE(DEFAULT_COLUMN, 1234, -1, ID_COLUMN) )

This DDL creates an unique index indexing -1 if the value of DEFAULT_COLUMN is 1234 and ID_COLUMN in any other case. Then, if two columns have DEFAULT_COLUMN value, it raises an exception.

Mridang Agarwalla
  • 43,201
  • 71
  • 221
  • 382
FerranB
  • 35,683
  • 18
  • 66
  • 85
2
CREATE VIEW vOnlyOneDefault
AS
  SELECT 1 as Lock
  FROM <underlying table>
  WHERE Default = 1
GO
CREATE UNIQUE CLUSTERED INDEX IX_vOnlyOneDefault on vOnlyOneDefault (Lock)
GO

You'll need to have the right ANSI settings turned on for this.

Damien_The_Unbeliever
  • 234,701
  • 27
  • 340
  • 448
  • This looks good, but complicated. I'd have to build the `create view` statement dynamically, as the default constraint isn't table wide, it's per FormID in the underlying table. – ProfK Mar 12 '09 at 16:02
  • When you have a complicated problem, you will need a complicated solution. This will not address all the other problems I mentioned about how you want to handle things as data changes. – HLGEM Mar 12 '09 at 18:21
  • 4
    Also remember to use WITH SCHEMABINDING when creating the view – kristof Mar 13 '09 at 10:57
  • This got my vote because it actually answers the original question. It also neatly solved a very similar problem I was having in a way that wasn't covered by any of the other answers. – Daniel Schealler May 15 '15 at 06:09
0

You could do it through an instead of trigger, or if you want it as a constraint create a constraint that references a function that checks for a row that has the default set to 1

EDIT oops, needs to be <=

Create table mytable(id1 int, defaultX bit not null default(0))
go

create Function dbo.fx_DefaultExists()
returns int as 
Begin
    Declare @Ret int
    Set @ret = 0
    Select @ret = count(1) from mytable 
    Where defaultX = 1

    Return @ret
End
GO
Alter table mytable add
CONSTRAINT  [CHK_DEFAULT_SET] CHECK 
 (([dbo].fx_DefaultExists()<=(1)))
GO
Insert into mytable (id1, defaultX) values (1,1)

Insert into mytable (id1, defaultX) values (2,1)
cmsjr
  • 56,771
  • 11
  • 70
  • 62
0

This is a fairly complex process that cannot be handled through a simple constraint.

We do this through a trigger. However before you write the trigger you need to be able to answer several things:

do we want to fail the insert if a default exists, change it to 0 instead of 1 or change the existing default to 0 and leave this one as 1? what do we want to do if the default record is deleted and other non default records are still there? Do we make one the default, if so how do we determine which one?

You will also need to be very, very careful to make the trigger handle multiple row processing. For instance a client might decide that all of the records of a particular type should be the default. You wouldn't change a million records one at a time, so this trigger needs to be able to handle that. It also needs to handle that without looping or the use of a cursor (you really don't want the type of transaction discussed above to take hours locking up the table the whole time).

You also need a very extensive tesing scenario for this trigger before it goes live. You need to test: adding a record with no default and it is the first record for that customer adding a record with a default and it is the first record for that customer adding a record with no default and it is the not the first record for that customer adding a record with a default and it is the not the first record for that customer Updating a record to have the default when no other record has it (assuming you don't require one record to always be set as the deafault) Updating a record to remove the default Deleting the record with the deafult Deleting a record without the default Performing a mass insert with multiple situations in the data including two records which both have isdefault set to 1 and all of the situations tested when running individual record inserts Performing a mass update with multiple situations in the data including two records which both have isdefault set to 1 and all of the situations tested when running individual record updates Performing a mass delete with multiple situations in the data including two records which both have isdefault set to 1 and all of the situations tested when running individual record deletes

HLGEM
  • 94,695
  • 15
  • 113
  • 186
0

@Andy Jones gave an answer above closest to mine, but bearing in mind the Rule of Three, I placed the logic directly in the stored proc that updates this table. This was my simple solution. If I need to update the table from elsewhere, I will move the logic to a trigger. The one default rule applies to each set of records specified by a FormID and a ConfigID:

ALTER proc [dbo].[cpForm_UpdateLinkedReport]
    @reportLinkId int,
    @defaultYN bit,
    @linkName nvarchar(150)
as
if @defaultYN = 1
begin
    declare @formId int, @configId int
    select @formId = FormID, @configId = ConfigID from csReportLink where ReportLinkID = @reportLinkId
    update csReportLink set DefaultYN = 0 where isnull(ConfigID,  @configId) = @configId and FormID = @formId
end
update
    csReportLink
 set
    DefaultYN = @defaultYN,
    LinkName = @linkName
where
    ReportLinkID = @reportLinkId
ProfK
  • 49,207
  • 121
  • 399
  • 775
  • ALways a bad idea to put required logic in a stored proc vice a trigger. You won't always know when you need to change to a trigger later because multiple applications are using this. You are creating a data integrity nightmare for some future person to fix. – HLGEM Mar 12 '09 at 18:24
  • I'm responsible for the single application area that maintains that table, and we release tomorrow, so not having to add to our SQL update script reduces risk. The update script for sp's is generated, other objects it's manual, with risk. I will schedule a trigger for next cycle, in 6 weeks time. – ProfK Mar 12 '09 at 18:34