From 983a7ec215d46b6d02935d1baa8bbe07fc371795 Mon Sep 17 00:00:00 2001
From: Aleksandr Lyapunov <alyapunov@tarantool.org>
Date: Thu, 3 Aug 2023 19:06:37 +0300
Subject: [PATCH] box: loose truncate check in case of foreign key

In #7309 a truncation of a space that was referenced by foreign
key from some other space was prohibited.

It appeared that this solution is too bothering since a user can't
truncate a space even if he truncated referring space before that.

Fix it by allowing space truncate if referring spaces are empty.
Also allow drop of the primary index in the same case with the
same reason: logically the index along with all space data is
not needed for consistency if there's no referring data.

Note that by design space truncate is implemented quite similar
to space drop. Both delete all indexes, from secondary to primary.
Since this patch allows deletion of the primary index (which is
the action that actually deletes all data from the space), this
patch changes the result of space drop too: the space remains
alive with no indexes, while before this patch it remained alive
with no secondary indexes but with present primary. In both cases
the behaviour is quite strange and must be fixed in #4348. To
make tests pass I had to perform drop in box.atomic manually.

Closes #8946

NO_DOC=bugfix
---
 .../gh-8946-foreign-key-disables-truncate.md  |   4 +
 src/box/alter.cc                              |  45 +++++-
 src/box/space_cache.h                         |   2 +-
 .../gh_6436_complex_foreign_key_test.lua      |   6 +-
 .../gh_6436_field_foreign_key_test.lua        |   6 +-
 ...946_foreign_key_disables_truncate_test.lua | 131 ++++++++++++++++++
 test/sql/delete.result                        |   4 +
 test/sql/delete.test.lua                      |   1 +
 8 files changed, 188 insertions(+), 11 deletions(-)
 create mode 100644 changelogs/unreleased/gh-8946-foreign-key-disables-truncate.md
 create mode 100644 test/engine-luatest/gh_8946_foreign_key_disables_truncate_test.lua

diff --git a/changelogs/unreleased/gh-8946-foreign-key-disables-truncate.md b/changelogs/unreleased/gh-8946-foreign-key-disables-truncate.md
new file mode 100644
index 0000000000..92dbfc5f4a
--- /dev/null
+++ b/changelogs/unreleased/gh-8946-foreign-key-disables-truncate.md
@@ -0,0 +1,4 @@
+## bugfix/core
+
+* Fixed a bug when a space that is referenced by a foreign key could not
+  be truncated even if the referring space was empty (gh-8946).
diff --git a/src/box/alter.cc b/src/box/alter.cc
index 9aa186fc43..f8784da6ed 100644
--- a/src/box/alter.cc
+++ b/src/box/alter.cc
@@ -40,6 +40,7 @@
 #include "coll_id_def.h"
 #include "txn.h"
 #include "tuple.h"
+#include "tuple_constraint.h"
 #include "fiber.h" /* for gc_pool */
 #include "scoped_guard.h"
 #include <base64.h>
@@ -1951,6 +1952,42 @@ space_check_pinned(struct space *space)
 	return 0;
 }
 
+/**
+ * Check whether @a space holders prohibit truncate of the space.
+ * For example truncation in not allowed if another non-empty space refers
+ * to this space via foreign key link.
+ * Return 0 if allowed, or -1 if not allowed (diag is set).
+ */
+static int
+space_check_truncate(struct space *space)
+{
+	/* Check for foreign keys that refers to this space. */
+	struct space_cache_holder *h;
+	rlist_foreach_entry(h, &space->space_cache_pin_list, link) {
+		if (h->selfpin)
+			continue;
+		if (h->type != SPACE_HOLDER_FOREIGN_KEY)
+			continue;
+		struct tuple_constraint *constr =
+			container_of(h, struct tuple_constraint,
+				     space_cache_holder);
+		struct space *other_space = constr->space;
+		/*
+		 * If the referring space is empty then the truncate can't
+		 * break foreign key consistency.
+		 */
+		if (space_bsize(other_space) == 0)
+			continue;
+		const char *type_str =
+			space_cache_holder_type_strs[h->type];
+		diag_set(ClientError, ER_ALTER_SPACE,
+			 space_name(space),
+			 tt_sprintf("space is referenced by %s", type_str));
+		return -1;
+	}
+	return 0;
+}
+
 /**
  * A trigger which is invoked on replace in a data dictionary
  * space _space.
@@ -2404,11 +2441,10 @@ on_replace_dd_index(struct trigger * /* trigger */, void *event)
 				  "space sequence exists");
 			return -1;
 		}
