Declarative approach to constrain data ranges in table - sql

I would like to learn a declarative approach for a data constraint issue I have had from time to time related to exclusive date ranges.
Below is a simplified example. I have items and prices on those items. I want the effective date range of the prices to be mutually exclusive with no overlap.
As I understand things with Oracle, user-defined functions are not eligible for use in CONSTRAINT declarations - and I can't even imagine how poorly it would perform if it were allowed. So I require a procedural approach using triggers. Typical trigger source is also included below.
I am not interested in learning better procedural logic for use within the trigger (it is just a simple demonstrative example). I am interested in learning a more declarative solution to a relatively popular data constraint issue I (and likely others) face.
I want to eliminate trigger-based solutions whenever I can, as a practice. However I can't seem to find my way out of this requirement without a trigger.
create table item ( title varchar2(32) primary key );
create table price (
item varchar2(32) not null references item (title),
price number(9,2),
effective_from date not null,
effective_to date not null,
constraint price_from_to_ck check (effective_to > effective_from ));
[REDACTED]
*(A combination of row and statement level triggers inteneded to prevent logical chronological overlap)
insert into item values ('LETTUCE');
insert into item values ('WHISKY');
insert into price values ( 'LETTUCE', 1.05, date '2013-01-01', date '2013-03-31' );
insert into price values ( 'LETTUCE', 1.08, date '2013-04-01', date '2013-06-30' );
insert into price values ( 'WHISKY', 33.99, date '2013-01-01', date '2013-05-31' );
insert into price values ( 'WHISKY', 31.15, date '2013-06-01', date '2013-07-31' );
-- should fail
insert into price values ( 'WHISKY', 30.55, date '2013-05-15', date '2013-06-05' );

While waiting for next Oracle 12c version, which supports Temporal Validity, I still use next approach:
create table item ( title varchar2(32) primary key );
create table price (
price_id number primary key,
item varchar2(32) not null references item (title),
price number(9,2),
effective_from date not null,
effective_to date not null,
effective_prev_to date,
constraint price_from_to_ck check ( effective_to > effective_from ),
constraint price_to_prev_ck check ( effective_from = effective_prev_to + 1 ),
constraint price_from_uq unique ( item, effective_to ),
constraint price_dates_chain_fk foreign key ( item, effective_prev_to ) references price ( item, effective_to ) );
insert into item values ('LETTUCE');
insert into item values ('WHISKY');
insert into price values ( 1, 'LETTUCE', 1.05, date '2013-01-01', date '2013-03-31', null );
insert into price values ( 2, 'LETTUCE', 1.08, date '2013-04-01', date '2013-06-30', date '2013-03-31' );
insert into price values ( 3, 'WHISKY', 33.99, date '2013-01-01', date '2013-05-31', null );
insert into price values ( 4, 'WHISKY', 31.15, date '2013-06-01', date '2013-07-31', date '2013-05-31' );
Let's try:
insert into price values ( 5, 'WHISKY', 30.55, date '2013-05-15', date '2013-06-05', date '2013-05-14' );
ORA-02291: integrity constraint (USER_4_E7DF1.PRICE_DATES_CHAIN_FK) violated - parent key not found : insert into price values ( 'WHISKY', 30.55, date '2013-05-15', date '2013-06-05', date '2013-05-14' )
But now updating and deleting dates in the middle of the chain is pain in the ass. It needs to change preceding and following rows in one statement with MERGE. That's why I've added price_id column, because you can't update a key in MERGE -- so, you need another key instead of (item, effective_%).

