15

I have a unique column in database which is named ip

IP addresses are stored in this column as BINARY(16) (with no collation) after converting them using the PHP function

$store_ip = inet_pton($ip);

When I try to insert the same IP twice it works fine and fails because it is unique,

But when I try to select the IP it doesn't work and always returns FALSE (not found)

<?php

try {
    $ip = inet_pton($_SERVER['REMOTE_ADDR']);
    $stmt = $db->prepare("SELECT * FROM `votes` WHERE ip=?");
    $stmt->execute([$ip]);
    $get = $stmt->fetch();

    if( ! $get){
        echo 'Not found';
    }else{
        echo 'Found';
    }

    // close connection
    $get = null;
    $stmt = null;

} catch (PDOException $e) {
    error_log($e->getMessage());
}

The part where I insert the IP:

<?php

if( ! filter_var($ip, FILTER_VALIDATE_IP)){
        return FALSE;
}

$ip = inet_pton($_SERVER['REMOTE_ADDR']);

try {
    $stmt = $db->prepare("INSERT INTO votes(ip, answer) VALUES(?,?)");
    $stmt->execute([$ip, $answer]);
    $stmt = null;
} catch (PDOException $e) {
    return FALSE;
}
J. Doe
  • 812
  • 1
  • 15
  • 33
  • Possible duplicate of [SQL Query with binary data (PHP and MySQL)](https://stackoverflow.com/questions/14505673/sql-query-with-binary-data-php-and-mysql) – M. Eriksson Mar 25 '19 at 22:32
  • 2
    @MagnusEriksson it's not. The guy who asked that question has much worse problems. – Sammitch Mar 26 '19 at 00:38
  • Assuming you've inserted the IP exactly as you've stated, this should work. That said, why don't you show us the code that inserts the IP so we don't have to assume? – Sammitch Mar 26 '19 at 00:39
  • @Sammitch Thank you for the reply, I've added the part you asked. After working hours on it with no success, I finally gave up and used `VARCHAR(45)` instead and put IPs in there without inet_pton(), I don't know how much better performance` BINARY(16)` would have against `VARCHAR(45)` but I'm still eager to know what was the problem here :( – J. Doe Mar 26 '19 at 12:46
  • 4
    @J.Doe I also have this exact problem... will add a bounty to know more – Vladimir Mar 30 '19 at 17:13
  • 1
    why not you use ip2long and long2ip. it will help you in future if you want some range search as well.Then you can link some ip to location database as well. It will help you grouping on locations etc. Because some time there can be multiple IPs of same location – Pontus Carme Mar 30 '19 at 18:02
  • @PontusCarme AFAIK `ip2long` is suitable for IPv4 only (not v6) – J. Doe Mar 30 '19 at 19:48
  • @Vladimir Thank you – J. Doe Mar 30 '19 at 19:49
  • 2
    With MySQL supporting `INET6_ATON` and `INET6_NTOA`. Why not use MySQL to manage the values instead of PHP? http://www.sqlfiddle.com/#!9/268d96/1 You can avoid `HEX`/`UNHEX` by using `VARBINARY(16)` as well http://www.sqlfiddle.com/#!9/983762/3 – Will B. Mar 31 '19 at 18:32
  • 1
    @fyrye Thank you, I didn't know that's possible... using `VARCHAR(250)` is not good because I could already use `VARCHAR(45)` for raw IPs and tried to use `BINARY(16)` for better performance. but the second link looks awesome, have to try it – J. Doe Mar 31 '19 at 19:33
  • Yea, I just used `VARCHAR(250)` for the demo, not representative of production settings when using `HEX` for the human readable format. `HEX` could be limited to 32 characters, given the same size IPV6 addresses, or more/less depending on the IPV6 notation your application uses. – Will B. Mar 31 '19 at 20:03

3 Answers3

18

First the fix, which is quite simple: If you want to store both, IPv4 and IPv6 addresses, you should use VARBINARY(16) instead of BINARY(16).

Now to the problem: Why doesn't it work as expected with BINARY(16)?

Consider we have a table ips with only one column ip BINARY(16) PRIMARY KEY. We store the default local IPv4 address with

$stmt = $db->prepare("INSERT INTO ips(ip) VALUES(?)");
$stmt->execute([inet_pton('127.0.0.1')]);

and find the following value in the database:

0x7F000001000000000000000000000000

As you see - It's a 4 byte binary value (0x7F000001) right-padded with zeros to fit the 16 byte fixed-length column.

When you now try to find it with

$stmt = $db->prepare("SELECT * FROM ips WHERE ip = ?");
$stmt->execute([inet_pton('127.0.0.1')]);

the following happens: PHP sends the value 0x7F000001 as parameter which is then compared with the stored value 0x7F000001000000000000000000000000. But since two binary values of different length are never equal, the WHERE condition will always return FALSE. You can try it with

SELECT 0x00 = 0x0000

which will return 0 (FALSE).

Note: The behavior is different for fixed length non binary strings (CHAR(N)).

We could use explicit casting as a workaround:

$stmt = $db->prepare("SELECT * FROM ips WHERE ip = CAST(? as BINARY(16))");
$stmt->execute([inet_pton('127.0.0.1')]);

and it will find the row. But if we look at what we get

var_dump(inet_ntop($stmt->fetch(PDO::FETCH_OBJ)->ip));

we will see

string(8) "7f00:1::"

But that is not (really) what we have tried to store. And when we now try to store 7f00:1::, we will get a duplicate key error, though we have never stored any IPv6 address yet.

So once again: Use VARBINARY(16), and you can keep your code untouched. You will even save some storage, if you store many IPv4 addresses.

Paul Spiegel
  • 30,925
  • 5
  • 44
  • 53
  • Thanks, I'll try that tonight, got the idea of `BINARY(16)` from this guy: https://stackoverflow.com/a/6427883/11210517 – J. Doe Mar 31 '19 at 00:04
  • OP doesn't use `inet_ntop` to convert from stored DB value to IP address, so your point is invalid. Suggestion about using `VARBINARY` is good though. – Styx Mar 31 '19 at 16:20
  • @Styx I don't think it makes my point invalid. But I would agree, if you say that it's not directly addressing OP's problem. So I I've extended my answer, describing why the query doesn't return any row. Thanks for your comment. – Paul Spiegel Mar 31 '19 at 17:05
  • Yes, now its clear why OP's code doesn't work as expected. Thanks. – Styx Apr 01 '19 at 05:57
  • Since when is `0x00` (=0) different from `0x0000` (=0)? (Just tested, it is indeed different. Which is very suprising. Because `0` = `00` = `000`) – knittl Apr 01 '19 at 06:14
  • @knittl They aren't compared by value, but as binary buffers. They have different lengths, thus different. – Styx Apr 01 '19 at 06:43
  • @knittl `0x00 = 0x0000` is better compared with `'0' = '00'` rather than with `0 = 00`. – Paul Spiegel Apr 01 '19 at 14:39
  • Why not str_pad('0x7F000001', 33, "0", STR_PAD_RIGHT); it? – Elanochecer Apr 03 '19 at 11:35
  • @Elanochecer - a) because there is a better solution. b) because after doing so, you don't know, if you have stored a v4 or v6 address (since they all would look like IPv6). See my workaround with casting, which is doing the same. – Paul Spiegel Apr 03 '19 at 13:34
  • @PaulSpiegel I know it's a better solution. I was just adding another posibility for the sake of completion, i haven't seen it on the answers nor in your options and your post is the more complete one. You can add it if you find it useful. – Elanochecer Apr 03 '19 at 13:54
  • @Elanochecer As I wrote - `CAST(? as BINARY(16))` is a similar solution, but that converts an IPv4 to IPv6 address - and noone want's that. – Paul Spiegel Apr 03 '19 at 14:03