-
 		/*
-		 * Must not truncate pinned space.
+		 * Check space's holders.
 		 */
-		if (space_check_pinned(old_space) != 0)
+		if (space_check_truncate(old_space) != 0)
 			return -1;
 	}
 
@@ -2667,7 +2703,8 @@ on_replace_dd_truncate(struct trigger * /* trigger */, void *event)
 		return -1;
 	}
 
-	if (space_check_pinned(old_space) != 0)
+	/* Check space's holders. */
+	if (space_check_truncate(old_space) != 0)
 		return -1;
 
 	struct alter_space *alter = alter_space_new(old_space);
diff --git a/src/box/space_cache.h b/src/box/space_cache.h
index 344f8359ec..97d64b52b1 100644
--- a/src/box/space_cache.h
+++ b/src/box/space_cache.h
@@ -176,7 +176,7 @@ space_cache_unpin(struct space_cache_holder *holder);
 /**
  * Check whether the @a space has holders or not.
  * If it has, @a type argument is set to the first holder's type.
- * The function must be in cache (asserted).
+ * The space must be in cache (asserted).
  * If a space has holders, it must not be deleted (asserted).
  */
 bool
diff --git a/test/engine-luatest/gh_6436_complex_foreign_key_test.lua b/test/engine-luatest/gh_6436_complex_foreign_key_test.lua
index e024a0882a..9a26e28516 100644
--- a/test/engine-luatest/gh_6436_complex_foreign_key_test.lua
+++ b/test/engine-luatest/gh_6436_complex_foreign_key_test.lua
@@ -109,7 +109,7 @@ g.test_complex_foreign_key_primary = function(cg)
         t.assert_equals(country:select{}, {{1, 11, 'Russia'}, {1, 12, 'France'}})
         t.assert_error_msg_content_equals(
             "Can't modify space 'country': space is referenced by foreign key",
-            function() country:drop() end
+            box.atomic, country.drop, country
         )
         t.assert_error_msg_content_equals(
             "Foreign key 'country' integrity check failed: wrong foreign field name",
@@ -198,7 +198,7 @@ g.test_complex_foreign_key_secondary = function(cg)
                                            {101, 1, 'earth', 'rf', 'France'}})
         t.assert_error_msg_content_equals(
             "Can't modify space 'country': space is referenced by foreign key",
-            function() country:drop() end
+            box.atomic, country.drop, country
         )
         t.assert_equals(country:select{}, {{100, 1, 'earth', 'ru', 'Russia'},
                                            {101, 1, 'earth', 'rf', 'France'}})
@@ -293,7 +293,7 @@ g.test_complex_foreign_key_numeric = function(cg)
                                            {101, 1, 'earth', 'rf', 'France'}})
         t.assert_error_msg_content_equals(
             "Can't modify space 'country': space is referenced by foreign key",
-            function() country:drop() end
+            box.atomic, country.drop, country
         )
         t.assert_equals(country:select{}, {{100, 1, 'earth', 'ru', 'Russia'},
                                            {101, 1, 'earth', 'rf', 'France'}})