You can do this declaritively with a materialized view, as first suggested by Brian Camire. Here's an example:
--Original tables (with an extra primary key on PRICE)
create table item ( title varchar2(32) primary key );
create table price (
id number primary key,
item varchar2(32) not null references item (title),
price number(9,2),
effective_from date not null,
effective_to date not null,
constraint price_from_to_ck check (effective_to > effective_from ));
create materialized view log on price with rowid;
--Items with overlapping dates
create materialized view price_no_overlap_mv
refresh fast on commit as
select 'overlapping row' as dummy, price1.rowid rowid1, price2.rowid rowid2
from price price1, price price2
where
--Same item
price1.item = price2.item
--Overlapping dates
and (price1.effective_from <= price2.effective_to and price1.effective_to >= price2.effective_from)
--Don't compare the same row
and price1.id <> price2.id
;
--Throw an error if any rows ever get created.
alter table price_no_overlap_mv
add constraint price_no_overlap_mv_ck check (dummy = 'no rows allowed');
insert into item values ('LETTUCE');
insert into item values ('WHISKY');
insert into price values (1, 'LETTUCE', 1.05, date '2013-01-01', date '2013-03-31' );
insert into price values (2, 'LETTUCE', 1.08, date '2013-04-01', date '2013-06-30' );
insert into price values (3, 'WHISKY', 33.99, date '2013-01-01', date '2013-05-31' );
insert into price values (4, 'WHISKY', 31.15, date '2013-06-01', date '2013-07-31' );
commit;
-- should fail
insert into price values (5, 'WHISKY', 30.55, date '2013-05-15', date '2013-06-05' );
commit;
ORA-12008: error in materialized view refresh path
ORA-02290: check constraint (JHELLER.PRICE_NO_OVERLAP_MV_CK) violated
This declarative approach is both concurrent and consistent. But there are a lot of draw backs:
Materialized view logs, which are required for a fast refresh, are only supported in Enterprise Edition.
Your table needs a primary key, although you probably already have one but just didn't include it in the example.
Although declarative, the solution is still not straight-forward. You have to declare the opposite condition, and then check that it never exists.
Getting FAST REFRESH to work can be a nightmare for more than the simplest of queries. Even for this simple example, I had to use the old-style joins and had to add useless ROWIDs.
The constraint is not enforced until a COMMIT. Although that could be a positive thing, as many types of changes would temporarily create overlapping results. If you never allow overlapping results, you have to modify the table in a specific order.

Related

SQLite: Unique per title and date (only date without time)

I have a table in which i store some titles as a TEXT and a date also as a TEXT.
I am inserting the date value as a string in ISO format in the database.
Now i want to have a unique combination of title and date but only considering the date part(year, month and day) of the ISO string and ignoring the time part. So i want a unique title per specific day in the year.
Example:
INSERT INTO TABLE_A (TITLE, DATE) VALUES ('TITLE_A', '2022-06-20T13:53:41.680Z') -- OK
INSERT INTO TABLE_A (TITLE, DATE) VALUES ('TITLE_A', '2022-06-20T22:12:32.430Z') -- NOT OK same title and only time is different
INSERT INTO TABLE_A (TITLE, DATE) VALUES ('TITLE_B', '2022-06-20T13:53:41.680Z') -- OK same date but title is different
What are my best options? I could implement a check but i'm not sure if that is the best option because as the table grows this could make the check slow.
SQLite does not allow the use of functions inside the definition of a UNIQUE constraint, which would solve the problem like this:
UNIQUE(TITLE, date(DATE)) -- not allowed
Instead you can define a generated column (version 3.31.0+) as the date part of the column DATE and use that in the definition of the constraint.
It would make more sense to rename your existing column to TIMESTAMP and name the generated column as DATE:
CREATE TABLE TABLE_A (
TITLE TEXT,
TIMESTAMP TEXT,
DATE TEXT GENERATED ALWAYS AS (date(TIMESTAMP)),
UNIQUE(TITLE, DATE)
);
INSERT INTO TABLE_A (TITLE, TIMESTAMP) VALUES ('TITLE_A', '2022-06-20T13:53:41.680Z'); -- OK
INSERT INTO TABLE_A (TITLE, TIMESTAMP) VALUES ('TITLE_A', '2022-06-20T22:12:32.430Z'); -- error
INSERT INTO TABLE_A (TITLE, TIMESTAMP) VALUES ('TITLE_B', '2022-06-20T13:53:41.680Z'); -- OK
As it is, the generated column DATE is not actually stored in the table.
You could maintain a pure date column and then impose a unique constraint on the combination of title and date.
CREATE TABLE TABLE_A (
TITLE VARCHAR NOT NULL,
DATE VARCHAR NOT NULL,
TIMESTAMP VARCHAR NOT NULL,
CONSTRAINT c_unique UNIQUE (TITLE, DATE)
);
INSERT INTO TABLE_A (TITLE, DATE, TIMESTAMP)
VALUES ('TITLE_A', '2022-06-20', '2022-06-20T13:53:41.680Z') -- passes
INSERT INTO TABLE_A (TITLE, DATE, TIMESTAMP)
VALUES ('TITLE_A', '2022-06-20', '2022-06-20T22:12:32.430Z') -- fails
INSERT INTO TABLE_A (TITLE, DATE, TIMESTAMP)
VALUES ('TITLE_B', '2022-06-20', '2022-06-20T13:53:41.680Z') -- passes