5

I will not answer why your code didn't work as expected because I don't know exactly. Thanks for @Paul Spiegel great answer, he explained why.

In this answer I just suggest you use the MySQL built-in functions instead of PHP.

This is how I handle IPs in my applications and I'm having no trouble until now with this model.

I store IPs in varbinary(16) column, and doing the conversions using MySQL built-in functions

  1. inet6_aton for converting IP strings to binary

  2. inet6_ntoa for converting binary to IP strings

So replace this code

//query 1 
$ip = inet_pton($_SERVER['REMOTE_ADDR']);
$stmt = $db->prepare("SELECT * FROM `votes` WHERE ip=?");
$stmt->execute([$ip]);

with this one

//query 2 
$stmt = $db->prepare("SELECT * FROM `votes` WHERE ip=INET6_ATON(?)");
$stmt->execute([$_SERVER['REMOTE_ADDR']]);

Needless to say DON'T do it like this (query 3)-

//query 3 
$stmt = $db->prepare("SELECT * FROM `votes` WHERE INET6_NTOA(ip)= ?");
$stmt->execute([$_SERVER['REMOTE_ADDR']]);

(because the Database will hate you for making it do the conversion for every IP record in the table)

From my short experience, I found that whenever I have the chance to let the database do something instead of the application layer(PHP), let the database do it immediately. Make MySQL fat and PHP skinny =) , like the fat model and skinny controller say.