diff --git a/test/engine-luatest/gh_6436_field_foreign_key_test.lua b/test/engine-luatest/gh_6436_field_foreign_key_test.lua
index 133d2ea0c3..bdb04acde4 100644
--- a/test/engine-luatest/gh_6436_field_foreign_key_test.lua
+++ b/test/engine-luatest/gh_6436_field_foreign_key_test.lua
@@ -137,7 +137,7 @@ g.test_foreign_key_primary = function(cg)
         t.assert_equals(country:select{}, {{1, 'ru', 'Russia'}, {2, 'fr', 'France'}})
         t.assert_error_msg_content_equals(
             "Can't modify space 'country': space is referenced by foreign key",
-            function() country:drop() end
+            box.atomic, country.drop, country
         )
         t.assert_equals(country:select{}, {{1, 'ru', 'Russia'}, {2, 'fr', 'France'}})
         t.assert_error_msg_content_equals(
@@ -215,7 +215,7 @@ g.test_foreign_key_secondary = function(cg)
         t.assert_equals(country:select{}, {{1, 'ru', 'Russia'}, {2, 'fr', 'France'}})
         t.assert_error_msg_content_equals(
             "Can't modify space 'country': space is referenced by foreign key",
-            function() country:drop() end
+            box.atomic, country.drop, country
         )
         t.assert_equals(country:select{}, {{1, 'ru', 'Russia'}, {2, 'fr', 'France'}})
         t.assert_error_msg_content_equals(
@@ -297,7 +297,7 @@ g.test_foreign_key_numeric = function(cg)
         t.assert_equals(country:select{}, {{1, 'ru', 'Russia'}, {2, 'fr', 'France'}})
         t.assert_error_msg_content_equals(
             "Can't modify space 'country': space is referenced by foreign key",
-            function() country:drop() end
+            box.atomic, country.drop, country
         )
         t.assert_equals(country:select{}, {{1, 'ru', 'Russia'}, {2, 'fr', 'France'}})
         t.assert_error_msg_content_equals(
diff --git a/test/engine-luatest/gh_8946_foreign_key_disables_truncate_test.lua b/test/engine-luatest/gh_8946_foreign_key_disables_truncate_test.lua
new file mode 100644
index 0000000000..17c6d3f2c0
--- /dev/null
+++ b/test/engine-luatest/gh_8946_foreign_key_disables_truncate_test.lua
@@ -0,0 +1,131 @@
+-- https://github.com/tarantool/tarantool/issues/8946
+-- Test that a space that another empty space refers to can be truncated.
+local server = require('luatest.server')
+local t = require('luatest')
+
+local engines = {{engine = 'memtx'}, {engine = 'vinyl'}}
+local g = t.group('gh-8946-foreign-key-truncate-test', engines)
+
+g.before_all(function(cg)
+    cg.server = server:new({alias = 'master'})
+    cg.server:start()
+end)
+
+g.after_all(function(cg)
+    cg.server:stop()
+    cg.server = nil
+end)
+
+g.after_each(function(cg)
+    cg.server:exec(function()
+        if box.space.client_phones then
+            box.space.client_phones:drop()
+        end
+        if box.space.client then
+            box.space.client:drop()
+        end
+    end)
+end)
+
+-- Field foreign key must not disable truncate if referring space is empty.
+g.test_field_foreign_key_truncate = function(cg)
+    local engine = cg.params.engine
+
+    cg.server:exec(function(engine)
+        box.schema.space.create('client', {engine = engine})
+        box.space.client:format({
+            {name = 'customer_id', type = 'string', is_nullable = false},
+            {name = 'esia_id', type = 'string', is_nullable = false},
+        })
+
+        box.space.client:create_index('pk_client_customer_id', {
+            parts = {{field = 'customer_id', collation = 'unicode'}},
+            type = 'tree', unique = true
+        })
+        box.space.client:create_index('idx_client_esia_id', {
+            parts = {{field = 'esia_id', collation = 'unicode'}},
+            type = 'tree', unique = false
+        })
+
+        box.schema.space.create('client_phones', {engine = engine})
+        box.space.client_phones:format({
+            {name = 'phone', type = 'string', is_nullable = false},
+            {name = 'customer_id',
+             foreign_key = {space = 'client', field = 'customer_id'}},
+        })
+
+        box.space.client_phones:create_index('idx_client_phones_phone', {
+            parts = {{field = 'phone', collation = 'unicode'}},
+            type = 'tree', unique = true
+        })
+
+        box.space.client:insert{'01','esia-01'}
+        box.space.client:insert{'02','esia-02'}
+
+        box.space.client_phones:insert{'9121234','01'}
+        box.space.client_phones:insert{'3222222','02'}
+
+        -- Now truncate is prohibited.
+        t.assert_error_msg_content_equals(
+            "Can't modify space 'client': space is referenced by foreign key",
+            box.space.client.truncate, box.space.client)
+
+        box.space.client_phones:truncate()
+
+        -- Now truncate is allowed.
+        box.space.client:truncate()
+    end, {engine})
+end
+
+-- Tuple foreign key must not disable truncate if referring space is empty.
+g.test_tuple_foreign_key_truncate = function(cg)
+    local engine = cg.params.engine
+
+    cg.server:exec(function(engine)
+        box.schema.space.create('client', {engine = engine})
+        box.space.client:format({
+            {name = 'customer_id', type = 'string', is_nullable = false},
+            {name = 'esia_id', type = 'string', is_nullable = false},
+        })
+
+        box.space.client:create_index('pk_client_customer_id', {
+            parts = {{field = 'customer_id', collation = 'unicode'}},
+            type = 'tree', unique = true
+        })
+        box.space.client:create_index('idx_client_esia_id', {
+            parts = {{field = 'esia_id', collation = 'unicode'}},
+            type = 'tree', unique = false
+        })
+
+        box.schema.space.create('client_phones', {
+            engine = engine,
+            foreign_key = {space = 'client',
+                           field = {customer_id = 'customer_id'}}
+        })
+        box.space.client_phones:format({
+            {name = 'phone', type = 'string', is_nullable = false},
+            {name = 'customer_id'},
+        })
+
+        box.space.client_phones:create_index('idx_client_phones_phone', {
+            parts = {{field = 'phone', collation = 'unicode'}},
+            type = 'tree', unique = true
+        })
+
+        box.space.client:insert{'01','esia-01'}
+        box.space.client:insert{'02','esia-02'}
+
+        box.space.client_phones:insert{'9121234','01'}
+        box.space.client_phones:insert{'3222222','02'}
+
+        -- Now truncate is prohibited.
+        t.assert_error_msg_content_equals(
+            "Can't modify space 'client': space is referenced by foreign key",
+            box.space.client.truncate, box.space.client)
+
+        box.space.client_phones:truncate()
+
+        -- Now truncate is allowed.
+        box.space.client:truncate()
+    end, {engine})
+end
diff --git a/test/sql/delete.result b/test/sql/delete.result
index 3c2ed1e2e9..3f3b8e5ef0 100644
--- a/test/sql/delete.result
+++ b/test/sql/delete.result
@@ -141,6 +141,10 @@ box.execute("CREATE TABLE t2(x INT PRIMARY KEY REFERENCES t1(id));")
 ---
 - row_count: 1
 ...
+box.execute("INSERT INTO t2 VALUES(1);")
+---
+- row_count: 1
+...
 box.execute("TRUNCATE TABLE t1;")
 ---
 - null
diff --git a/test/sql/delete.test.lua b/test/sql/delete.test.lua
index 82cf332232..8eab0b0aea 100644
--- a/test/sql/delete.test.lua
+++ b/test/sql/delete.test.lua
@@ -60,6 +60,7 @@ box.execute("TRUNCATE TABLE v1;")
 
 -- Can't truncate table with FK.
 box.execute("CREATE TABLE t2(x INT PRIMARY KEY REFERENCES t1(id));")
+box.execute("INSERT INTO t2 VALUES(1);")
 box.execute("TRUNCATE TABLE t1;")
 
 -- Table triggers should be ignored.
-- 
GitLab