Postgres CHECK constraint executing before ON CONFLICT

I have this historical stock movements table, that saves the stock losses per day:
CREATE TABLE stock (
item_id INTEGER NOT NULL,
date DATE NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity >= 0),
PRIMARY KEY (item_id, date)
);
And this INSERT/ON CONFLICT (To update instead, atomically):
INSERT INTO stock.loss (item_id, date, quantity)
VALUES(2, '2021-10-03', -1)
ON CONFLICT (item_id, date)
DO
UPDATE SET quantity = stock.quantity + excluded.quantity;
With this, I insert a movement for a day, or update it if it already exists.
The thing is, if "quantity" is negative (see query above), the CHECK constraint throws:
SQL Error [23514]: ERROR: new row for relation "stock" violates check constraint "stock_quantity_check"
Detail: Failing row contains (2, 2021-10-03, -1).
If I change the INSERT to an UPDATE, it works as expected. As I see, it's running the check on the temporary row instead of the final row, after the ON CONFLICT.
Is there any way to avoid this, and keep the CHECK? I would prefer to avoid a SELECT+INSERT/UPDATE, and make a direct "upsert".

sql unique constraint with time window

I have a table where records have a (begin, end) time window of existence (for things like employement duration, birth and death, rent duration, ...)
begin IS NULL or end IS NULL if there is no bound.
CREATE TABLE mytable(
id int primary key,
value int, --UNIQUE at any point in time
begin datetime NULL,
end datetime NULL
);
I want column value to be unique at any point in time.
INSERT INTO mytable VALUES(1, 1, '2021-07-23', '2021-07-24'),(2, 1, '2021-07-25', NULL);
Is OK
Whereas
INSERT INTO mytable VALUES(1, 1, '2021-07-23', '2021-07-30'),(2, 1, '2021-07-25', NULL);
Is not OK, because both records have value=1 and overlapping time windows.
Is there a way to enforce such a constraint in SQL ?
You can't do this on the table, no, as there's nothing to make UNIQUE on.
What you could do, however, is use a VIEW to enforce it.
Firstly, let's create your table. I assume the columns datetime, should actually be begin and end; I recommend against these names as they are reserved keywords. As such I am calling them DateBegin and DateEnd. I am also assuming that they are date only (no time portion) values and so define them as a date not a datetime:
CREATE TABLE dbo.mytable(ID int primary key,
Value int,
[BeginDate] date NULL,
[EndEnd] date NULL);
And we'll INSERT your first 2 rows, as they are "ok":
INSERT INTO dbo.mytable (ID, Value, BeginDate, EndDate)
VALUES(1, 1, '20210723', '20210724'),
(2, 1, '20210725', NULL);
Now we need to make a VIEW, but we need one row per date. As such you'll want to create a Calendar Table. I'm not going to cover how to create one here, but there are literally 100's of articles, such as there on SQL Server Central: Bones of SQL - The Calendar Table, Calendar Tables in T-SQL.
Once you have your Calendar table, you can create the VIEW below, which JOINs the data in your table to the calendar table. We're going to make it so that the VIEW just returns the columns value and the date. WE're also going to schemabind it; this means we'll be able to add an UNIQUE INDEX to it:
CREATE VIEW dbo.MyView
WITH SCHEMABINDING
AS
SELECT MT.[Value],
CT.CalendarDate
FROM dbo.MyTable MT
JOIN dbo.CalendarTable CT ON MT.BeginDate <= CT.CalendarDate --I assume, despite your schema, MT.BeginDate can't be NULL
AND (MT.EndDate >= CT.CalendarDate OR MT.EndDate IS NULL);
Now we have a VIEW that has a row for each date, and for each value. This means we can now create our UNIQUE INDEX:
CREATE UNIQUE CLUSTERED INDEX MyIndex ON dbo.MyView ([Value], CalendarDate);
Now if we try to INSERT a row that is on the same date and value, we'll get an error:
INSERT INTO dbo.MyTable (ID, Value, BeginDate, EndDate)
VALUES(3, 1, '20210720', '20210723');
Cannot insert duplicate key row in object 'dbo.MyView' with unique index 'MyIndex'. The duplicate key value is (1, 2021-07-23).