When You make most of your work inside the database, that will make your database can work better independent from the PHP code (it doesn't need it), which makes your database more portable.

For example if you wanted to turn your system from an on-cloud web based one that uses PHP, to an on-premise one that uses .net languages, the .net developers will love you for making them code less because most of the work is already written and done by MySQL.

Another example, your application succeed and you are now having more clients that want your application, you will just get them another server and install MySQL only on it, and because most of the work is done by the database, your application scaled easier than installing a complete web server and do the scaling also for the PHP.

Accountant م
  • 6,975
  • 3
  • 41
  • 61
  • 1
    "whenever I have the chance to let the database do something instead of the application layer" - You can do almost anything in the DB using stored procedures, and only use PHP for routing. But if you convert an IP address in PHP or in SQL - Who cares? The only difference - If you do it in PHP, you save some traffic. And the code is **one byte shorter** :-) – Paul Spiegel Apr 01 '19 at 13:01
  • Yes, the only reason that will make me prefer doing things in PHP is saving traffic between the Database server and the Web server, thanks for noting this. – Accountant م Apr 01 '19 at 13:38
4

Instead of struggling over escaping BINARY, let's avoid that.

INSERT INTO ips (ip) VALUES(INET6_ATON(?))

and

SELECT INET6_NTOA(ip) FROM ips WHERE ...;

That way, you are working only with human-readable strings.

Notes:

  • Skip the use of inet_pton() in PHP, since the conversion is now being done in MySQL.
  • The INET6... functions do not exist in old versions of MySQL.
  • Yes, do use VARBINARY(16), and be sure to check whether IPv4 strings (like "1.2.3.4") will work.
Rick James
  • 135,179
  • 13
  • 127
  • 222
  • Hello Rick, what do you mean by *"check whether IPv4 strings (like "1.2.3.4") will work."* . is there a chance that IPv4 won't work this way ? – Accountant م Apr 01 '19 at 17:14
  • 1
    To clarify, the [`INET6_`](https://dev.mysql.com/doc/refman/5.6/en/miscellaneous-functions.html#function_inet6-aton) functions, that became available in MySQL 5.6+, support notation strings of both IPV4 and IPV6. The original `INET_` functions support notation strings of only IPV4. Additionally there are functions available to validate an IP notation string by using `IS_IPV4()` or `IS_IPV6()`. MySQL will emit a warning during insertion, when `INET6_ATON` fails to parse the supplied value, resulting in a `NULL` value. – Will B. Apr 01 '19 at 18:13
  • Note that `INET6_NTOA` neither works for IPv4 addresses with `BINARY(16)`? You need to use `VARBINARY`. – Paul Spiegel Apr 03 '19 at 13:57
  • 1
    I also want to know if there is a chance that IPv4 won't work this way because of your last note: _be sure to check whether IPv4 strings (like "1.2.3.4") will work_ – J. Doe Apr 06 '19 at 10:59
  • @J.Doe - give it a try and report back. – Rick James Apr 09 '19 at 18:14