3

I have this MySQL table:

CREATE TABLE bills
(
    id_interess     INT UNSIGNED    NOT NULL,
    id_bill         VARCHAR(30)     NULL,
    PRIMARY KEY (id_interess)
) ENGINE=InnoDB;

And now I want to be able to manually insert unique integer for id_interess and automatically generate id_bill so that it consists of a current date and an integer (integer resets on a new year using trigger) like this:

id_interess |id_bill    |
------------+-----------+
1           |20170912-1 |
2           |20171030-2 |
6           |20171125-3 |
10          |20171231-4 |
200         |20180101-1 |
3           |20180101-2 |
8           |20180102-3 | 

If anyone has direct solution to this using only one query, I would be very glad! I only came up with a solution that uses three queries, but I still get some errors...

My newbie attempt: I created an additional column id_bill_tmp which holds integer part of id_bill like this:

CREATE TABLE bill
(
    id_interess     INT UNSIGNED    NOT NULL,   
    id_bill_tmp     INT UNSIGNED    NULL,
    id_bill         VARCHAR(30)     NULL,
    PRIMARY KEY (id_interess)
) ENGINE=InnoDB;

Table from above would in this case look like this (note that on new year id_bill_tmp is reset to 1 and therefore I can't use AUTO_INCREMENT which can only be used on keys and keys need unique values in a column):

id_interess |id_bill_tmp   |id_bill    |
------------+--------------+-----------+
1           |1             |20170912-1 |
2           |2             |20171030-2 |
6           |3             |20171125-3 |
10          |4             |20171231-4 |
200         |1             |20180101-1 |
3           |2             |20180101-2 |
6           |3             |20180102-3 | 

So for example to insert 1st row from the above table, table would have to be empty, and I would insert a value in three queries like this:

1st query:

INSERT INTO racuni (id_interess) VALUES (1);

I do this first because I don't know how to increment a nonexistent value for id_bill_tmp and this helped me to first get id_bill_tmp = NULL:

id_interess |id_bill_tmp   |id_bill    |
------------+--------------+-----------+
1           |[NULL]        |[NULL]     |

2nd query

Now I try to increment id_bill_tmp to become 1 - I tried two queries both fail saying:

table is specified twice both as a target for 'update' and as a separate source for data

This are the queries I tried:

UPDATE bills
SET id_bill_tmp = (SELECT IFNULL(id_bill_tmp, 0)+1 AS id_bill_tmp FROM bills)
WHERE id_interess = 1;

UPDATE bills
SET id_bill_tmp = (SELECT max(id_bill_tmp)+1 FROM bills)
WHERE id_interess = 1;

3rd query:

The final step would be to reuse id_bill_tmp as integer part of id_bill like this:

UPDATE bills
SET id_bill = concat(curdate()+0,'-',id_bill_tmp)
WHERE id_interess = 1;

so that I finally get

id_interess |id_bill_tmp   |id_bill    |
------------+--------------+-----------+
1           |1             |20170912-1 |

So if anyone can help me with the 2nd query or even present a solution with a single query or even without using column id_bill_tmp it would be wonderful.

Steve Chambers
  • 37,270
  • 24
  • 156
  • 208
71GA
  • 1,132
  • 6
  • 36
  • 69
  • 2
    I don't really see the point of storing derived data – Strawberry Sep 19 '17 at 21:37
  • @Strawberry `id_bill`are the bill numbers which need to be stored in order to check them out later. – 71GA Sep 20 '17 at 05:40
  • I suspect that your entire approach is back-to-front. Can you find a tutorial for constructing something similar, with accounts and invoices, and adapt it to your needs? – Strawberry Sep 20 '17 at 05:56
  • @Strawberry Couldn't find anything similar. I don't even know where to look... I am very inexperienced using MySQL. – 71GA Sep 20 '17 at 05:59
  • Unclear what you are asking -- Do you need to retrofit the values into existing rows? Do you need a Stored Function that will create a new id_bill on the fly? Maybe you need both? Maybe something else? – Rick James Sep 22 '17 at 19:30
  • Is there any possibility of more than one record per `curdate()` ? or there will be just one entry per `curdate()` – M Khalid Junaid Sep 22 '17 at 20:30
  • @MKhalidJunaid there might be more entries on a same date so `curdate()` might be the same in multiple cases. – 71GA Sep 22 '17 at 21:26

6 Answers6

2

If you are certain to be inserting in chronological order, then this will both bump the number and eliminate the need for the annual trigger:

DROP FUNCTION fcn46309431;
DELIMITER //
CREATE FUNCTION fcn46309431 (_max VARCHAR(22))
    RETURNS VARCHAR(22)
    DETERMINISTIC
    SQL SECURITY INVOKER
BEGIN
    RETURN
        CONCAT(DATE_FORMAT(CURDATE(), "%Y%m%d"), '-',
            IF( LEFT(_max, 4) = YEAR(CURDATE()),
                  SUBSTRING_INDEX(_max, '-', -1) + 1,
                  1 ) );
END                  
//
DELIMITER ;

INSERT INTO se46309431 (id_interess, id_bill)
    SELECT 149, fcn46309431(MAX(id_bill)) FROM se46309431;

SELECT * FROM se46309431;

(If you might insert out of date order, then the MAX(..) can mess up.)

Rick James
  • 135,179
  • 13
  • 127
  • 222
  • I probably won't insert data in table `bills` in chronological order. Or should I say I don't want to be forced to do that because i am sure I wight mess it up at some point. – 71GA Sep 22 '17 at 20:39
  • 1
    @71GA You've accepted an answer which is also using `MAX(id_bill)` and thus has the same issue. So this solution works as good, but has a simpler insert statement. – Paul Spiegel Sep 23 '17 at 17:19
  • I think that is impossible?? Since any newly-inserted bill goes in with _today's_ date, they can't get out of order?? – Rick James Sep 23 '17 at 20:12
  • But if it is possible, then something like `WHERE id_bill < CONCAT('', YEAR(CURDATE()+1)` may help. (Sorry, that would mess up the `INSERT`.) – Rick James Sep 23 '17 at 20:13
2

Solution #1 - with the extra column

Demo

http://rextester.com/GOTPA70741

SQL

INSERT INTO bills (id_interess, id_bill_tmp, id_bill) VALUES (
    1, -- (Change this value appropriately for each insert)
    IF(LEFT((SELECT id_bill FROM 
             (SELECT MAX(CONCAT(LEFT(id_bill, 8),
                                LPAD(SUBSTR(id_bill, 10), 10, 0))) AS id_bill
              FROM bills) b1), 4) = DATE_FORMAT(CURDATE(),'%Y'), 
       IFNULL(
           (SELECT id_bill_tmp
            FROM (SELECT id_bill_tmp
                  FROM bills
                  WHERE CONCAT(LEFT(id_bill, 8),
                               LPAD(SUBSTR(id_bill, 10), 10, 0)) =
                        (SELECT MAX(CONCAT(LEFT(id_bill, 8),
                                           LPAD(SUBSTR(id_bill, 10), 10, 0)))
                         FROM bills)) b2),
           0),
       0)
       + 1,
    CONCAT(DATE_FORMAT(CURDATE(),'%Y%m%d'), '-' , id_bill_tmp));

Notes

The query looks slightly more complicated that it actually is because of the issue that MySQL won't let you directly use a subselect from the same table that's being inserted into. This is circumvented using the method of wrapping another subselect around it as described here.

Solution #2 - without the extra column

Demo

http://rextester.com/IYES40010

SQL

INSERT INTO bills (id_interess, id_bill) VALUES (
    1, -- (Change this value appropriately for each insert)
    CONCAT(DATE_FORMAT(CURDATE(),'%Y%m%d'),
           '-' ,
           IF(LEFT((SELECT id_bill
                    FROM (SELECT MAX(CONCAT(LEFT(id_bill, 8),
                                            LPAD(SUBSTR(id_bill, 10), 10, 0))) AS id_bill
                          FROM bills) b1), 4) = DATE_FORMAT(CURDATE(),'%Y'), 
              IFNULL(
                  (SELECT id_bill_tmp
                   FROM (SELECT SUBSTR(MAX(CONCAT(LEFT(id_bill, 8),
                                                  LPAD(SUBSTR(id_bill, 10), 10, 0))), 9)
                                AS id_bill_tmp
                         FROM bills) b2),
                  0),
              0)
              + 1));

Notes

This is along the same lines as above but gets the numeric value that would have been in id_bill_tmp by extracting from the right part of id_bill from the 10th character position onwards via SUBSTR(id_bill, 10).

Step by step breakdown

  1. CONCAT(...) assembles the string by concatenating its parts together.
  2. DATE_FORMAT(CURDATE(),'%Y%m%d') formats the current date as yyyymmdd (e.g. 20170923).
  3. The IF(..., <x>, <y>) is used to check whether the most recent date that is already present is for the current year: If it is then the numeric part should continue by incrementing the sequence, otherwise it is reset to 1.
  4. LEFT(<date>, 4) gets the year from the most recent date - by extracting from the first 4 characters of id_bill.
  5. SELECT MAX(...) AS id_bill FROM bills gets the most recent date + sequence number from id_bill and gives this an alias of id_bill. (See the notes above about why the subquery also needs to be given an alias (b1) and then wrapped in another SELECT). See the two steps below for how a string is constructed such that MAX can be used for the ordering.
  6. CONCAT(LEFT(id_bill, 8), ...) is constructing a string that can be used for the above ordering by combining the date part with the sequence number padded with zeros. E.g. 201709230000000001.
  7. LPAD(SUBSTR(id_bill, 10), 10, 0) pads the sequence number with zeros (e.g. 0000000001 so that MAX can be used for the ordering. (See the comment by Paul Spiegel to understand why this needs to be done - e.g. so that sequence number 10 is ordered just after 9 rather than just after 1).
  8. DATE_FORMAT(CURDATE(),'%Y') formats the current date as a year (e.g. 2017) for the IF comparison mentioned in (3) above.
  9. IFNULL(<x>, <y>) is used for the very first row since no existing row will be found so the result will be NULL. In this case the numeric part should begin at 1.
  10. SELECT SUBSTR(MAX(...), 9) AS id_bill_tmp FROM bills selects the most recent date + sequence number from id_bill (as described above) and then extracts its sequence number, which is always from character position 9 onwards. Again, this subquery needs to be aliased (b2) and wrapped in another SELECT.
  11. + 1 increments the sequence number. (Note that this is always done since 0 is used in the cases described above where the sequence number should be set to 1).
Steve Chambers
  • 37,270
  • 24
  • 156
  • 208
  • After taking my time to rethink all the examples, I think this that yours is the best solution. It does exactly what I asked for. Is it possible to breakdown and explain the query in **Solution #2** step by step? – 71GA Sep 23 '17 at 15:48
  • 1
    Glad it helps - have now added a step by step breakdown for Solution #2 (+ made a minor simplification). – Steve Chambers Sep 23 '17 at 16:27
  • 1
    Note that `id_bill` is a string and `'20170923-2' > '20170923-10'`. So `MAX(id_bill)` might not work as you expect. – Paul Spiegel Sep 23 '17 at 18:10
  • @PaulSpiegel So is there a fix to this? – 71GA Sep 23 '17 at 18:32
  • @PaulSpiegel Good call - when I get a chance I'll update the answer to extract the number and pad with zeros when finding the max. – Steve Chambers Sep 23 '17 at 19:16
  • 1
    Please see the updated answer. Have added further inserts to the demos to test this works beyond 10. – Steve Chambers Sep 24 '17 at 07:56
  • 1
    Thank you @SteveChambers. You deserve the reward. – 71GA Sep 24 '17 at 21:58
1

A similar solution is shown here: https://www.percona.com/blog/2008/04/02/stored-function-to-generate-sequences/

What you could do is to create a sequence with table, as shown there:

delimiter // create function seq(seq_name char (20)) returns int begin update seq set val=last_insert_id(val+1) where name=seq_name; return last_insert_id(); end // delimiter ; CREATE TABLE `seq` ( `name` varchar(20) NOT NULL, `val` int(10) unsigned NOT NULL, PRIMARY KEY (`name`) )

Then you need to populate the sequence values for each year, like so: insert into seq values('2017',1); insert into seq values('2018',1); insert into seq values('2019',1); ... (only need to do this once)

Finally, this should work: insert into bills (id_interess, id_bill) select 123, concat(date_format(now(), '%Y%m%d-'), seq(date_format(now(), '%Y')));

Just replace 123 with some real/unique/dynamic id and you should be good to go.

mindas
  • 26,463
  • 15
  • 97
  • 154
1

I think you should redesign your approach to make life easier. I would design your table as follows:

id_interess |id_counter    |id_bill    |  
------------+--------------+-----------+  
1           |1             |20170912   |
2           |2             |20171231   |
3           |1             |20180101   |

Your desired output for the first row would be "20170912-1", but you would merge id_counter and id_bill in your SQL-Query or in your application logic, not directly in a table (here is why).

Now you can write your SQL-Statements for that table.

Furthermore, I would advise not to store the counter in the table. You should only read the records' id and date from your database and calculate the id_counter in your application (or even in your SQL-Query).

You could also declare your column id_counter as auto_increment and reset it each time, see here.

Rapwnzel
  • 272
  • 2
  • 9
  • How would you reset the `id_counter` on new year? – 71GA Sep 22 '17 at 19:56
  • You could run a script every time you insert a row in the table that checks for the last counter on the current year and increments it; or starts from 1 again if there is none. But as I said, the much better solution is to not store it at all in your table. – Rapwnzel Sep 22 '17 at 20:37
1

One approach to do in single query would be just save the date in your table when ever you update any record. For id_bill no., generate a sequence when you want to display the records.

Schema

CREATE TABLE IF NOT EXISTS `bill` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
  `bill_date` date NULL
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

Query

select a.id,concat(DATE_FORMAT(a.bill_date,"%Y%m%d"),'-',a.no) id_bill
from(    
    select b.*,count(b2.bill_date) no
    from bill b
    join bill b2 ON (EXTRACT(YEAR FROM b.bill_date) = EXTRACT(YEAR FROM b2.bill_date) 
      and b.bill_date >= b2.bill_date)
    group by b.id
    order by b.bill_date,no    
) a

Inner query will return you the rank of each record per year by joining the same table outer query just format the data as per your desired view

DEMO

If for same date there can be more than 1 entries then in inner query the id column which is set to auto_increment can be used to handle this case

Updated Query

select a.id,concat(DATE_FORMAT(a.bill_date,"%Y%m%d"),'-',a.no) id_bill
from(
    select b.*,count(b2.bill_date) no
    from bill b
    join bill b2 ON (EXTRACT(YEAR FROM b.bill_date) = EXTRACT(YEAR FROM b2.bill_date) 
      and b.id >= b2.id)
    group by b.id
    order by b.bill_date,no
) a

Updated Demo

M Khalid Junaid
  • 63,861
  • 10
  • 90
  • 118
1

The following solution requires generated (virtual) columns (available in MySQL 5.7 and MariaDB).

CREATE TABLE bills (
    id_interess   INT UNSIGNED    NOT NULL,
    bill_dt       DATETIME DEFAULT CURRENT_TIMESTAMP,
    bill_year     YEAR AS (year(bill_dt)),
    year_position INT UNSIGNED    NULL,
    id_bill       VARCHAR(30) AS (concat(date_format(bill_dt, '%Y%m%d-'), year_position)),
    PRIMARY KEY (id_interess),
    INDEX (bill_year, year_position)
) ENGINE=InnoDB;

bill_year and id_bill are not stored in the table. They are derived from other columns. However - bill_year is stored in the index, which we need to get the last position for a specific year efficiently (it would also work without the index).

To insert a new row with the current timestamp:

insert into bills(id_interess, year_position) 
    select 1, coalesce(max(year_position), 0) + 1
    from bills
    where bill_year = year(now());

You can also use a custom timestamp or date:

insert into bills(id_interess, bill_dt, year_position) 
    select 10, '2016-01-01', coalesce(max(year_position), 0) + 1
    from bills
    where bill_year = year('2016-01-01')

Demo: https://www.db-fiddle.com/f/8pFKQb93LqNPNaD5UhzVwu/0

To get even simpler inserts, you can create a trigger which will calculate year_postion:

CREATE TRIGGER bills_after_insert BEFORE INSERT ON bills FOR EACH ROW
    SET new.year_position = (
        SELECT coalesce(max(year_position), 0) + 1
        FROM bills
        WHERE bill_year = year(coalesce(new.bill_dt, now()))
    );

Now your insert statement would look like:

insert into bills(id_interess) values (1);

or

insert into bills(id_interess, bill_dt) values (11, '2016-02-02');

And the select statements:

select id_interess, id_bill
from bills
order by id_bill;

Demo: https://www.db-fiddle.com/f/55yqMh4E1tVxbpt9HXnBaS/0

Update

If you really, really need to keep your schema, you can try the following insert statement:

insert into bills(id_interess, id_bill)
    select
        @id_interess, 
        concat(
            date_format(@date, '%Y%m%d-'),
            coalesce(max(substr(id_bill, 10) + 1), 1)
        )
    from bills 
    where id_bill like concat(year(@date), '%');

Replace @id_interess and @date accordingly. For @date you can use CURDATE() but also any other date you want. There is no issue inserting dates out of order. You can even insert dates from 2016 when entries for 2017 already exist.

Demo: http://rextester.com/BXK47791

The LIKE condition in the WHERE clause can use an index on id_bill (if you define it), so the query only need to read the entries from the same year. But there is no way to determine the last counter value efficiently with this schema. The engine will need to read all rows for the cpecified year, extract the counter and search for the MAX value. Beside the complexity of the insert statement, this is one more reason to change the schema.

Paul Spiegel
  • 30,925
  • 5
  • 44
  • 53