How to seperate Date and Time in Oracle SQL

I have a table called transaction that contains a TxDateTime which is a DATE data type. When I run a query I would like to separate the date and time into their own columns; TxDate and TxTime. How can I achieve this??
Here is how I created the table and inserted the date/time:
Table creation
CREATE TABLE TRANSACTION(TxNbr INTEGER PRIMARY KEY,
TxCode CHAR(1) NOT NULL,
AccountNbr INTEGER NOT NULL,
Amount DECIMAL(13,2) NOT NULL,
TxDateTime DATE,
RefNbr VARCHAR(3),
FOREIGN KEY(AccountNbr) REFERENCES ACCOUNT (AccountNbr) ON DELETE SET NULL,
FOREIGN KEY(TxCode) REFERENCES TX_TYPE (TxCode) ON DELETE SET NULL
);
Insert into table
INSERT INTO TRANSACTION VALUES(TxNbr_Seq.nextval, 'X', 1000001, 123.45, TO_DATE('2019/05/01 12:00', 'yyyy/mm/dd hh24:mi'), '101');
Select
SELECT Transaction.TxDateTime,
FROM TRANSACTION
The output only gives the date: 19-05-01, but I would like the date and time in separate columns when I run a query.
Oracle doesn't have a time data type. You can convert to a string:
select to_char(TxDateTime, 'YYYY-MM-DD') as date, to_char(TxDateTime, 'HH24:MI:SS')
from transaction t;

Retrieve values in same row of SQL table

I have a table that I use to calculate prices that looks up a table to get price per part and then multiplies that by number of parts ordered.
This number of parts ordered is in the same table however, and I can't seem to find a way to access values in the same row in a table when adding values.
Is this not possible, or are there better ways of doing this?
Here is the table this concerns:
CREATE TABLE PartOrder
(
OrderID INTEGER NOT NULL,
CustomerID INTEGER NOT NULL,
PartID INTEGER NOT NULL,
NumParts INTEGER NOT NULL,
Status CHAR(1) NOT NULL
CHECK (Status IN ('R', 'H',
'E', 'C')
OrderTime TIMESTAMP NOT NULL,
TotalCost DECIMAL,
CONSTRAINT partOrder_pk PRIMARY KEY (OrderID),
CONSTRAINT partOrder_fk1 FOREIGN KEY (CustomerID) REFERENCES Customer ON DELETE CASCADE,
CONSTRAINT partOrder_fk2 FOREIGN KEY (FlightID) REFERENCES Part ON DELETE CASCADE
);
I want it so that it will take the numParts value and multiply it by the price per part referenced in the parts table. however, I can't stick to hard values as the number ordered may change later, meaning that the totalPrice will change.
At the moment my insert statement is just:
INSERT INTO PartOrder VALUES (001, 001, 001, 4, 'R', NOW(), (4*(SELECT PricePerPart FROM Part WHERE PartID = 001)));
You might want to give a look at SELECT INTO as well
http://www.postgresql.org/docs/8.1/static/sql-selectinto.html
Or You can go with
INSERT INTO PartOrder
SELECT (1,1,1,4,'R',NOW, (4*Part.PricePerPart))
FROM Part
WHERE Part.PartId = 1;