diff --git a/composer.json b/composer.json index ee4de86f9e..aea8c4dda8 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "friendsofphp/proxy-manager-lts": "^1.0", "friendsofsymfony/http-cache-bundle": "^3.0", "friendsofsymfony/jsrouting-bundle": "^3.5", - "ibexa/doctrine-migrations": "~6.0.x-dev", + "ibexa/doctrine-migrations": "6.0.x-dev", "ibexa/doctrine-schema": "~6.0.x-dev", "ibexa/jms-translation-bundle": "^2.6.0", "league/flysystem-memory": "^2.0.6", diff --git a/data/mysql/cleandata.sql b/data/mysql/cleandata.sql index c0aae20d8a..89927a48d1 100644 --- a/data/mysql/cleandata.sql +++ b/data/mysql/cleandata.sql @@ -1,9 +1,9 @@ -INSERT INTO `ibexa_object_state` (`default_language_id`, `group_id`, `id`, `identifier`, `language_mask`, `priority`) -VALUES (2, 2, 1, 'not_locked', 3, 0), - (2, 2, 2, 'locked', 3, 1); +INSERT INTO `ibexa_object_state` (`default_language_id`, `group_id`, `id`, `identifier`, `priority`) +VALUES (2, 2, 1, 'not_locked', 0), + (2, 2, 2, 'locked', 1); -INSERT INTO `ibexa_object_state_group` (`default_language_id`, `id`, `identifier`, `language_mask`) -VALUES (2,2,'ibexa_lock',3); +INSERT INTO `ibexa_object_state_group` (`default_language_id`, `id`, `identifier`) +VALUES (2,2,'ibexa_lock'); INSERT INTO `ibexa_object_state_group_language` (`contentobject_state_group_id`, `description`, `language_id`, `name`, `real_language_id`) VALUES (2,'',3,'Lock',2); @@ -26,16 +26,16 @@ VALUES (1, 1), (50, 1), (51, 1); -INSERT INTO `ibexa_content_language` (`disabled`, `id`, `locale`, `name`) +INSERT INTO `ibexa_language` (`disabled`, `id`, `locale`, `name`) VALUES (0, 2, 'eng-GB', 'English (United Kingdom)'); -INSERT INTO `ibexa_content_type` (`always_available`, `contentobject_name`, `created`, `creator_id`, `id`, `identifier`, `initial_language_id`, `is_container`, `language_mask`, `modified`, `modifier_id`, `remote_id`, `serialized_description_list`, `serialized_name_list`, `sort_field`, `sort_order`, `url_alias_name`, `status`) -VALUES (1,'',1024392098,14,1,'folder',2,1,2,1448831672,14,'a3d405b81be900468eb153d774f4f0d2','a:0:{}','a:1:{s:6:\"eng-GB\";s:6:\"Folder\";}',1,1,NULL,0), - (0,'',1024392098,14,2,'article',2,1,3,1082454989,14,'c15b600eb9198b1924063b5a68758232',NULL,'a:2:{s:6:\"eng-GB\";s:7:\"Article\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0), - (1,'',1024392098,14,3,'user_group',2,1,3,1048494743,14,'25b4268cdcd01921b808a0d854b877ef',NULL,'a:2:{s:6:\"eng-GB\";s:10:\"User group\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0), - (1,' ',1024392098,14,4,'user',2,0,3,1082018364,14,'40faa822edc579b02c25f6bb7beec3ad',NULL,'a:2:{s:6:\"eng-GB\";s:4:\"User\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0), - (1,'',1031484992,14,5,'image',2,0,3,1048494784,14,'f6df12aa74e36230eb675f364fccd25a',NULL,'a:2:{s:6:\"eng-GB\";s:5:\"Image\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0), - (1,'',1052385472,14,12,'file',2,0,3,1052385669,14,'637d58bfddf164627bdfd265733280a0',NULL,'a:2:{s:6:\"eng-GB\";s:4:\"File\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0); +INSERT INTO `ibexa_content_type` (`always_available`, `contentobject_name`, `created`, `creator_id`, `id`, `identifier`, `initial_language_id`, `is_container`, `modified`, `modifier_id`, `remote_id`, `serialized_description_list`, `serialized_name_list`, `sort_field`, `sort_order`, `url_alias_name`, `status`) +VALUES (1,'',1024392098,14,1,'folder',2,1,1448831672,14,'a3d405b81be900468eb153d774f4f0d2','a:0:{}','a:1:{s:6:\"eng-GB\";s:6:\"Folder\";}',1,1,NULL,0), + (0,'',1024392098,14,2,'article',2,1,1082454989,14,'c15b600eb9198b1924063b5a68758232',NULL,'a:2:{s:6:\"eng-GB\";s:7:\"Article\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0), + (1,'',1024392098,14,3,'user_group',2,1,1048494743,14,'25b4268cdcd01921b808a0d854b877ef',NULL,'a:2:{s:6:\"eng-GB\";s:10:\"User group\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0), + (1,' ',1024392098,14,4,'user',2,0,1082018364,14,'40faa822edc579b02c25f6bb7beec3ad',NULL,'a:2:{s:6:\"eng-GB\";s:4:\"User\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0), + (1,'',1031484992,14,5,'image',2,0,1048494784,14,'f6df12aa74e36230eb675f364fccd25a',NULL,'a:2:{s:6:\"eng-GB\";s:5:\"Image\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0), + (1,'',1052385472,14,12,'file',2,0,1052385669,14,'637d58bfddf164627bdfd265733280a0',NULL,'a:2:{s:6:\"eng-GB\";s:4:\"File\";s:16:\"always-available\";s:6:\"eng-GB\";}',1,1,NULL,0); INSERT INTO `ibexa_content_type_field_definition` (`can_translate`, `category`, `content_type_id`, `data_float1`, `data_float2`, `data_float3`, `data_float4`, `data_int1`, `data_int2`, `data_int3`, `data_int4`, `data_text1`, `data_text2`, `data_text3`, `data_text4`, `data_text5`, `data_type_string`, `id`, `identifier`, `is_information_collector`, `is_required`, `is_searchable`, `is_thumbnail`, `placement`, `serialized_data_text`, `serialized_description_list`, `serialized_name_list`, `status`) VALUES (1,'',2,0,0,0,0,255,0,0,0,'New article','','','','','ibexa_string',1,'title',0,1,1,1,0,NULL,NULL,'a:2:{s:6:\"eng-GB\";s:5:\"Title\";s:16:\"always-available\";s:6:\"eng-GB\";}',0), @@ -84,19 +84,33 @@ VALUES (1031216928, 14, 1, 1033922106, 14, 'Content'), (1031216941, 14, 2, 1033922113, 14, 'Users'), (1032009743, 14, 3, 1033922120, 14, 'Media'); -INSERT INTO `ibexa_content` (`content_type_id`, `current_version`, `id`, `initial_language_id`, `language_mask`, `modified`, `name`, `owner_id`, `published`, `remote_id`, `section_id`, `status`) -VALUES (1,9,1,2,3,1448889046,'Ibexa Platform',14,1448889046,'9459d3c29e15006e45197295722c7ade',1,1), - (3,1,4,2,3,1033917596,'Users',14,1033917596,'f5c88a2209584891056f987fd965b0ba',2,1), - (4,2,10,2,3,1072180405,'Anonymous User',14,1033920665,'faaeb9be3bd98ed09f606fc16d144eca',2,1), - (3,1,11,2,3,1033920746,'Guest accounts',14,1033920746,'5f7f0bdb3381d6a461d8c29ff53d908f',2,1), - (3,1,12,2,3,1033920775,'Administrator users',14,1033920775,'9b47a45624b023b1a76c73b74d704acf',2,1), - (3,1,13,2,3,1033920794,'Editors',14,1033920794,'3c160cca19fb135f83bd02d911f04db2',2,1), - (4,3,14,2,3,1301062024,'Administrator User',14,1033920830,'1bb4fe25487f05527efa8bfd394cecc7',2,1), - (1,1,41,2,3,1060695457,'Media',14,1060695457,'a6e35cbcb7cd6ae4b691f3eee30cd262',3,1), - (3,1,42,2,3,1072180330,'Anonymous users',14,1072180330,'15b256dbea2ae72418ff5facc999e8f9',2,1), - (1,1,49,2,3,1080220197,'Images',14,1080220197,'e7ff633c6b8e0fd3531e74c6e712bead',3,1), - (1,1,50,2,3,1080220220,'Files',14,1080220220,'732a5acd01b51a6fe6eab448ad4138a9',3,1), - (1,1,51,2,3,1080220233,'Multimedia',14,1080220233,'09082deb98662a104f325aaa8c4933d3',3,1); +INSERT INTO `ibexa_content` (`content_type_id`, `current_version`, `id`, `initial_language_id`, `always_available`, `modified`, `name`, `owner_id`, `published`, `remote_id`, `section_id`, `status`) +VALUES (1,9,1,2,1,1448889046,'Ibexa Platform',14,1448889046,'9459d3c29e15006e45197295722c7ade',1,1), + (3,1,4,2,1,1033917596,'Users',14,1033917596,'f5c88a2209584891056f987fd965b0ba',2,1), + (4,2,10,2,1,1072180405,'Anonymous User',14,1033920665,'faaeb9be3bd98ed09f606fc16d144eca',2,1), + (3,1,11,2,1,1033920746,'Guest accounts',14,1033920746,'5f7f0bdb3381d6a461d8c29ff53d908f',2,1), + (3,1,12,2,1,1033920775,'Administrator users',14,1033920775,'9b47a45624b023b1a76c73b74d704acf',2,1), + (3,1,13,2,1,1033920794,'Editors',14,1033920794,'3c160cca19fb135f83bd02d911f04db2',2,1), + (4,3,14,2,1,1301062024,'Administrator User',14,1033920830,'1bb4fe25487f05527efa8bfd394cecc7',2,1), + (1,1,41,2,1,1060695457,'Media',14,1060695457,'a6e35cbcb7cd6ae4b691f3eee30cd262',3,1), + (3,1,42,2,1,1072180330,'Anonymous users',14,1072180330,'15b256dbea2ae72418ff5facc999e8f9',2,1), + (1,1,49,2,1,1080220197,'Images',14,1080220197,'e7ff633c6b8e0fd3531e74c6e712bead',3,1), + (1,1,50,2,1,1080220220,'Files',14,1080220220,'732a5acd01b51a6fe6eab448ad4138a9',3,1), + (1,1,51,2,1,1080220233,'Multimedia',14,1080220233,'09082deb98662a104f325aaa8c4933d3',3,1); + +INSERT INTO `ibexa_content_translation` (`content_id`, `language_id`) +VALUES (1,2), + (4,2), + (10,2), + (11,2), + (12,2), + (13,2), + (14,2), + (41,2), + (42,2), + (49,2), + (50,2), + (51,2); INSERT INTO `ibexa_content_field` (`attribute_original_id`, `content_type_field_definition_id`, `contentobject_id`, `data_float`, `data_int`, `data_text`, `data_type_string`, `id`, `language_code`, `language_id`, `sort_key_int`, `sort_key_string`, `version`) VALUES (0,4,1,NULL,NULL,'Ibexa Platform','ibexa_string',1,'eng-GB',3,0,'ibexa platform',9), @@ -169,19 +183,33 @@ VALUES (0,1,1,0,0,0,1,1448999778,1,1,'','/1/',0,'629709ba256fe317c3ddcee35453a96 (50,1,1,2,0,0,52,1081860720,52,43,'media/files','/1/43/52/',0,'0b113a208f7890f9ad3c24444ff5988c',9,1), (51,1,1,2,0,0,53,1081860720,53,43,'media/multimedia','/1/43/53/',0,'4f18b82c75f10aad476cae5adf98c11f',9,1); -INSERT INTO `ibexa_content_version` (`contentobject_id`, `created`, `creator_id`, `id`, `initial_language_id`, `language_mask`, `modified`, `status`, `user_id`, `version`, `workflow_event_pos`) -VALUES (4,0,14,4,2,3,0,1,0,1,1), - (11,1033920737,14,439,2,3,1033920746,1,0,1,0), - (12,1033920760,14,440,2,3,1033920775,1,0,1,0), - (13,1033920786,14,441,2,3,1033920794,1,0,1,0), - (41,1060695450,14,472,2,3,1060695457,1,0,1,0), - (42,1072180278,14,473,2,3,1072180330,1,0,1,0), - (10,1072180337,14,474,2,3,1072180405,1,0,2,0), - (49,1080220181,14,488,2,3,1080220197,1,0,1,0), - (50,1080220211,14,489,2,3,1080220220,1,0,1,0), - (51,1080220225,14,490,2,3,1080220233,1,0,1,0), - (14,1301061783,14,499,2,3,1301062024,1,0,3,0), - (1,1448889045,14,506,2,3,1448889046,1,0,9,0); +INSERT INTO `ibexa_content_version` (`contentobject_id`, `created`, `creator_id`, `id`, `initial_language_id`, `always_available`, `modified`, `status`, `user_id`, `version`, `workflow_event_pos`) +VALUES (4,0,14,4,2,1,0,1,0,1,1), + (11,1033920737,14,439,2,1,1033920746,1,0,1,0), + (12,1033920760,14,440,2,1,1033920775,1,0,1,0), + (13,1033920786,14,441,2,1,1033920794,1,0,1,0), + (41,1060695450,14,472,2,1,1060695457,1,0,1,0), + (42,1072180278,14,473,2,1,1072180330,1,0,1,0), + (10,1072180337,14,474,2,1,1072180405,1,0,2,0), + (49,1080220181,14,488,2,1,1080220197,1,0,1,0), + (50,1080220211,14,489,2,1,1080220220,1,0,1,0), + (51,1080220225,14,490,2,1,1080220233,1,0,1,0), + (14,1301061783,14,499,2,1,1301062024,1,0,3,0), + (1,1448889045,14,506,2,1,1448889046,1,0,9,0); + +INSERT INTO `ibexa_content_version_translation` (`content_version_id`, `language_id`) +VALUES (4,2), + (439,2), + (440,2), + (441,2), + (472,2), + (473,2), + (474,2), + (488,2), + (489,2), + (490,2), + (499,2), + (506,2); INSERT INTO `ibexa_node_assignment` (`contentobject_id`, `contentobject_version`, `from_node_id`, `id`, `is_main`, `op_code`, `parent_node`, `parent_remote_id`, `remote_id`, `sort_field`, `sort_order`, `priority`, `is_hidden`) VALUES (8,2,0,4,1,2,5,'','0',1,1,0,0), @@ -252,33 +280,47 @@ VALUES ('content/view/full/2',0,12,1,1,0,'d41d8cd98f00b204e9800998ecf8427e',''), ('content/view/full/52',0,29,1,1,0,'ad5a8c6f6aac3b1b9df267fe22e7aef6','media/files'), ('content/view/full/53',0,30,1,1,0,'562a0ac498571c6c3529173184a2657c','media/multimedia'); -INSERT INTO `ibexa_url_alias_ml` (`action`, `action_type`, `alias_redirects`, `id`, `is_alias`, `is_original`, `lang_mask`, `link`, `parent`, `text`, `text_md5`) +INSERT INTO `ibexa_url_alias_ml` (`action`, `action_type`, `alias_redirects`, `id`, `is_alias`, `is_original`, `is_always_available`, `link`, `parent`, `text`, `text_md5`) VALUES ('nop:','nop',1,17,0,0,1,17,0,'media2','50e2736330de124f6edea9b008556fe6'), - ('eznode:43','eznode',1,9,0,1,3,9,0,'Media','62933a2951ef01f4eafd9bdf4d3cd2f0'), + ('eznode:43','eznode',1,9,0,1,1,9,0,'Media','62933a2951ef01f4eafd9bdf4d3cd2f0'), ('nop:','nop',1,3,0,0,1,3,0,'users2','86425c35a33507d479f71ade53a669aa'), - ('eznode:5','eznode',1,2,0,1,3,2,0,'Users','9bc65c2abec141778ffaa729489f3e87'), - ('eznode:2','eznode',1,1,0,1,3,1,0,'','d41d8cd98f00b204e9800998ecf8427e'), - ('eznode:14','eznode',1,6,0,1,3,6,2,'Editors','a147e136bfa717592f2bd70bd4b53b17'), - ('eznode:44','eznode',1,10,0,1,3,10,2,'Anonymous-Users','c2803c3fa1b0b5423237b4e018cae755'), - ('eznode:12','eznode',1,4,0,1,3,4,2,'Guest-accounts','e57843d836e3af8ab611fde9e2139b3a'), - ('eznode:13','eznode',1,5,0,1,3,5,2,'Administrator-users','f89fad7f8a3abc8c09e1deb46a420007'), + ('eznode:5','eznode',1,2,0,1,1,2,0,'Users','9bc65c2abec141778ffaa729489f3e87'), + ('eznode:2','eznode',1,1,0,1,1,1,0,'','d41d8cd98f00b204e9800998ecf8427e'), + ('eznode:14','eznode',1,6,0,1,1,6,2,'Editors','a147e136bfa717592f2bd70bd4b53b17'), + ('eznode:44','eznode',1,10,0,1,1,10,2,'Anonymous-Users','c2803c3fa1b0b5423237b4e018cae755'), + ('eznode:12','eznode',1,4,0,1,1,4,2,'Guest-accounts','e57843d836e3af8ab611fde9e2139b3a'), + ('eznode:13','eznode',1,5,0,1,1,5,2,'Administrator-users','f89fad7f8a3abc8c09e1deb46a420007'), ('nop:','nop',1,11,0,0,1,11,3,'anonymous_users2','505e93077a6dde9034ad97a14ab022b1'), ('eznode:12','eznode',1,26,0,0,1,4,3,'guest_accounts','70bb992820e73638731aa8de79b3329e'), ('eznode:14','eznode',1,29,0,0,1,6,3,'editors','a147e136bfa717592f2bd70bd4b53b17'), ('nop:','nop',1,7,0,0,1,7,3,'administrator_users2','a7da338c20bf65f9f789c87296379c2a'), ('eznode:13','eznode',1,27,0,0,1,5,3,'administrator_users','aeb8609aa933b0899aa012c71139c58c'), ('eznode:44','eznode',1,30,0,0,1,10,3,'anonymous_users','e9e5ad0c05ee1a43715572e5cc545926'), - ('eznode:15','eznode',1,8,0,1,3,8,5,'Administrator-User','5a9d7b0ec93173ef4fedee023209cb61'), + ('eznode:15','eznode',1,8,0,1,1,8,5,'Administrator-User','5a9d7b0ec93173ef4fedee023209cb61'), ('eznode:15','eznode',1,28,0,0,0,8,7,'administrator_user','a3cca2de936df1e2f805710399989971'), - ('eznode:53','eznode',1,20,0,1,3,20,9,'Multimedia','2e5bc8831f7ae6a29530e7f1bbf2de9c'), - ('eznode:52','eznode',1,19,0,1,3,19,9,'Files','45b963397aa40d4a0063e0d85e4fe7a1'), - ('eznode:51','eznode',1,18,0,1,3,18,9,'Images','59b514174bffe4ae402b3d63aad79fe0'), - ('eznode:45','eznode',1,12,0,1,3,12,10,'Anonymous-User','ccb62ebca03a31272430bc414bd5cd5b'), + ('eznode:53','eznode',1,20,0,1,1,20,9,'Multimedia','2e5bc8831f7ae6a29530e7f1bbf2de9c'), + ('eznode:52','eznode',1,19,0,1,1,19,9,'Files','45b963397aa40d4a0063e0d85e4fe7a1'), + ('eznode:51','eznode',1,18,0,1,1,18,9,'Images','59b514174bffe4ae402b3d63aad79fe0'), + ('eznode:45','eznode',1,12,0,1,1,12,10,'Anonymous-User','ccb62ebca03a31272430bc414bd5cd5b'), ('eznode:45','eznode',1,31,0,0,1,12,11,'anonymous_user','c593ec85293ecb0e02d50d4c5c6c20eb'), ('eznode:53','eznode',1,34,0,0,1,20,17,'multimedia','2e5bc8831f7ae6a29530e7f1bbf2de9c'), ('eznode:52','eznode',1,33,0,0,1,19,17,'files','45b963397aa40d4a0063e0d85e4fe7a1'), ('eznode:51','eznode',1,32,0,0,1,18,17,'images','59b514174bffe4ae402b3d63aad79fe0'); +INSERT INTO `ibexa_url_alias_ml_translation` (`parent`, `text_md5`, `language_id`) +VALUES (0,'62933a2951ef01f4eafd9bdf4d3cd2f0',2), + (0,'9bc65c2abec141778ffaa729489f3e87',2), + (0,'d41d8cd98f00b204e9800998ecf8427e',2), + (2,'a147e136bfa717592f2bd70bd4b53b17',2), + (2,'c2803c3fa1b0b5423237b4e018cae755',2), + (2,'e57843d836e3af8ab611fde9e2139b3a',2), + (2,'f89fad7f8a3abc8c09e1deb46a420007',2), + (5,'5a9d7b0ec93173ef4fedee023209cb61',2), + (9,'2e5bc8831f7ae6a29530e7f1bbf2de9c',2), + (9,'45b963397aa40d4a0063e0d85e4fe7a1',2), + (9,'59b514174bffe4ae402b3d63aad79fe0',2), + (10,'ccb62ebca03a31272430bc414bd5cd5b',2); + INSERT INTO `ibexa_url_alias_ml_incr` (`id`) VALUES (1), (2), (3), (4), (5), (6), (7), (8), (9), (10), (11), (12), (13), (14), (15), (16), (17), (18), (19), (20), (21), (22), (24), (25), (26), (27), (28), (29), (30), (31), (32), (33), diff --git a/data/postgresql/cleandata.sql b/data/postgresql/cleandata.sql index d6c69314f2..71a34da929 100644 --- a/data/postgresql/cleandata.sql +++ b/data/postgresql/cleandata.sql @@ -1,9 +1,9 @@ -INSERT INTO "ibexa_object_state" ("default_language_id", "group_id", "id", "identifier", "language_mask", "priority") -VALUES (2, 2, 1, 'not_locked', 3, 0), - (2, 2, 2, 'locked', 3, 1); +INSERT INTO "ibexa_object_state" ("default_language_id", "group_id", "id", "identifier", "priority") +VALUES (2, 2, 1, 'not_locked', 0), + (2, 2, 2, 'locked', 1); -INSERT INTO "ibexa_object_state_group" ("default_language_id", "id", "identifier", "language_mask") -VALUES (2, 2, 'ibexa_lock', 3); +INSERT INTO "ibexa_object_state_group" ("default_language_id", "id", "identifier") +VALUES (2, 2, 'ibexa_lock'); INSERT INTO "ibexa_object_state_group_language" ("contentobject_state_group_id", "description","language_id", "name", "real_language_id") VALUES (2, '', 3, 'Lock', 2); @@ -26,16 +26,16 @@ VALUES ( 1, 1), (50, 1), (51, 1); -INSERT INTO "ibexa_content_language" ("disabled", "id", "locale", "name") +INSERT INTO "ibexa_language" ("disabled", "id", "locale", "name") VALUES (0, 2, 'eng-GB', 'English (United Kingdom)'); -INSERT INTO "ibexa_content_type" ("always_available", "contentobject_name", "created", "creator_id", "id", "identifier", "initial_language_id", "is_container", "language_mask", "modified", "modifier_id", "remote_id", "serialized_description_list", "serialized_name_list", "sort_field", "sort_order", "url_alias_name", "status") -VALUES (1,'',1024392098,14,1,'folder',2,1,2,1448831672,14,'a3d405b81be900468eb153d774f4f0d2','a:0:{}','a:1:{s:6:"eng-GB";s:6:"Folder";}',1,1,NULL,0), - (0,'',1024392098,14,2,'article',2,1,3,1082454989,14,'c15b600eb9198b1924063b5a68758232',NULL,'a:2:{s:6:"eng-GB";s:7:"Article";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0), - (1,'',1024392098,14,3,'user_group',2,1,3,1048494743,14,'25b4268cdcd01921b808a0d854b877ef',NULL,'a:2:{s:6:"eng-GB";s:10:"User group";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0), - (1,' ',1024392098,14,4,'user',2,0,3,1082018364,14,'40faa822edc579b02c25f6bb7beec3ad',NULL,'a:2:{s:6:"eng-GB";s:4:"User";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0), - (1,'',1031484992,14,5,'image',2,0,3,1048494784,14,'f6df12aa74e36230eb675f364fccd25a',NULL,'a:2:{s:6:"eng-GB";s:5:"Image";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0), - (1,'',1052385472,14,12,'file',2,0,3,1052385669,14,'637d58bfddf164627bdfd265733280a0',NULL,'a:2:{s:6:"eng-GB";s:4:"File";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0); +INSERT INTO "ibexa_content_type" ("always_available", "contentobject_name", "created", "creator_id", "id", "identifier", "initial_language_id", "is_container", "modified", "modifier_id", "remote_id", "serialized_description_list", "serialized_name_list", "sort_field", "sort_order", "url_alias_name", "status") +VALUES (1,'',1024392098,14,1,'folder',2,1,1448831672,14,'a3d405b81be900468eb153d774f4f0d2','a:0:{}','a:1:{s:6:"eng-GB";s:6:"Folder";}',1,1,NULL,0), + (0,'',1024392098,14,2,'article',2,1,1082454989,14,'c15b600eb9198b1924063b5a68758232',NULL,'a:2:{s:6:"eng-GB";s:7:"Article";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0), + (1,'',1024392098,14,3,'user_group',2,1,1048494743,14,'25b4268cdcd01921b808a0d854b877ef',NULL,'a:2:{s:6:"eng-GB";s:10:"User group";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0), + (1,' ',1024392098,14,4,'user',2,0,1082018364,14,'40faa822edc579b02c25f6bb7beec3ad',NULL,'a:2:{s:6:"eng-GB";s:4:"User";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0), + (1,'',1031484992,14,5,'image',2,0,1048494784,14,'f6df12aa74e36230eb675f364fccd25a',NULL,'a:2:{s:6:"eng-GB";s:5:"Image";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0), + (1,'',1052385472,14,12,'file',2,0,1052385669,14,'637d58bfddf164627bdfd265733280a0',NULL,'a:2:{s:6:"eng-GB";s:4:"File";s:16:"always-available";s:6:"eng-GB";}',1,1,NULL,0); INSERT INTO "ibexa_content_type_field_definition" ("can_translate", "category", "content_type_id", "data_float1", "data_float2", "data_float3", "data_float4", "data_int1", "data_int2", "data_int3", "data_int4", "data_text1", "data_text2", "data_text3", "data_text4", "data_text5", "data_type_string", "id", "identifier", "is_information_collector", "is_required", "is_searchable", "is_thumbnail", "placement", "serialized_data_text", "serialized_description_list", "serialized_name_list", "status") VALUES (1,'',2,0,0,0,0,255,0,0,0,'New article','','','','','ibexa_string',1,'title',0,1,1,FALSE,1,NULL,NULL,'a:2:{s:6:"eng-GB";s:5:"Title";s:16:"always-available";s:6:"eng-GB";}',0), @@ -84,19 +84,33 @@ VALUES (1031216928, 14, 1, 1033922106, 14, 'Content'), (1031216941, 14, 2, 1033922113, 14, 'Users'), (1032009743, 14, 3, 1033922120, 14, 'Media'); -INSERT INTO "ibexa_content" ("content_type_id", "current_version", "id", "initial_language_id", "language_mask", "modified", "name", "owner_id", "published", "remote_id", "section_id", "status") -VALUES (1,9,1,2,3,1448889046,'Ibexa Platform',14,1448889046,'9459d3c29e15006e45197295722c7ade',1,1), - (3,1,4,2,3,1033917596,'Users',14,1033917596,'f5c88a2209584891056f987fd965b0ba',2,1), - (4,2,10,2,3,1072180405,'Anonymous User',14,1033920665,'faaeb9be3bd98ed09f606fc16d144eca',2,1), - (3,1,11,2,3,1033920746,'Guest accounts',14,1033920746,'5f7f0bdb3381d6a461d8c29ff53d908f',2,1), - (3,1,12,2,3,1033920775,'Administrator users',14,1033920775,'9b47a45624b023b1a76c73b74d704acf',2,1), - (3,1,13,2,3,1033920794,'Editors',14,1033920794,'3c160cca19fb135f83bd02d911f04db2',2,1), - (4,3,14,2,3,1301062024,'Administrator User',14,1033920830,'1bb4fe25487f05527efa8bfd394cecc7',2,1), - (1,1,41,2,3,1060695457,'Media',14,1060695457,'a6e35cbcb7cd6ae4b691f3eee30cd262',3,1), - (3,1,42,2,3,1072180330,'Anonymous users',14,1072180330,'15b256dbea2ae72418ff5facc999e8f9',2,1), - (1,1,49,2,3,1080220197,'Images',14,1080220197,'e7ff633c6b8e0fd3531e74c6e712bead',3,1), - (1,1,50,2,3,1080220220,'Files',14,1080220220,'732a5acd01b51a6fe6eab448ad4138a9',3,1), - (1,1,51,2,3,1080220233,'Multimedia',14,1080220233,'09082deb98662a104f325aaa8c4933d3',3,1); +INSERT INTO "ibexa_content" ("content_type_id", "current_version", "id", "initial_language_id", "always_available", "modified", "name", "owner_id", "published", "remote_id", "section_id", "status") +VALUES (1,9,1,2,TRUE,1448889046,'Ibexa Platform',14,1448889046,'9459d3c29e15006e45197295722c7ade',1,1), + (3,1,4,2,TRUE,1033917596,'Users',14,1033917596,'f5c88a2209584891056f987fd965b0ba',2,1), + (4,2,10,2,TRUE,1072180405,'Anonymous User',14,1033920665,'faaeb9be3bd98ed09f606fc16d144eca',2,1), + (3,1,11,2,TRUE,1033920746,'Guest accounts',14,1033920746,'5f7f0bdb3381d6a461d8c29ff53d908f',2,1), + (3,1,12,2,TRUE,1033920775,'Administrator users',14,1033920775,'9b47a45624b023b1a76c73b74d704acf',2,1), + (3,1,13,2,TRUE,1033920794,'Editors',14,1033920794,'3c160cca19fb135f83bd02d911f04db2',2,1), + (4,3,14,2,TRUE,1301062024,'Administrator User',14,1033920830,'1bb4fe25487f05527efa8bfd394cecc7',2,1), + (1,1,41,2,TRUE,1060695457,'Media',14,1060695457,'a6e35cbcb7cd6ae4b691f3eee30cd262',3,1), + (3,1,42,2,TRUE,1072180330,'Anonymous users',14,1072180330,'15b256dbea2ae72418ff5facc999e8f9',2,1), + (1,1,49,2,TRUE,1080220197,'Images',14,1080220197,'e7ff633c6b8e0fd3531e74c6e712bead',3,1), + (1,1,50,2,TRUE,1080220220,'Files',14,1080220220,'732a5acd01b51a6fe6eab448ad4138a9',3,1), + (1,1,51,2,TRUE,1080220233,'Multimedia',14,1080220233,'09082deb98662a104f325aaa8c4933d3',3,1); + +INSERT INTO "ibexa_content_translation" ("content_id", "language_id") +VALUES (1,2), + (4,2), + (10,2), + (11,2), + (12,2), + (13,2), + (14,2), + (41,2), + (42,2), + (49,2), + (50,2), + (51,2); INSERT INTO "ibexa_content_field" ("attribute_original_id", "content_type_field_definition_id", "contentobject_id", "data_float", "data_int", "data_text", "data_type_string", "id", "language_code", "language_id", "sort_key_int", "sort_key_string", "version") VALUES (0,4,1,NULL,NULL,'Ibexa Platform','ibexa_string',1,'eng-GB',3,0,'ibexa platform',9), @@ -169,19 +183,33 @@ VALUES (0,1,1,0,0,0,1,1448999778,1,1,'','/1/',0,'629709ba256fe317c3ddcee35453a96 (50,1,1,2,0,0,52,1081860720,52,43,'media/files','/1/43/52/',0,'0b113a208f7890f9ad3c24444ff5988c',9,1), (51,1,1,2,0,0,53,1081860720,53,43,'media/multimedia','/1/43/53/',0,'4f18b82c75f10aad476cae5adf98c11f',9,1); -INSERT INTO "ibexa_content_version" ("contentobject_id", "created", "creator_id", "id", "initial_language_id", "language_mask", "modified", "status", "user_id", "version", "workflow_event_pos") -VALUES (4,0,14,4,2,3,0,1,0,1,1), - (11,1033920737,14,439,2,3,1033920746,1,0,1,0), - (12,1033920760,14,440,2,3,1033920775,1,0,1,0), - (13,1033920786,14,441,2,3,1033920794,1,0,1,0), - (41,1060695450,14,472,2,3,1060695457,1,0,1,0), - (42,1072180278,14,473,2,3,1072180330,1,0,1,0), - (10,1072180337,14,474,2,3,1072180405,1,0,2,0), - (49,1080220181,14,488,2,3,1080220197,1,0,1,0), - (50,1080220211,14,489,2,3,1080220220,1,0,1,0), - (51,1080220225,14,490,2,3,1080220233,1,0,1,0), - (14,1301061783,14,499,2,3,1301062024,1,0,3,0), - (1,1448889045,14,506,2,3,1448889046,1,0,9,0); +INSERT INTO "ibexa_content_version" ("contentobject_id", "created", "creator_id", "id", "initial_language_id", "always_available", "modified", "status", "user_id", "version", "workflow_event_pos") +VALUES (4,0,14,4,2,TRUE,0,1,0,1,1), + (11,1033920737,14,439,2,TRUE,1033920746,1,0,1,0), + (12,1033920760,14,440,2,TRUE,1033920775,1,0,1,0), + (13,1033920786,14,441,2,TRUE,1033920794,1,0,1,0), + (41,1060695450,14,472,2,TRUE,1060695457,1,0,1,0), + (42,1072180278,14,473,2,TRUE,1072180330,1,0,1,0), + (10,1072180337,14,474,2,TRUE,1072180405,1,0,2,0), + (49,1080220181,14,488,2,TRUE,1080220197,1,0,1,0), + (50,1080220211,14,489,2,TRUE,1080220220,1,0,1,0), + (51,1080220225,14,490,2,TRUE,1080220233,1,0,1,0), + (14,1301061783,14,499,2,TRUE,1301062024,1,0,3,0), + (1,1448889045,14,506,2,TRUE,1448889046,1,0,9,0); + +INSERT INTO "ibexa_content_version_translation" ("content_version_id", "language_id") +VALUES (4,2), + (439,2), + (440,2), + (441,2), + (472,2), + (473,2), + (474,2), + (488,2), + (489,2), + (490,2), + (499,2), + (506,2); INSERT INTO "ibexa_node_assignment" ("contentobject_id", "contentobject_version", "from_node_id", "id", "is_main", "op_code", "parent_node", "parent_remote_id", "remote_id", "sort_field", "sort_order", "priority", "is_hidden") VALUES (8,2,0,4,1,2,5,'','0',1,1,0,0), @@ -252,32 +280,46 @@ VALUES ('content/view/full/2',0,12,1,1,0,'d41d8cd98f00b204e9800998ecf8427e',''), ('content/view/full/52',0,29,1,1,0,'ad5a8c6f6aac3b1b9df267fe22e7aef6','media/files'), ('content/view/full/53',0,30,1,1,0,'562a0ac498571c6c3529173184a2657c','media/multimedia'); -INSERT INTO "ibexa_url_alias_ml" ("action", "action_type", "alias_redirects", "id", "is_alias", "is_original", "lang_mask", "link", "parent", "text", "text_md5") -VALUES ('nop:','nop',1,17,0,0,1,17,0,'media2','50e2736330de124f6edea9b008556fe6'), - ('eznode:43','eznode',1,9,0,1,3,9,0,'Media','62933a2951ef01f4eafd9bdf4d3cd2f0'), - ('nop:','nop',1,3,0,0,1,3,0,'users2','86425c35a33507d479f71ade53a669aa'), - ('eznode:5','eznode',1,2,0,1,3,2,0,'Users','9bc65c2abec141778ffaa729489f3e87'), - ('eznode:2','eznode',1,1,0,1,3,1,0,'','d41d8cd98f00b204e9800998ecf8427e'), - ('eznode:14','eznode',1,6,0,1,3,6,2,'Editors','a147e136bfa717592f2bd70bd4b53b17'), - ('eznode:44','eznode',1,10,0,1,3,10,2,'Anonymous-Users','c2803c3fa1b0b5423237b4e018cae755'), - ('eznode:12','eznode',1,4,0,1,3,4,2,'Guest-accounts','e57843d836e3af8ab611fde9e2139b3a'), - ('eznode:13','eznode',1,5,0,1,3,5,2,'Administrator-users','f89fad7f8a3abc8c09e1deb46a420007'), - ('nop:','nop',1,11,0,0,1,11,3,'anonymous_users2','505e93077a6dde9034ad97a14ab022b1'), - ('eznode:12','eznode',1,26,0,0,1,4,3,'guest_accounts','70bb992820e73638731aa8de79b3329e'), - ('eznode:14','eznode',1,29,0,0,1,6,3,'editors','a147e136bfa717592f2bd70bd4b53b17'), - ('nop:','nop',1,7,0,0,1,7,3,'administrator_users2','a7da338c20bf65f9f789c87296379c2a'), - ('eznode:13','eznode',1,27,0,0,1,5,3,'administrator_users','aeb8609aa933b0899aa012c71139c58c'), - ('eznode:44','eznode',1,30,0,0,1,10,3,'anonymous_users','e9e5ad0c05ee1a43715572e5cc545926'), - ('eznode:15','eznode',1,8,0,1,3,8,5,'Administrator-User','5a9d7b0ec93173ef4fedee023209cb61'), - ('eznode:15','eznode',1,28,0,0,0,8,7,'administrator_user','a3cca2de936df1e2f805710399989971'), - ('eznode:53','eznode',1,20,0,1,3,20,9,'Multimedia','2e5bc8831f7ae6a29530e7f1bbf2de9c'), - ('eznode:52','eznode',1,19,0,1,3,19,9,'Files','45b963397aa40d4a0063e0d85e4fe7a1'), - ('eznode:51','eznode',1,18,0,1,3,18,9,'Images','59b514174bffe4ae402b3d63aad79fe0'), - ('eznode:45','eznode',1,12,0,1,3,12,10,'Anonymous-User','ccb62ebca03a31272430bc414bd5cd5b'), - ('eznode:45','eznode',1,31,0,0,1,12,11,'anonymous_user','c593ec85293ecb0e02d50d4c5c6c20eb'), - ('eznode:53','eznode',1,34,0,0,1,20,17,'multimedia','2e5bc8831f7ae6a29530e7f1bbf2de9c'), - ('eznode:52','eznode',1,33,0,0,1,19,17,'files','45b963397aa40d4a0063e0d85e4fe7a1'), - ('eznode:51','eznode',1,32,0,0,1,18,17,'images','59b514174bffe4ae402b3d63aad79fe0'); +INSERT INTO "ibexa_url_alias_ml" ("action", "action_type", "alias_redirects", "id", "is_alias", "is_original", "is_always_available", "link", "parent", "text", "text_md5") +VALUES ('nop:','nop',1,17,0,0,TRUE,17,0,'media2','50e2736330de124f6edea9b008556fe6'), + ('eznode:43','eznode',1,9,0,1,TRUE,9,0,'Media','62933a2951ef01f4eafd9bdf4d3cd2f0'), + ('nop:','nop',1,3,0,0,TRUE,3,0,'users2','86425c35a33507d479f71ade53a669aa'), + ('eznode:5','eznode',1,2,0,1,TRUE,2,0,'Users','9bc65c2abec141778ffaa729489f3e87'), + ('eznode:2','eznode',1,1,0,1,TRUE,1,0,'','d41d8cd98f00b204e9800998ecf8427e'), + ('eznode:14','eznode',1,6,0,1,TRUE,6,2,'Editors','a147e136bfa717592f2bd70bd4b53b17'), + ('eznode:44','eznode',1,10,0,1,TRUE,10,2,'Anonymous-Users','c2803c3fa1b0b5423237b4e018cae755'), + ('eznode:12','eznode',1,4,0,1,TRUE,4,2,'Guest-accounts','e57843d836e3af8ab611fde9e2139b3a'), + ('eznode:13','eznode',1,5,0,1,TRUE,5,2,'Administrator-users','f89fad7f8a3abc8c09e1deb46a420007'), + ('nop:','nop',1,11,0,0,TRUE,11,3,'anonymous_users2','505e93077a6dde9034ad97a14ab022b1'), + ('eznode:12','eznode',1,26,0,0,TRUE,4,3,'guest_accounts','70bb992820e73638731aa8de79b3329e'), + ('eznode:14','eznode',1,29,0,0,TRUE,6,3,'editors','a147e136bfa717592f2bd70bd4b53b17'), + ('nop:','nop',1,7,0,0,TRUE,7,3,'administrator_users2','a7da338c20bf65f9f789c87296379c2a'), + ('eznode:13','eznode',1,27,0,0,TRUE,5,3,'administrator_users','aeb8609aa933b0899aa012c71139c58c'), + ('eznode:44','eznode',1,30,0,0,TRUE,10,3,'anonymous_users','e9e5ad0c05ee1a43715572e5cc545926'), + ('eznode:15','eznode',1,8,0,1,TRUE,8,5,'Administrator-User','5a9d7b0ec93173ef4fedee023209cb61'), + ('eznode:15','eznode',1,28,0,0,FALSE,8,7,'administrator_user','a3cca2de936df1e2f805710399989971'), + ('eznode:53','eznode',1,20,0,1,TRUE,20,9,'Multimedia','2e5bc8831f7ae6a29530e7f1bbf2de9c'), + ('eznode:52','eznode',1,19,0,1,TRUE,19,9,'Files','45b963397aa40d4a0063e0d85e4fe7a1'), + ('eznode:51','eznode',1,18,0,1,TRUE,18,9,'Images','59b514174bffe4ae402b3d63aad79fe0'), + ('eznode:45','eznode',1,12,0,1,TRUE,12,10,'Anonymous-User','ccb62ebca03a31272430bc414bd5cd5b'), + ('eznode:45','eznode',1,31,0,0,TRUE,12,11,'anonymous_user','c593ec85293ecb0e02d50d4c5c6c20eb'), + ('eznode:53','eznode',1,34,0,0,TRUE,20,17,'multimedia','2e5bc8831f7ae6a29530e7f1bbf2de9c'), + ('eznode:52','eznode',1,33,0,0,TRUE,19,17,'files','45b963397aa40d4a0063e0d85e4fe7a1'), + ('eznode:51','eznode',1,32,0,0,TRUE,18,17,'images','59b514174bffe4ae402b3d63aad79fe0'); + +INSERT INTO "ibexa_url_alias_ml_translation" ("parent", "text_md5", "language_id") +VALUES (0,'62933a2951ef01f4eafd9bdf4d3cd2f0',2), + (0,'9bc65c2abec141778ffaa729489f3e87',2), + (0,'d41d8cd98f00b204e9800998ecf8427e',2), + (2,'a147e136bfa717592f2bd70bd4b53b17',2), + (2,'c2803c3fa1b0b5423237b4e018cae755',2), + (2,'e57843d836e3af8ab611fde9e2139b3a',2), + (2,'f89fad7f8a3abc8c09e1deb46a420007',2), + (5,'5a9d7b0ec93173ef4fedee023209cb61',2), + (9,'2e5bc8831f7ae6a29530e7f1bbf2de9c',2), + (9,'45b963397aa40d4a0063e0d85e4fe7a1',2), + (9,'59b514174bffe4ae402b3d63aad79fe0',2), + (10,'ccb62ebca03a31272430bc414bd5cd5b',2); INSERT INTO "ibexa_url_alias_ml_incr" ("id") VALUES (1), (2), (3), (4), (5), (6), (7), (8), (9), (10), (11), (12), (13), (14), (15), (16), (17), diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 30d9604c37..b72ca9d7bf 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -3697,71 +3697,17 @@ parameters: path: src/bundle/RepositoryInstaller/Command/InstallPlatformCommand.php - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Command\\InstallPlatformCommand\:\:__construct\(\) has parameter \$installers with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/bundle/RepositoryInstaller/Command/InstallPlatformCommand.php - - - - message: '#^Strict comparison using \=\=\= between bool and 1 will always evaluate to false\.$#' - identifier: identical.alwaysFalse - count: 1 - path: src/bundle/RepositoryInstaller/Command/InstallPlatformCommand.php - - - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Command\\InstallPlatformCommand\:\:cacheClear\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/bundle/RepositoryInstaller/Command/InstallPlatformCommand.php - - - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Command\\InstallPlatformCommand\:\:checkPermissions\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/bundle/RepositoryInstaller/Command/InstallPlatformCommand.php - - - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Command\\InstallPlatformCommand\:\:executeCommand\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/bundle/RepositoryInstaller/Command/InstallPlatformCommand.php - - - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Command\\InstallPlatformCommand\:\:getInstaller\(\) has parameter \$type with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: src/bundle/RepositoryInstaller/Command/InstallPlatformCommand.php - - - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Command\\InstallPlatformCommand\:\:getInstaller\(\) should return Ibexa\\Bundle\\RepositoryInstaller\\Installer\\Installer but returns false\.$#' - identifier: return.type + message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Command\\InstallPlatformCommand\:\:__construct\(\) has parameter \$installers with generic class Symfony\\Component\\DependencyInjection\\ServiceLocator but does not specify its types\: T$#' + identifier: missingType.generics count: 1 path: src/bundle/RepositoryInstaller/Command/InstallPlatformCommand.php - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Command\\InstallPlatformCommand\:\:indexData\(\) has no return type specified\.$#' - identifier: missingType.return + message: '#^Property Ibexa\\Bundle\\RepositoryInstaller\\Command\\InstallPlatformCommand\:\:\$installers with generic class Symfony\\Component\\DependencyInjection\\ServiceLocator does not specify its types\: T$#' + identifier: missingType.generics count: 1 path: src/bundle/RepositoryInstaller/Command/InstallPlatformCommand.php - - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Command\\ValidatePasswordHashesCommand\:\:configure\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/bundle/RepositoryInstaller/Command/ValidatePasswordHashesCommand.php - - - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\DependencyInjection\\Compiler\\InstallerTagPass\:\:process\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/bundle/RepositoryInstaller/DependencyInjection/Compiler/InstallerTagPass.php - - - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\DependencyInjection\\IbexaRepositoryInstallerExtension\:\:load\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/bundle/RepositoryInstaller/DependencyInjection/IbexaRepositoryInstallerExtension.php - - message: '#^Method Ibexa\\Bundle\\RepositoryInstaller\\Event\\Subscriber\\BuildSchemaSubscriber\:\:getSubscribedEvents\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -12102,42 +12048,6 @@ parameters: count: 1 path: src/lib/Persistence/Legacy/Content/Language/Mapper.php - - - message: '#^Call to function is_int\(\) with 2\|int\<4, max\> will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: src/lib/Persistence/Legacy/Content/Language/MaskGenerator.php - - - - message: '#^Method Ibexa\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGenerator\:\:extractLanguageCodesFromMask\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/lib/Persistence/Legacy/Content/Language/MaskGenerator.php - - - - message: '#^Method Ibexa\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGenerator\:\:extractLanguageIdsFromMask\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/lib/Persistence/Legacy/Content/Language/MaskGenerator.php - - - - message: '#^Method Ibexa\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGenerator\:\:isLanguageAlwaysAvailable\(\) has parameter \$languages with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/lib/Persistence/Legacy/Content/Language/MaskGenerator.php - - - - message: '#^Parameter \#1 \$array of function array_keys expects array, iterable\ given\.$#' - identifier: argument.type - count: 1 - path: src/lib/Persistence/Legacy/Content/Language/MaskGenerator.php - - - - message: '#^Property Ibexa\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGenerator\:\:\$languageHandler \(Ibexa\\Core\\Persistence\\Legacy\\Content\\Language\\Handler\) does not accept Ibexa\\Contracts\\Core\\Persistence\\Content\\Language\\Handler\.$#' - identifier: assign.propertyType - count: 1 - path: src/lib/Persistence/Legacy/Content/Language/MaskGenerator.php - - message: '#^Method Ibexa\\Core\\Persistence\\Legacy\\Content\\Location\\Gateway\:\:getBasicNodeData\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -12348,12 +12258,6 @@ parameters: count: 1 path: src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php - - - message: '#^Parameter \#2 \$value2 of method Doctrine\\DBAL\\Platforms\\AbstractPlatform\:\:getBitAndComparisonExpression\(\) expects string, int given\.$#' - identifier: argument.type - count: 1 - path: src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php - - message: '#^Parameter \#2 \$y of method Doctrine\\DBAL\\Query\\Expression\\ExpressionBuilder\:\:in\(\) expects array\\|string, list\ given\.$#' identifier: argument.type @@ -12552,12 +12456,6 @@ parameters: count: 2 path: src/lib/Persistence/Legacy/Content/Location/Trash/Handler.php - - - message: '#^Call to function is_int\(\) with 2\|int\<4, max\> will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: src/lib/Persistence/Legacy/Content/Mapper.php - - message: '#^Method Ibexa\\Core\\Persistence\\Legacy\\Content\\Mapper\:\:extractContentInfoFromRows\(\) has parameter \$rows with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -13758,24 +13656,6 @@ parameters: count: 1 path: src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabase.php - - - message: '#^Method Ibexa\\Core\\Persistence\\Legacy\\Content\\UrlAlias\\Gateway\\DoctrineDatabase\:\:updateRow\(\) has parameter \$values with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabase.php - - - - message: '#^Parameter \#2 \$callback of function array_filter expects \(callable\(int\)\: bool\)\|null, Closure\(mixed\)\: int given\.$#' - identifier: argument.type - count: 1 - path: src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabase.php - - - - message: '#^Variable \$connection in PHPDoc tag @var does not match assigned variable \$query\.$#' - identifier: varTag.differentVariable - count: 1 - path: src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabase.php - - message: '#^Dead catch \- Doctrine\\DBAL\\Exception is never thrown in the try block\.$#' identifier: catch.neverThrown @@ -18432,12 +18312,6 @@ parameters: count: 1 path: src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/Field.php - - - message: '#^Cannot call method getBitAndComparisonExpression\(\) on Doctrine\\DBAL\\Platforms\\AbstractPlatform\|null\.$#' - identifier: method.nonObject - count: 8 - path: src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FieldBase.php - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Common\\Gateway\\CriterionHandler\\FieldBase\:\:getFieldCondition\(\) has parameter \$languageSettings with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -18636,24 +18510,12 @@ parameters: count: 1 path: src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/IsUserEnabled.php - - - message: '#^Cannot call method getBitAndComparisonExpression\(\) on Doctrine\\DBAL\\Platforms\\AbstractPlatform\|null\.$#' - identifier: method.nonObject - count: 1 - path: src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/LanguageCode.php - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Common\\Gateway\\CriterionHandler\\LanguageCode\:\:handle\(\) has parameter \$languageSettings with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/LanguageCode.php - - - message: '#^Parameter \#1 \$languageCodes of method Ibexa\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGenerator\:\:generateLanguageMaskFromLanguageCodes\(\) expects array\, array\\|bool\|float\|int\|string given\.$#' - identifier: argument.type - count: 1 - path: src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/LanguageCode.php - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Common\\Gateway\\CriterionHandler\\LogicalAnd\:\:handle\(\) has parameter \$languageSettings with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -18876,12 +18738,6 @@ parameters: count: 1 path: src/lib/Search/Legacy/Content/Common/Gateway/SortClauseHandler/Factory/RandomSortClauseHandlerFactory.php - - - message: '#^Cannot call method getBitAndComparisonExpression\(\) on Doctrine\\DBAL\\Platforms\\AbstractPlatform\|null\.$#' - identifier: method.nonObject - count: 8 - path: src/lib/Search/Legacy/Content/Common/Gateway/SortClauseHandler/Field.php - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Common\\Gateway\\SortClauseHandler\\Field\:\:applyJoin\(\) has parameter \$languageSettings with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -19098,12 +18954,6 @@ parameters: count: 1 path: src/lib/Search/Legacy/Content/Gateway/DoctrineDatabase.php - - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Gateway\\DoctrineDatabase\:\:getLanguageMask\(\) has parameter \$languageSettings with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/lib/Search/Legacy/Content/Gateway/DoctrineDatabase.php - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Gateway\\DoctrineDatabase\:\:getQueryCondition\(\) has parameter \$languageFilter with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -19170,30 +19020,6 @@ parameters: count: 1 path: src/lib/Search/Legacy/Content/Handler.php - - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Handler\:\:extractMatchedLanguage\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/lib/Search/Legacy/Content/Handler.php - - - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Handler\:\:extractMatchedLanguage\(\) has parameter \$languageMask with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: src/lib/Search/Legacy/Content/Handler.php - - - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Handler\:\:extractMatchedLanguage\(\) has parameter \$languageSettings with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: src/lib/Search/Legacy/Content/Handler.php - - - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Handler\:\:extractMatchedLanguage\(\) has parameter \$mainLanguageId with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: src/lib/Search/Legacy/Content/Handler.php - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Handler\:\:indexContent\(\) has no return type specified\.$#' identifier: missingType.return @@ -19386,12 +19212,6 @@ parameters: count: 1 path: src/lib/Search/Legacy/Content/Location/Gateway/DoctrineDatabase.php - - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Location\\Gateway\\DoctrineDatabase\:\:getLanguageMask\(\) has parameter \$languageFilter with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/lib/Search/Legacy/Content/Location/Gateway/DoctrineDatabase.php - - message: '#^Method Ibexa\\Core\\Search\\Legacy\\Content\\Location\\Gateway\\DoctrineDatabase\:\:getTotalCount\(\) has parameter \$languageFilter with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -31218,18 +31038,6 @@ parameters: count: 1 path: tests/integration/Core/Repository/LanguageServiceAuthorizationTest.php - - - message: '#^Comparison operation "\<" between int\<80300, 80599\> and 50400 is always false\.$#' - identifier: smaller.alwaysFalse - count: 1 - path: tests/integration/Core/Repository/LanguageServiceMaximumSupportedLanguagesTest.php - - - - message: '#^Method Ibexa\\Tests\\Integration\\Core\\Repository\\LanguageServiceMaximumSupportedLanguagesTest\:\:testCreateMaximumLanguageLimit\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/integration/Core/Repository/LanguageServiceMaximumSupportedLanguagesTest.php - - message: '#^Parameter \#1 \$value of function count expects array\|Countable, iterable\ given\.$#' identifier: argument.type @@ -31242,12 +31050,6 @@ parameters: count: 1 path: tests/integration/Core/Repository/LanguageServiceMaximumSupportedLanguagesTest.php - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: tests/integration/Core/Repository/LanguageServiceMaximumSupportedLanguagesTest.php - - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Ibexa\\\\Contracts\\\\Core\\\\Repository\\\\Values\\\\Content\\\\Language'' and Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Language will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType @@ -47814,108 +47616,6 @@ parameters: count: 1 path: tests/lib/Persistence/Legacy/Content/Language/MapperTest.php - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:getLanguageHandler\(\) should return Ibexa\\Core\\Persistence\\Legacy\\Content\\Language\\Handler but returns Ibexa\\Contracts\\Core\\Persistence\\Content\\Language\\Handler\.$#' - identifier: return.type - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:getLanguageIndicatorData\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:isAlwaysAvailableProvider\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:languageIdsFromMaskProvider\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:removeAlwaysAvailableFlagProvider\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testExtractLanguageIdsFromMask\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testExtractLanguageIdsFromMask\(\) has parameter \$expectedResult with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testGenerateLanguageIndicator\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testIsAlwaysAvailable\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testIsLanguageAlwaysAvailable\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testIsLanguageAlwaysAvailableNoDefault\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testIsLanguageAlwaysAvailableOtherLanguage\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testRemoveAlwaysAvailableFlag\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testRemoveAlwaysAvailableFlag\(\) has parameter \$expectedResult with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGeneratorTest\:\:testRemoveAlwaysAvailableFlag\(\) has parameter \$langMask with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Offset string might not exist on array\{\}\|array\{eng\-US\?\: Ibexa\\Contracts\\Core\\Persistence\\Content\\Language, eng\-GB\?\: Ibexa\\Contracts\\Core\\Persistence\\Content\\Language\}\.$#' - identifier: offsetAccess.notFound - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - - - message: '#^Property Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\LanguageAwareTestCase\:\:\$languageHandler \(Ibexa\\Contracts\\Core\\Persistence\\Content\\Language\\Handler\) in isset\(\) is not nullable\.$#' - identifier: isset.property - count: 1 - path: tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\LanguageAwareTestCase\:\:getFullTextSearchConfiguration\(\) has no return type specified\.$#' identifier: missingType.return @@ -47934,12 +47634,6 @@ parameters: count: 1 path: tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php - - - message: '#^Property Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\LanguageAwareTestCase\:\:\$languageMaskGenerator \(Ibexa\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGenerator\) in isset\(\) is not nullable\.$#' - identifier: isset.property - count: 1 - path: tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\LanguageHandlerMock\:\:delete\(\) has no return type specified\.$#' identifier: missingType.return @@ -50304,12 +49998,6 @@ parameters: count: 1 path: tests/lib/Persistence/Legacy/Content/Type/MapperTest.php - - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Type\\MapperTest\:\:getMaskGeneratorMock\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/lib/Persistence/Legacy/Content/Type/MapperTest.php - - message: '#^Method Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\Type\\MapperTest\:\:testCreateGroupFromCreateStruct\(\) has no return type specified\.$#' identifier: missingType.return @@ -51618,12 +51306,6 @@ parameters: count: 1 path: tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php - - - message: '#^Property Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\UrlAlias\\UrlAliasHandlerTest\:\:\$languageMaskGenerator \(Ibexa\\Core\\Persistence\\Legacy\\Content\\Language\\MaskGenerator\) in isset\(\) is not nullable\.$#' - identifier: isset.property - count: 1 - path: tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php - - message: '#^Property Ibexa\\Tests\\Core\\Persistence\\Legacy\\Content\\UrlAlias\\UrlAliasHandlerTest\:\:\$locationGateway \(Ibexa\\Core\\Persistence\\Legacy\\Content\\Location\\Gateway\) in isset\(\) is not nullable\.$#' identifier: isset.property diff --git a/src/bundle/Core/Command/BackfillLanguageTranslationsCommand.php b/src/bundle/Core/Command/BackfillLanguageTranslationsCommand.php new file mode 100644 index 0000000000..aa13fdc53a --- /dev/null +++ b/src/bundle/Core/Command/BackfillLanguageTranslationsCommand.php @@ -0,0 +1,266 @@ +addOption( + 'table', + 't', + InputOption::VALUE_OPTIONAL, + sprintf( + 'Which table to backfill: one of "%s", or "%s" for all of them.', + implode('", "', self::VALID_TABLES), + self::TABLE_ALL + ), + self::TABLE_ALL + ) + ->addOption( + 'batch-size', + null, + InputOption::VALUE_OPTIONAL, + 'Number of primary-key values processed per batch.', + (string)self::DEFAULT_BATCH_SIZE + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Report how many rows would be inserted per batch without writing anything.' + ) + ->setHelp( + <<%command.name% populates "ibexa_content_translation", +"ibexa_content_version_translation" and "ibexa_url_alias_ml_translation" from the existing +"language_mask"/"lang_mask" bitmask columns. It is idempotent - already-backfilled rows are +skipped - so it is safe to re-run, including after a partial/interrupted run. + +Run ibexa:languages:verify-translations afterward to confirm the backfill is complete. +EOT + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $table = $input->getOption('table'); + $batchSize = (int)$input->getOption('batch-size'); + $dryRun = (bool)$input->getOption('dry-run'); + + if ($batchSize < 1) { + throw new InvalidArgumentException('batch-size', 'must be a positive integer.'); + } + + $tables = $table === self::TABLE_ALL ? self::VALID_TABLES : [$table]; + foreach ($tables as $singleTable) { + if (!in_array($singleTable, self::VALID_TABLES, true)) { + throw new InvalidArgumentException( + 'table', + sprintf( + '"%s" is not one of "%s" or "%s".', + $singleTable, + implode('", "', self::VALID_TABLES), + self::TABLE_ALL + ) + ); + } + } + + foreach ($tables as $singleTable) { + $this->backfillTable($singleTable, $batchSize, $dryRun, $output); + } + + return self::SUCCESS; + } + + private function backfillTable(string $table, int $batchSize, bool $dryRun, OutputInterface $output): void + { + [$sourceTable, $pkColumn, $insertSql] = match ($table) { + self::TABLE_CONTENT => [ + 'ibexa_content', + 'id', + 'INSERT %s INTO ibexa_content_translation (content_id, language_id) + SELECT c.id, l.id FROM ibexa_content c + JOIN ibexa_content_language l ON (c.language_mask & l.id) = l.id + WHERE c.id BETWEEN :from AND :to %s', + ], + self::TABLE_CONTENT_VERSION => [ + 'ibexa_content_version', + 'id', + 'INSERT %s INTO ibexa_content_version_translation (content_version_id, language_id) + SELECT v.id, l.id FROM ibexa_content_version v + JOIN ibexa_content_language l ON (v.language_mask & l.id) = l.id + WHERE v.id BETWEEN :from AND :to %s', + ], + self::TABLE_URL_ALIAS => [ + 'ibexa_url_alias_ml', + 'parent', + 'INSERT %s INTO ibexa_url_alias_ml_translation (parent, text_md5, language_id) + SELECT u.parent, u.text_md5, l.id FROM ibexa_url_alias_ml u + JOIN ibexa_content_language l ON (u.lang_mask & l.id) = l.id + WHERE u.parent BETWEEN :from AND :to %s', + ], + default => throw new InvalidArgumentException('table', "unknown table \"{$table}\"."), + }; + + // MIN()/MAX() rather than COUNT()-based emptiness + a hardcoded lower bound of 1: some of + // these tables (e.g. "ibexa_url_alias_ml" for root-level aliases) legitimately use 0 as a + // valid primary-key value, so neither "MAX() === 0" nor an assumed start of 1 is safe here. + $range = $this->connection->fetchAssociative( + "SELECT MIN({$pkColumn}) AS min_id, MAX({$pkColumn}) AS max_id FROM {$sourceTable}" + ); + if ($range === false || $range['min_id'] === null) { + $output->writeln("{$sourceTable} is empty, nothing to backfill."); + + return; + } + + $minId = (int)$range['min_id']; + $maxId = (int)$range['max_id']; + + $output->writeln("Backfilling {$sourceTable} ({$pkColumn} {$minId}..{$maxId}, batch size {$batchSize})..."); + + $insertSql = sprintf($insertSql, $this->insertIgnoreKeyword(), $this->onConflictClause()); + $totalInserted = 0; + + for ($from = $minId; $from <= $maxId; $from += $batchSize) { + $to = min($from + $batchSize - 1, $maxId); + + if ($dryRun) { + $inserted = (int)$this->connection->fetchOne( + $this->buildDryRunCountSql($table), + ['from' => $from, 'to' => $to] + ); + } else { + $inserted = $this->connection->executeStatement($insertSql, ['from' => $from, 'to' => $to]); + } + + $totalInserted += $inserted; + $output->writeln( + sprintf(' %d..%d: %d row(s)%s', $from, $to, $inserted, $dryRun ? ' (dry-run)' : ''), + OutputInterface::VERBOSITY_VERBOSE + ); + } + + $output->writeln(sprintf( + '%s%s: %d row(s) %s.', + $dryRun ? '[dry-run] ' : '', + $sourceTable, + $totalInserted, + $dryRun ? 'would be inserted' : 'inserted' + )); + } + + /** + * Mirrors the real INSERT's idempotency (via insertIgnoreKeyword()/onConflictClause()) with an + * explicit "NOT EXISTS" against the target translation table: without it, re-running + * `--dry-run` after a prior (partial or complete) backfill would count every mask-derived pair + * again, including ones already present, and report them as "would be inserted" when a real run + * would actually leave them untouched. + */ + private function buildDryRunCountSql(string $table): string + { + return match ($table) { + self::TABLE_CONTENT => 'SELECT COUNT(*) FROM ibexa_content c + JOIN ibexa_content_language l ON (c.language_mask & l.id) = l.id + WHERE c.id BETWEEN :from AND :to + AND NOT EXISTS ( + SELECT 1 FROM ibexa_content_translation t + WHERE t.content_id = c.id AND t.language_id = l.id + )', + self::TABLE_CONTENT_VERSION => 'SELECT COUNT(*) FROM ibexa_content_version v + JOIN ibexa_content_language l ON (v.language_mask & l.id) = l.id + WHERE v.id BETWEEN :from AND :to + AND NOT EXISTS ( + SELECT 1 FROM ibexa_content_version_translation t + WHERE t.content_version_id = v.id AND t.language_id = l.id + )', + self::TABLE_URL_ALIAS => 'SELECT COUNT(*) FROM ibexa_url_alias_ml u + JOIN ibexa_content_language l ON (u.lang_mask & l.id) = l.id + WHERE u.parent BETWEEN :from AND :to + AND NOT EXISTS ( + SELECT 1 FROM ibexa_url_alias_ml_translation t + WHERE t.parent = u.parent AND t.text_md5 = u.text_md5 AND t.language_id = l.id + )', + default => throw new InvalidArgumentException('table', "unknown table \"{$table}\"."), + }; + } + + private function insertIgnoreKeyword(): string + { + return match (DatabasePlatformResolver::resolveName($this->connection->getDatabasePlatform())) { + DatabasePlatformName::Mysql => 'IGNORE', + DatabasePlatformName::Sqlite => 'OR IGNORE', + DatabasePlatformName::Postgresql => '', + }; + } + + /** + * Appended after the SELECT to make the insert idempotent on platforms that don't support + * "INSERT IGNORE" (MySQL is handled via insertIgnoreKeyword() instead, since its "ON DUPLICATE + * KEY" clause needs different syntax for an INSERT ... SELECT). + */ + private function onConflictClause(): string + { + return match (DatabasePlatformResolver::resolveName($this->connection->getDatabasePlatform())) { + DatabasePlatformName::Postgresql => 'ON CONFLICT DO NOTHING', + DatabasePlatformName::Sqlite => '', // OR IGNORE is part of the INSERT keyword, not a trailing clause + DatabasePlatformName::Mysql => '', + }; + } +} diff --git a/src/bundle/Core/Command/VerifyLanguageTranslationsCommand.php b/src/bundle/Core/Command/VerifyLanguageTranslationsCommand.php new file mode 100644 index 0000000000..bafe3f8ffa --- /dev/null +++ b/src/bundle/Core/Command/VerifyLanguageTranslationsCommand.php @@ -0,0 +1,259 @@ + + */ + private const QUERIES = [ + self::TABLE_CONTENT => [ + 'missing' => 'SELECT COUNT(*) FROM ibexa_content c + JOIN ibexa_content_language l ON (c.language_mask & l.id) = l.id + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_content_translation ct + WHERE ct.content_id = c.id AND ct.language_id = l.id + )', + 'orphaned' => 'SELECT COUNT(*) FROM ibexa_content_translation ct + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_content c + WHERE c.id = ct.content_id AND (c.language_mask & ct.language_id) = ct.language_id + )', + 'fixMissing' => 'INSERT INTO ibexa_content_translation (content_id, language_id) + SELECT c.id, l.id FROM ibexa_content c + JOIN ibexa_content_language l ON (c.language_mask & l.id) = l.id + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_content_translation ct + WHERE ct.content_id = c.id AND ct.language_id = l.id + )', + // No alias on the DELETE target: SQLite's DELETE FROM does not accept one. + 'fixOrphaned' => 'DELETE FROM ibexa_content_translation + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_content c + WHERE c.id = ibexa_content_translation.content_id + AND (c.language_mask & ibexa_content_translation.language_id) = ibexa_content_translation.language_id + )', + ], + self::TABLE_CONTENT_VERSION => [ + 'missing' => 'SELECT COUNT(*) FROM ibexa_content_version v + JOIN ibexa_content_language l ON (v.language_mask & l.id) = l.id + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_content_version_translation vt + WHERE vt.content_version_id = v.id AND vt.language_id = l.id + )', + 'orphaned' => 'SELECT COUNT(*) FROM ibexa_content_version_translation vt + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_content_version v + WHERE v.id = vt.content_version_id AND (v.language_mask & vt.language_id) = vt.language_id + )', + 'fixMissing' => 'INSERT INTO ibexa_content_version_translation (content_version_id, language_id) + SELECT v.id, l.id FROM ibexa_content_version v + JOIN ibexa_content_language l ON (v.language_mask & l.id) = l.id + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_content_version_translation vt + WHERE vt.content_version_id = v.id AND vt.language_id = l.id + )', + // No alias on the DELETE target: SQLite's DELETE FROM does not accept one. + 'fixOrphaned' => 'DELETE FROM ibexa_content_version_translation + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_content_version v + WHERE v.id = ibexa_content_version_translation.content_version_id + AND (v.language_mask & ibexa_content_version_translation.language_id) = ibexa_content_version_translation.language_id + )', + ], + self::TABLE_URL_ALIAS => [ + 'missing' => 'SELECT COUNT(*) FROM ibexa_url_alias_ml u + JOIN ibexa_content_language l ON (u.lang_mask & l.id) = l.id + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_url_alias_ml_translation ut + WHERE ut.parent = u.parent AND ut.text_md5 = u.text_md5 AND ut.language_id = l.id + )', + 'orphaned' => 'SELECT COUNT(*) FROM ibexa_url_alias_ml_translation ut + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_url_alias_ml u + WHERE u.parent = ut.parent AND u.text_md5 = ut.text_md5 + AND (u.lang_mask & ut.language_id) = ut.language_id + )', + 'fixMissing' => 'INSERT INTO ibexa_url_alias_ml_translation (parent, text_md5, language_id) + SELECT u.parent, u.text_md5, l.id FROM ibexa_url_alias_ml u + JOIN ibexa_content_language l ON (u.lang_mask & l.id) = l.id + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_url_alias_ml_translation ut + WHERE ut.parent = u.parent AND ut.text_md5 = u.text_md5 AND ut.language_id = l.id + )', + // No alias on the DELETE target: SQLite's DELETE FROM does not accept one. + 'fixOrphaned' => 'DELETE FROM ibexa_url_alias_ml_translation + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_url_alias_ml u + WHERE u.parent = ibexa_url_alias_ml_translation.parent + AND u.text_md5 = ibexa_url_alias_ml_translation.text_md5 + AND (u.lang_mask & ibexa_url_alias_ml_translation.language_id) = ibexa_url_alias_ml_translation.language_id + )', + ], + ]; + + public function __construct(private readonly Connection $connection) + { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption( + 'table', + 't', + InputOption::VALUE_OPTIONAL, + sprintf( + 'Which table to verify: one of "%s", or "%s" for all of them.', + implode('", "', self::VALID_TABLES), + self::TABLE_ALL + ), + self::TABLE_ALL + ) + ->addOption( + 'fix', + null, + InputOption::VALUE_NONE, + 'Insert missing rows and delete orphaned rows to bring the translation table back in sync.' + ) + ->setHelp( + <<%command.name% compares "ibexa_content_translation", +"ibexa_content_version_translation" and "ibexa_url_alias_ml_translation" against the +"language_mask"/"lang_mask" columns they were backfilled from, reporting: + - missing: a language bit set in the mask with no corresponding translation row + - orphaned: a translation row whose bit is no longer set in the mask + +Exits non-zero if any drift is found and --fix was not passed. Run this after +ibexa:languages:backfill-translations, and again before relying on the translation +tables for anything - a clean report here is the precondition for every later migration step. +EOT + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $table = $input->getOption('table'); + $fix = (bool)$input->getOption('fix'); + + $tables = $table === self::TABLE_ALL ? self::VALID_TABLES : [$table]; + foreach ($tables as $singleTable) { + if (!isset(self::QUERIES[$singleTable])) { + throw new InvalidArgumentException( + 'table', + sprintf( + '"%s" is not one of "%s" or "%s".', + $singleTable, + implode('", "', self::VALID_TABLES), + self::TABLE_ALL + ) + ); + } + } + + $clean = true; + foreach ($tables as $singleTable) { + $clean = $this->verifyTable($singleTable, $fix, $output) && $clean; + } + + if ($clean) { + $output->writeln('All checked translation tables are in sync with their language masks.'); + + return self::SUCCESS; + } + + if ($fix) { + $output->writeln('Drift found and fixed.'); + + return self::SUCCESS; + } + + $output->writeln('Drift found. Re-run with --fix to correct it.'); + + return self::FAILURE; + } + + private function verifyTable(string $table, bool $fix, OutputInterface $output): bool + { + $queries = self::QUERIES[$table]; + + $missing = (int)$this->connection->fetchOne($queries['missing']); + $orphaned = (int)$this->connection->fetchOne($queries['orphaned']); + + if ($missing === 0 && $orphaned === 0) { + $output->writeln("{$table}: in sync."); + + return true; + } + + $output->writeln(sprintf( + '%s: %d missing, %d orphaned row(s).', + $table, + $missing, + $orphaned + )); + + if (!$fix) { + return false; + } + + if ($missing > 0) { + $inserted = $this->connection->executeStatement($queries['fixMissing']); + $output->writeln(" inserted {$inserted} missing row(s)."); + } + + if ($orphaned > 0) { + $deleted = $this->connection->executeStatement($queries['fixOrphaned']); + $output->writeln(" deleted {$deleted} orphaned row(s)."); + } + + return true; + } +} diff --git a/src/bundle/Core/Resources/config/doctrine_migrations.yml b/src/bundle/Core/Resources/config/doctrine_migrations.yml index efdb675c83..fffadedbd1 100644 --- a/src/bundle/Core/Resources/config/doctrine_migrations.yml +++ b/src/bundle/Core/Resources/config/doctrine_migrations.yml @@ -38,3 +38,59 @@ services: $connection: '@ibexa.persistence.connection' tags: - { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG } + + Ibexa\Bundle\RepositoryInstaller\Migration\AddContentAlwaysAvailableColumnsMigration: + autowire: true + public: false + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG } + + Ibexa\Bundle\RepositoryInstaller\Migration\AddLanguageTranslationTablesMigration: + autowire: true + public: false + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG } + + Ibexa\Bundle\RepositoryInstaller\Migration\BackfillLanguageTranslationsMigration: + autowire: true + public: false + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG } + + Ibexa\Bundle\RepositoryInstaller\Migration\AddSearchObjectWordLinkLanguageIdColumnsMigration: + autowire: true + public: false + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG } + + Ibexa\Bundle\RepositoryInstaller\Migration\AddUrlAliasAlwaysAvailableColumnMigration: + autowire: true + public: false + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG } + + Ibexa\Bundle\RepositoryInstaller\Migration\DropLanguageBitmaskColumnsMigration: + autowire: true + public: false + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG } + + Ibexa\Bundle\RepositoryInstaller\Migration\NarrowLanguageIdColumnTypesMigration: + autowire: true + public: false + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG } diff --git a/src/bundle/Core/Resources/config/services.yml b/src/bundle/Core/Resources/config/services.yml index 129414be41..a433eb7f86 100644 --- a/src/bundle/Core/Resources/config/services.yml +++ b/src/bundle/Core/Resources/config/services.yml @@ -312,6 +312,18 @@ services: tags: - { name: console.command } + Ibexa\Bundle\Core\Command\BackfillLanguageTranslationsCommand: + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - { name: console.command } + + Ibexa\Bundle\Core\Command\VerifyLanguageTranslationsCommand: + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - { name: console.command } + Ibexa\Bundle\Core\Session\Handler\NativeSessionHandler: class: Ibexa\Bundle\Core\Session\Handler\NativeSessionHandler arguments: diff --git a/src/bundle/Core/Resources/config/storage/legacy/schema.yaml b/src/bundle/Core/Resources/config/storage/legacy/schema.yaml index 68353ee052..58c89f8b00 100644 --- a/src/bundle/Core/Resources/config/storage/legacy/schema.yaml +++ b/src/bundle/Core/Resources/config/storage/legacy/schema.yaml @@ -11,40 +11,35 @@ tables: ibexa_object_state: indexes: ibexa_object_state_priority: { fields: [priority] } - ibexa_object_state_lmask: { fields: [language_mask] } uniqueConstraints: ibexa_object_state_identifier: { fields: [group_id, identifier] } id: id: { type: integer, nullable: false, options: { autoincrement: true } } fields: - default_language_id: { type: bigint, nullable: false, options: { default: '0' } } + default_language_id: { type: integer, nullable: false, options: { default: '0' } } group_id: { type: integer, nullable: false, options: { default: '0' } } identifier: { type: string, nullable: false, length: 45, options: { default: '' } } - language_mask: { type: bigint, nullable: false, options: { default: '0' } } priority: { type: integer, nullable: false, options: { default: '0' } } ibexa_object_state_group: - indexes: - ibexa_object_state_group_lmask: { fields: [language_mask] } uniqueConstraints: ibexa_object_state_group_identifier: { fields: [identifier] } id: id: { type: integer, nullable: false, options: { autoincrement: true } } fields: - default_language_id: { type: bigint, nullable: false, options: { default: '0' } } + default_language_id: { type: integer, nullable: false, options: { default: '0' } } identifier: { type: string, nullable: false, length: 45, options: { default: '' } } - language_mask: { type: bigint, nullable: false, options: { default: '0' } } ibexa_object_state_group_language: id: contentobject_state_group_id: { type: integer, nullable: false, options: { default: '0' } } - real_language_id: { type: bigint, nullable: false, options: { default: '0' } } + real_language_id: { type: integer, nullable: false, options: { default: '0' } } fields: description: { type: text, nullable: false, length: 0 } - language_id: { type: bigint, nullable: false, options: { default: '0' } } + language_id: { type: integer, nullable: false, options: { default: '0' } } name: { type: string, nullable: false, length: 45, options: { default: '' } } ibexa_object_state_language: id: contentobject_state_id: { type: integer, nullable: false, options: { default: '0' } } - language_id: { type: bigint, nullable: false, options: { default: '0' } } + language_id: { type: integer, nullable: false, options: { default: '0' } } fields: description: { type: text, nullable: false, length: 0 } name: { type: string, nullable: false, length: 45, options: { default: '' } } @@ -52,11 +47,11 @@ tables: id: contentobject_id: { type: integer, nullable: false, options: { default: '0' } } contentobject_state_id: { type: integer, nullable: false, options: { default: '0' } } - ibexa_content_language: + ibexa_language: indexes: - ibexa_content_language_name: { fields: [name], options: { lengths: ['191'] } } + ibexa_language_name: { fields: [name], options: { lengths: ['191'] } } id: - id: { type: bigint, nullable: false, options: { default: '0' } } + id: { type: integer, nullable: false, options: { default: '0' } } fields: disabled: { type: integer, nullable: false, options: { default: '0' } } locale: { type: string, nullable: false, length: 20, options: { default: '' } } @@ -127,9 +122,8 @@ tables: created: { type: integer, nullable: false, options: { default: '0' } } creator_id: { type: integer, nullable: false, options: { default: '0' } } identifier: { type: string, nullable: false, length: 50, options: { default: '' } } - initial_language_id: { type: bigint, nullable: false, options: { default: '0' } } + initial_language_id: { type: integer, nullable: false, options: { default: '0' } } is_container: { type: integer, nullable: false, options: { default: '0' } } - language_mask: { type: bigint, nullable: false, options: { default: '0' } } modified: { type: integer, nullable: false, options: { default: '0' } } modifier_id: { type: integer, nullable: false, options: { default: '0' } } remote_id: { type: string, nullable: false, length: 100, options: { default: '' } } @@ -178,14 +172,14 @@ tables: id: content_type_field_definition_id: { type: integer, nullable: false } status: { type: integer, nullable: false } - language_id: { type: bigint, nullable: false } + language_id: { type: integer, nullable: false } fields: name: { type: string, nullable: false, length: 255 } description: { type: text, nullable: true, length: 65535 } data_text: { type: text, nullable: true, length: 65535 } data_json: { type: text, nullable: true, length: 65535 } foreignKeys: - ibexa_content_type_field_definition_ml_lang_fk: { fields: [language_id], foreignTable: ibexa_content_language, foreignFields: [id], options: { onDelete: CASCADE, onUpdate: CASCADE } } + ibexa_content_type_field_definition_ml_lang_fk: { fields: [language_id], foreignTable: ibexa_language, foreignFields: [id], options: { onDelete: CASCADE, onUpdate: CASCADE } } ibexa_content_type_group_assignment: id: content_type_id: { type: integer, nullable: false, options: { default: '0' } } @@ -214,7 +208,6 @@ tables: ibexa_content: indexes: ibexa_content_type_id: { fields: [content_type_id] } - ibexa_content_lmask: { fields: [language_mask] } ibexa_content_pub: { fields: [published] } ibexa_content_section: { fields: [section_id] } ibexa_content_currentversion: { fields: [current_version] } @@ -227,8 +220,8 @@ tables: fields: content_type_id: { type: integer, nullable: false, options: { default: '0' } } current_version: { type: integer, nullable: true } - initial_language_id: { type: bigint, nullable: false, options: { default: '0' } } - language_mask: { type: bigint, nullable: false, options: { default: '0' } } + initial_language_id: { type: integer, nullable: false, options: { default: '0' } } + always_available: { type: boolean, nullable: false, options: { default: false } } modified: { type: integer, nullable: false, options: { default: '0' } } name: { type: string, nullable: true, length: 255 } owner_id: { type: integer, nullable: false, options: { default: '0' } } @@ -324,13 +317,31 @@ tables: contentobject_id: { type: integer, nullable: true } created: { type: integer, nullable: false, options: { default: '0' } } creator_id: { type: integer, nullable: false, options: { default: '0' } } - initial_language_id: { type: bigint, nullable: false, options: { default: '0' } } - language_mask: { type: bigint, nullable: false, options: { default: '0' } } + initial_language_id: { type: integer, nullable: false, options: { default: '0' } } + always_available: { type: boolean, nullable: false, options: { default: false } } modified: { type: integer, nullable: false, options: { default: '0' } } status: { type: integer, nullable: false, options: { default: '0' } } user_id: { type: integer, nullable: false, options: { default: '0' } } version: { type: integer, nullable: false, options: { default: '0' } } workflow_event_pos: { type: integer, nullable: true, options: { default: '0' } } + ibexa_content_translation: + indexes: + ibexa_content_translation_language: { fields: [language_id, content_id] } + id: + content_id: { type: integer, nullable: false } + language_id: { type: integer, nullable: false } + foreignKeys: + ibexa_content_translation_content_fk: { fields: [content_id], foreignTable: ibexa_content, foreignFields: [id], options: { onDelete: CASCADE, onUpdate: CASCADE } } + ibexa_content_translation_language_fk: { fields: [language_id], foreignTable: ibexa_language, foreignFields: [id], options: { onDelete: RESTRICT, onUpdate: CASCADE } } + ibexa_content_version_translation: + indexes: + ibexa_content_version_translation_language: { fields: [language_id, content_version_id] } + id: + content_version_id: { type: integer, nullable: false } + language_id: { type: integer, nullable: false } + foreignKeys: + ibexa_content_version_translation_version_fk: { fields: [content_version_id], foreignTable: ibexa_content_version, foreignFields: [id], options: { onDelete: CASCADE, onUpdate: CASCADE } } + ibexa_content_version_translation_language_fk: { fields: [language_id], foreignTable: ibexa_language, foreignFields: [id], options: { onDelete: RESTRICT, onUpdate: CASCADE } } ibexa_dfs_file: indexes: ibexa_dfs_file_name_trunk: { fields: [name_trunk], options: { lengths: ['191'] } } @@ -510,7 +521,8 @@ tables: published: { type: integer, nullable: false, options: { default: '0' } } section_id: { type: integer, nullable: false, options: { default: '0' } } word_id: { type: integer, nullable: false, options: { default: '0' } } - language_mask: { type: bigint, nullable: false, options: { default: '0' } } + language_id: { type: integer, nullable: false, options: { default: '0' } } + is_main_and_always_available: { type: boolean, nullable: false, options: { default: false } } ibexa_search_word: indexes: ibexa_search_word_word_i: { fields: [word] } @@ -576,7 +588,7 @@ tables: ibexa_url_alias_ml: indexes: ibexa_url_alias_ml_actt_org_al: { fields: [action_type, is_original, is_alias] } - ibexa_url_alias_ml_text_lang: { fields: [text, lang_mask, parent], options: { lengths: ['32', null, null] } } + ibexa_url_alias_ml_text_lang: { fields: [text, parent], options: { lengths: ['32', null] } } ibexa_url_alias_ml_par_act_id_lnk: { fields: [action, id, link, parent], options: { lengths: ['32', null, null, null] } } ibexa_url_alias_ml_par_lnk_txt: { fields: [parent, text, link], options: { lengths: [null, '32', null] } } ibexa_url_alias_ml_act_org: { fields: [action, is_original], options: { lengths: ['32', null] } } @@ -593,12 +605,22 @@ tables: id: { type: integer, nullable: false, options: { default: '0' } } is_alias: { type: integer, nullable: false, options: { default: '0' } } is_original: { type: integer, nullable: false, options: { default: '0' } } - lang_mask: { type: bigint, nullable: false, options: { default: '0' } } link: { type: integer, nullable: false, options: { default: '0' } } text: { type: text, nullable: false, length: 0 } + is_always_available: { type: boolean, nullable: false, options: { default: false } } ibexa_url_alias_ml_incr: id: id: { type: integer, nullable: false, options: { autoincrement: true } } + ibexa_url_alias_ml_translation: + indexes: + ibexa_url_alias_ml_translation_language: { fields: [language_id] } + id: + parent: { type: integer, nullable: false } + text_md5: { type: string, nullable: false, length: 32 } + language_id: { type: integer, nullable: false } + foreignKeys: + ibexa_url_alias_ml_translation_alias_fk: { fields: [parent, text_md5], foreignTable: ibexa_url_alias_ml, foreignFields: [parent, text_md5], options: { onDelete: CASCADE, onUpdate: CASCADE } } + ibexa_url_alias_ml_translation_language_fk: { fields: [language_id], foreignTable: ibexa_language, foreignFields: [id], options: { onDelete: RESTRICT, onUpdate: CASCADE } } ibexa_url_wildcard: id: id: { type: integer, nullable: false, options: { autoincrement: true } } diff --git a/src/bundle/RepositoryInstaller/Installer/CoreInstaller.php b/src/bundle/RepositoryInstaller/Installer/CoreInstaller.php index fabbc7331e..03fdc5a436 100644 --- a/src/bundle/RepositoryInstaller/Installer/CoreInstaller.php +++ b/src/bundle/RepositoryInstaller/Installer/CoreInstaller.php @@ -61,7 +61,7 @@ public function __construct( * (core's own {@see \Ibexa\Bundle\RepositoryInstaller\Migration\InstallSchemaMigration} plus any other * package's) via the application's Doctrine Migrations DependencyFactory. * - * @throws \Doctrine\DBAL\DBALException + * @throws \Doctrine\DBAL\Exception * @throws \RuntimeException if "ibexa.installer.schema_builder_event.enabled" is disabled but * "ibexa/doctrine-migrations" isn't installed/enabled to run the migrations-based path instead */ diff --git a/src/bundle/RepositoryInstaller/Migration/AddContentAlwaysAvailableColumnsMigration.php b/src/bundle/RepositoryInstaller/Migration/AddContentAlwaysAvailableColumnsMigration.php new file mode 100644 index 0000000000..68e0f7399f --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/AddContentAlwaysAvailableColumnsMigration.php @@ -0,0 +1,107 @@ +hasTable()/hasColumn() would always report false there. + */ +final class AddContentAlwaysAvailableColumnsMigration extends AbstractSqlMigration implements IbexaMigrationInterface +{ + private const CONTENT_TABLE = 'ibexa_content'; + private const CONTENT_VERSION_TABLE = 'ibexa_content_version'; + private const ALWAYS_AVAILABLE_COLUMN = 'always_available'; + + public function getDescription(): string + { + return 'Adds "always_available" columns to "ibexa_content" and "ibexa_content_version", backfilled from the language mask'; + } + + public static function getTargetVersion(): string + { + return '6.0.0'; + } + + public static function getCreationDate(): DateTimeImmutable + { + return new DateTimeImmutable('2026-08-09 00:00:00'); + } + + public function up(Schema $schema): void + { + $this->abortIfUnsupportedPlatform(SqlPlatform::MYSQL, SqlPlatform::POSTGRESQL, SqlPlatform::SQLITE); + + $schemaManager = $this->connection->createSchemaManager(); + + if (!$schemaManager->tablesExist([self::CONTENT_TABLE, self::CONTENT_VERSION_TABLE])) { + return; + } + + $hasContentColumn = $schemaManager->introspectTable(self::CONTENT_TABLE) + ->hasColumn(self::ALWAYS_AVAILABLE_COLUMN); + $hasContentVersionColumn = $schemaManager->introspectTable(self::CONTENT_VERSION_TABLE) + ->hasColumn(self::ALWAYS_AVAILABLE_COLUMN); + + if ($hasContentColumn && $hasContentVersionColumn) { + // Already fully applied - avoid an unconditional full-table backfill re-scan once this + // migration has genuinely completed. + return; + } + + if (!$hasContentColumn) { + $this->addSql($this->buildAddColumnSql(self::CONTENT_TABLE)); + } + + if (!$hasContentVersionColumn) { + $this->addSql($this->buildAddColumnSql(self::CONTENT_VERSION_TABLE)); + } + + $this->addSql($this->buildBackfillSql(self::CONTENT_TABLE)); + $this->addSql($this->buildBackfillSql(self::CONTENT_VERSION_TABLE)); + } + + private function buildAddColumnSql(string $table): string + { + $columnDefinition = match (true) { + $this->isMySQL() => "TINYINT(1) DEFAULT '0' NOT NULL", + $this->isPostgreSQL() => "BOOLEAN DEFAULT 'false' NOT NULL", + default => "BOOLEAN DEFAULT '0' NOT NULL", + }; + + return "ALTER TABLE {$table} ADD COLUMN " . self::ALWAYS_AVAILABLE_COLUMN . " {$columnDefinition}"; + } + + private function buildBackfillSql(string $table): string + { + $trueLiteral = $this->isPostgreSQL() ? 'true' : '1'; + + return "UPDATE {$table} SET " . self::ALWAYS_AVAILABLE_COLUMN . " = {$trueLiteral} WHERE (language_mask & 1) = 1"; + } +} diff --git a/src/bundle/RepositoryInstaller/Migration/AddLanguageTranslationTablesMigration.php b/src/bundle/RepositoryInstaller/Migration/AddLanguageTranslationTablesMigration.php new file mode 100644 index 0000000000..59399ce5af --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/AddLanguageTranslationTablesMigration.php @@ -0,0 +1,147 @@ +hasTable() would always report false there. + */ +final class AddLanguageTranslationTablesMigration extends AbstractSqlMigration implements IbexaMigrationInterface +{ + private const CONTENT_TABLE = 'ibexa_content'; + + /** + * Not yet renamed to "ibexa_language" at this point in the migration sequence - that rename + * happens later, in {@see NarrowLanguageIdColumnTypesMigration}. + * + * @var array> + */ + private const FOREIGN_KEYS = [ + 'ibexa_content_translation' => [ + 'ibexa_content_translation_content_fk' => 'FOREIGN KEY (content_id) REFERENCES ibexa_content (id) ON DELETE CASCADE ON UPDATE CASCADE', + 'ibexa_content_translation_language_fk' => 'FOREIGN KEY (language_id) REFERENCES ibexa_content_language (id) ON DELETE RESTRICT ON UPDATE CASCADE', + ], + 'ibexa_content_version_translation' => [ + 'ibexa_content_version_translation_version_fk' => 'FOREIGN KEY (content_version_id) REFERENCES ibexa_content_version (id) ON DELETE CASCADE ON UPDATE CASCADE', + 'ibexa_content_version_translation_language_fk' => 'FOREIGN KEY (language_id) REFERENCES ibexa_content_language (id) ON DELETE RESTRICT ON UPDATE CASCADE', + ], + 'ibexa_url_alias_ml_translation' => [ + 'ibexa_url_alias_ml_translation_alias_fk' => 'FOREIGN KEY (parent, text_md5) REFERENCES ibexa_url_alias_ml (parent, text_md5) ON DELETE CASCADE ON UPDATE CASCADE', + 'ibexa_url_alias_ml_translation_language_fk' => 'FOREIGN KEY (language_id) REFERENCES ibexa_content_language (id) ON DELETE RESTRICT ON UPDATE CASCADE', + ], + ]; + + public function getDescription(): string + { + return 'Creates "ibexa_content_translation", "ibexa_content_version_translation" and "ibexa_url_alias_ml_translation"'; + } + + public static function getTargetVersion(): string + { + return '6.0.0'; + } + + public static function getCreationDate(): DateTimeImmutable + { + return new DateTimeImmutable('2026-08-09 00:00:01'); + } + + public function up(Schema $schema): void + { + $this->abortIfUnsupportedPlatform(SqlPlatform::MYSQL, SqlPlatform::POSTGRESQL, SqlPlatform::SQLITE); + + $schemaManager = $this->connection->createSchemaManager(); + + if (!$schemaManager->tablesExist([self::CONTENT_TABLE])) { + return; + } + + $allTablesExist = $schemaManager->tablesExist(array_keys(self::FOREIGN_KEYS)); + + if ($this->isSqlite()) { + if ($allTablesExist) { + return; + } + + $this->addSqlFile(__DIR__ . '/sql/add-language-translation-tables-sqlite.sql'); + + return; + } + + $missingForeignKeys = $this->findMissingForeignKeys($schemaManager); + + if ($allTablesExist && $missingForeignKeys === []) { + return; + } + + if ($this->isMySQL()) { + $this->addSqlFile(__DIR__ . '/sql/add-language-translation-tables-mysql.sql'); + } elseif ($this->isPostgreSQL()) { + $this->addSqlFile(__DIR__ . '/sql/add-language-translation-tables-postgresql.sql'); + } + + foreach ($missingForeignKeys as [$table, $constraintName, $definition]) { + $this->addSql("ALTER TABLE {$table} ADD CONSTRAINT {$constraintName} {$definition}"); + } + } + + /** + * @param AbstractSchemaManager $schemaManager + * + * @return list + */ + private function findMissingForeignKeys(AbstractSchemaManager $schemaManager): array + { + $missing = []; + + foreach (self::FOREIGN_KEYS as $table => $foreignKeys) { + $existingTable = $schemaManager->tablesExist([$table]) + ? $schemaManager->introspectTable($table) + : null; + + foreach ($foreignKeys as $constraintName => $definition) { + if ($existingTable !== null && $existingTable->hasForeignKey($constraintName)) { + continue; + } + + $missing[] = [$table, $constraintName, $definition]; + } + } + + return $missing; + } +} diff --git a/src/bundle/RepositoryInstaller/Migration/AddSearchObjectWordLinkLanguageIdColumnsMigration.php b/src/bundle/RepositoryInstaller/Migration/AddSearchObjectWordLinkLanguageIdColumnsMigration.php new file mode 100644 index 0000000000..e2935ec1fb --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/AddSearchObjectWordLinkLanguageIdColumnsMigration.php @@ -0,0 +1,118 @@ +hasTable()/hasColumn() would always report false there. + */ +final class AddSearchObjectWordLinkLanguageIdColumnsMigration extends AbstractSqlMigration implements IbexaMigrationInterface +{ + private const TABLE = 'ibexa_search_object_word_link'; + private const LANGUAGE_ID_COLUMN = 'language_id'; + private const IS_MAIN_AND_ALWAYS_AVAILABLE_COLUMN = 'is_main_and_always_available'; + + public function getDescription(): string + { + return 'Adds "language_id" and "is_main_and_always_available" columns to "ibexa_search_object_word_link", backfilled from the language mask'; + } + + public static function getTargetVersion(): string + { + return '6.0.0'; + } + + public static function getCreationDate(): DateTimeImmutable + { + return new DateTimeImmutable('2026-08-09 00:00:03'); + } + + public function up(Schema $schema): void + { + $this->abortIfUnsupportedPlatform(SqlPlatform::MYSQL, SqlPlatform::POSTGRESQL, SqlPlatform::SQLITE); + + $schemaManager = $this->connection->createSchemaManager(); + + if (!$schemaManager->tablesExist([self::TABLE])) { + return; + } + + $table = $schemaManager->introspectTable(self::TABLE); + $hasLanguageIdColumn = $table->hasColumn(self::LANGUAGE_ID_COLUMN); + $hasAlwaysAvailableColumn = $table->hasColumn(self::IS_MAIN_AND_ALWAYS_AVAILABLE_COLUMN); + + if ($hasLanguageIdColumn && $hasAlwaysAvailableColumn) { + // Already fully applied - avoid an unconditional full-table backfill re-scan once this + // migration has genuinely completed. + return; + } + + if (!$hasLanguageIdColumn) { + $this->addSql($this->buildAddLanguageIdColumnSql()); + } + + if (!$hasAlwaysAvailableColumn) { + $this->addSql($this->buildAddAlwaysAvailableColumnSql()); + } + + $this->addSql( + 'UPDATE ' . self::TABLE . ' SET ' . self::LANGUAGE_ID_COLUMN . ' = (language_mask & -2)' + ); + $this->addSql( + 'UPDATE ' . self::TABLE . ' SET ' . self::IS_MAIN_AND_ALWAYS_AVAILABLE_COLUMN . ' = ' . + ($this->isPostgreSQL() ? 'true' : '1') . ' WHERE (language_mask & 1) = 1' + ); + } + + private function buildAddLanguageIdColumnSql(): string + { + $columnDefinition = $this->isMySQL() ? "INT DEFAULT '0' NOT NULL" : 'INTEGER DEFAULT 0 NOT NULL'; + + return 'ALTER TABLE ' . self::TABLE . ' ADD COLUMN ' . self::LANGUAGE_ID_COLUMN . " {$columnDefinition}"; + } + + private function buildAddAlwaysAvailableColumnSql(): string + { + $columnDefinition = match (true) { + $this->isMySQL() => "TINYINT(1) DEFAULT '0' NOT NULL", + $this->isPostgreSQL() => "BOOLEAN DEFAULT 'false' NOT NULL", + default => "BOOLEAN DEFAULT '0' NOT NULL", + }; + + return 'ALTER TABLE ' . self::TABLE . ' ADD COLUMN ' . self::IS_MAIN_AND_ALWAYS_AVAILABLE_COLUMN . + " {$columnDefinition}"; + } +} diff --git a/src/bundle/RepositoryInstaller/Migration/AddUrlAliasAlwaysAvailableColumnMigration.php b/src/bundle/RepositoryInstaller/Migration/AddUrlAliasAlwaysAvailableColumnMigration.php new file mode 100644 index 0000000000..594085ad16 --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/AddUrlAliasAlwaysAvailableColumnMigration.php @@ -0,0 +1,84 @@ +hasTable()/hasColumn() would always report false there. + */ +final class AddUrlAliasAlwaysAvailableColumnMigration extends AbstractSqlMigration implements IbexaMigrationInterface +{ + private const TABLE = 'ibexa_url_alias_ml'; + private const ALWAYS_AVAILABLE_COLUMN = 'is_always_available'; + + public function getDescription(): string + { + return 'Adds "is_always_available" column to "ibexa_url_alias_ml", backfilled from the language mask'; + } + + public static function getTargetVersion(): string + { + return '6.0.0'; + } + + public static function getCreationDate(): DateTimeImmutable + { + return new DateTimeImmutable('2026-08-09 00:00:04'); + } + + public function up(Schema $schema): void + { + $this->abortIfUnsupportedPlatform(SqlPlatform::MYSQL, SqlPlatform::POSTGRESQL, SqlPlatform::SQLITE); + + $schemaManager = $this->connection->createSchemaManager(); + + if (!$schemaManager->tablesExist([self::TABLE])) { + return; + } + + if (!$schemaManager->introspectTable(self::TABLE)->hasColumn(self::ALWAYS_AVAILABLE_COLUMN)) { + $columnDefinition = match (true) { + $this->isMySQL() => "TINYINT(1) DEFAULT '0' NOT NULL", + $this->isPostgreSQL() => "BOOLEAN DEFAULT 'false' NOT NULL", + default => "BOOLEAN DEFAULT '0' NOT NULL", + }; + + $this->addSql( + 'ALTER TABLE ' . self::TABLE . ' ADD COLUMN ' . self::ALWAYS_AVAILABLE_COLUMN . " {$columnDefinition}" + ); + } + + $trueLiteral = $this->isPostgreSQL() ? 'true' : '1'; + + $this->addSql( + 'UPDATE ' . self::TABLE . ' SET ' . self::ALWAYS_AVAILABLE_COLUMN . + " = {$trueLiteral} WHERE (lang_mask & 1) = 1" + ); + } +} diff --git a/src/bundle/RepositoryInstaller/Migration/BackfillLanguageTranslationsMigration.php b/src/bundle/RepositoryInstaller/Migration/BackfillLanguageTranslationsMigration.php new file mode 100644 index 0000000000..f1a4777282 --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/BackfillLanguageTranslationsMigration.php @@ -0,0 +1,177 @@ +hasTable()/hasColumn() would always report false there. + */ +final class BackfillLanguageTranslationsMigration extends AbstractSqlMigration implements IbexaMigrationInterface +{ + private const BATCH_SIZE = 5000; + + public function getDescription(): string + { + return 'Backfills the language translation tables from the legacy language bitmask columns'; + } + + public static function getTargetVersion(): string + { + return '6.0.0'; + } + + public static function getCreationDate(): DateTimeImmutable + { + return new DateTimeImmutable('2026-08-09 00:00:02'); + } + + public function isTransactional(): bool + { + return false; + } + + public function up(Schema $schema): void + { + $this->abortIfUnsupportedPlatform(SqlPlatform::MYSQL, SqlPlatform::POSTGRESQL, SqlPlatform::SQLITE); + + if (!$this->connection->createSchemaManager()->tablesExist(['ibexa_content_translation'])) { + // AddLanguageTranslationTablesMigration hasn't run yet (or "ibexa_content" itself + // doesn't exist - a project-only install without core's own content tables). + return; + } + + $this->backfillTable( + 'ibexa_content', + 'language_mask', + 'id', + 'INSERT %s INTO ibexa_content_translation (content_id, language_id) + SELECT c.id, l.id FROM ibexa_content c + JOIN ibexa_content_language l ON (c.language_mask & l.id) = l.id + WHERE c.id BETWEEN :from AND :to %s' + ); + + $this->backfillTable( + 'ibexa_content_version', + 'language_mask', + 'id', + 'INSERT %s INTO ibexa_content_version_translation (content_version_id, language_id) + SELECT v.id, l.id FROM ibexa_content_version v + JOIN ibexa_content_language l ON (v.language_mask & l.id) = l.id + WHERE v.id BETWEEN :from AND :to %s' + ); + + $this->backfillTable( + 'ibexa_url_alias_ml', + 'lang_mask', + 'parent', + 'INSERT %s INTO ibexa_url_alias_ml_translation (parent, text_md5, language_id) + SELECT u.parent, u.text_md5, l.id FROM ibexa_url_alias_ml u + JOIN ibexa_content_language l ON (u.lang_mask & l.id) = l.id + WHERE u.parent BETWEEN :from AND :to %s' + ); + } + + private function backfillTable( + string $sourceTable, + string $maskColumn, + string $pkColumn, + string $insertSqlTemplate + ): void { + $schemaManager = $this->connection->createSchemaManager(); + if ( + !$schemaManager->tablesExist([$sourceTable]) + || !$schemaManager->introspectTable($sourceTable)->hasColumn($maskColumn) + ) { + // Column already dropped (re-running after a prior upgrade already completed this + // step), or a fresh install whose schema.yaml never had it. + return; + } + + // MIN()/MAX() rather than COUNT()-based emptiness + a hardcoded lower bound of 1: some of + // these tables (e.g. "ibexa_url_alias_ml" for root-level aliases) legitimately use 0 as a + // valid primary-key value, so neither "MAX() === 0" nor an assumed start of 1 is safe here. + $range = $this->connection->fetchAssociative( + "SELECT MIN({$pkColumn}) AS min_id, MAX({$pkColumn}) AS max_id FROM {$sourceTable}" + ); + if ($range === false || $range['min_id'] === null) { + return; + } + + $minId = (int)$range['min_id']; + $maxId = (int)$range['max_id']; + + $insertSql = sprintf($insertSqlTemplate, $this->insertIgnoreKeyword(), $this->onConflictClause()); + + for ($from = $minId; $from <= $maxId; $from += self::BATCH_SIZE) { + $to = min($from + self::BATCH_SIZE - 1, $maxId); + $this->addSql( + $insertSql, + ['from' => $from, 'to' => $to], + ['from' => ParameterType::INTEGER, 'to' => ParameterType::INTEGER] + ); + } + } + + private function insertIgnoreKeyword(): string + { + if ($this->isMySQL()) { + return 'IGNORE'; + } + if ($this->isSqlite()) { + return 'OR IGNORE'; + } + + return ''; + } + + /** + * Appended after the SELECT to make the insert idempotent on platforms that don't support + * "INSERT IGNORE" (MySQL is handled via insertIgnoreKeyword() instead, since its "ON DUPLICATE + * KEY" clause needs different syntax for an INSERT ... SELECT). + */ + private function onConflictClause(): string + { + if ($this->isPostgreSQL()) { + return 'ON CONFLICT DO NOTHING'; + } + + return ''; + } +} diff --git a/src/bundle/RepositoryInstaller/Migration/DropLanguageBitmaskColumnsMigration.php b/src/bundle/RepositoryInstaller/Migration/DropLanguageBitmaskColumnsMigration.php new file mode 100644 index 0000000000..33233bc956 --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/DropLanguageBitmaskColumnsMigration.php @@ -0,0 +1,230 @@ +hasTable()/hasColumn() would always report false there. + */ +final class DropLanguageBitmaskColumnsMigration extends AbstractSqlMigration implements IbexaMigrationInterface +{ + private const CONTENT_TABLE = 'ibexa_content'; + + /** + * Not yet renamed to "ibexa_language" at this point in the migration sequence - that rename + * happens later, in {@see NarrowLanguageIdColumnTypesMigration}. + */ + private const LANGUAGE_TABLE = 'ibexa_content_language'; + + /** + * Table => [mask column, index to drop (if any), whether the index needs replacing with a + * lang-less equivalent rather than just dropped (only "ibexa_url_alias_ml")]. + * + * @var array + */ + private const DROPS = [ + 'ibexa_object_state' => ['language_mask', 'ibexa_object_state_lmask', false], + 'ibexa_object_state_group' => ['language_mask', 'ibexa_object_state_group_lmask', false], + 'ibexa_content_type' => ['language_mask', null, false], + self::CONTENT_TABLE => ['language_mask', 'ibexa_content_lmask', false], + 'ibexa_content_version' => ['language_mask', null, false], + 'ibexa_search_object_word_link' => ['language_mask', null, false], + 'ibexa_url_alias_ml' => ['lang_mask', 'ibexa_url_alias_ml_text_lang', true], + ]; + + /** + * Which of {@see DROPS} also need a `abortIfTranslationsNotBackfilled()` check before their + * column is dropped, and what to check it against. + */ + private const BACKFILL_CHECKS = [ + self::CONTENT_TABLE => ['ibexa_content_translation', 'content_id'], + 'ibexa_content_version' => ['ibexa_content_version_translation', 'content_version_id'], + 'ibexa_url_alias_ml' => ['ibexa_url_alias_ml_translation', null], + ]; + + public function getDescription(): string + { + return 'Drops the language bitmask ("language_mask"/"lang_mask") columns, now that nothing reads them'; + } + + public static function getTargetVersion(): string + { + return '6.0.0'; + } + + public static function getCreationDate(): DateTimeImmutable + { + return new DateTimeImmutable('2026-08-09 00:00:05'); + } + + public function up(Schema $schema): void + { + $this->abortIfUnsupportedPlatform(SqlPlatform::MYSQL, SqlPlatform::POSTGRESQL, SqlPlatform::SQLITE); + + $schemaManager = $this->connection->createSchemaManager(); + + if (!$schemaManager->tablesExist([self::CONTENT_TABLE])) { + return; + } + + $tablesStillCarryingMask = []; + + foreach (self::DROPS as $table => [$maskColumn]) { + if ( + $schemaManager->tablesExist([$table]) + && $schemaManager->introspectTable($table)->hasColumn($maskColumn) + ) { + $tablesStillCarryingMask[$table] = true; + } + } + + if ($tablesStillCarryingMask === []) { + // Already dropped everywhere (or a fresh install whose schema.yaml never had it). + return; + } + + $this->abortIfTranslationsNotBackfilled(array_keys($tablesStillCarryingMask)); + + foreach (self::DROPS as $table => [$maskColumn, $indexName, $replaceIndex]) { + if (!isset($tablesStillCarryingMask[$table])) { + continue; + } + + $introspectedTable = $schemaManager->introspectTable($table); + + if ($indexName !== null) { + $indexExists = $introspectedTable->hasIndex($indexName); + $indexStillHasMaskColumn = $indexExists + && in_array($maskColumn, $introspectedTable->getIndex($indexName)->getColumns(), true); + + if ($replaceIndex) { + if ($indexStillHasMaskColumn) { + // Still the old, mask-including definition - drop it before recreating + // without the mask column below. + $this->addSql($this->buildDropIndexSql($table, $indexName)); + } + + if ($indexStillHasMaskColumn || !$indexExists) { + // Either just dropped above, or missing entirely because a prior partial + // run dropped it but was interrupted before recreating it - either way it + // still needs (re)creating in its final, mask-less form. If neither is true, + // the index already exists in that final form and needs no change. + $this->addSql($this->buildCreateUrlAliasTextParentIndexSql()); + } + } elseif ($indexExists) { + $this->addSql($this->buildDropIndexSql($table, $indexName)); + } + } + + $this->addSql("ALTER TABLE {$table} DROP COLUMN {$maskColumn}"); + } + } + + private function buildDropIndexSql(string $table, string $indexName): string + { + // MySQL ties an index's identity to its table ("DROP INDEX x ON t" / "ALTER TABLE t DROP + // INDEX x"); PostgreSQL/SQLite index names are unique connection/schema-wide, dropped + // without referencing the table. + return $this->isMySQL() + ? "ALTER TABLE {$table} DROP INDEX {$indexName}" + : "DROP INDEX {$indexName}"; + } + + private function buildCreateUrlAliasTextParentIndexSql(): string + { + // Replaces the dropped "(text(32), parent)"/"(text, parent)" + lang index with a lang-less + // equivalent - "lang_mask" is gone, but the (text, parent) lookup itself is still needed. + return $this->isMySQL() + ? 'ALTER TABLE ibexa_url_alias_ml ADD INDEX ibexa_url_alias_ml_text_lang (text(32), parent)' + : 'CREATE INDEX ibexa_url_alias_ml_text_lang ON ibexa_url_alias_ml (text, parent)'; + } + + /** + * Refuses to drop the mask columns if any row still carrying a real (non-always-available) + * language bit has no corresponding row in the relational replacement it should have been + * backfilled into - i.e. `ibexa:languages:backfill-translations` was never run, or didn't + * finish, for this table. Once the mask columns are gone the mask data is unrecoverable, so + * this check is deliberately a hard abort rather than a warning. + * + * Joins against every language bit actually set in the mask (via {@see LANGUAGE_TABLE}, the + * table of valid bit values) rather than just checking "does any translation row exist for this + * content id at all" - a row with two bits set but only one backfilled (e.g. mask 2|4 with only + * language 2's translation row written) must still be caught here, or dropping the mask below + * would silently and irrecoverably lose language 4's membership for that row. + * + * Only checks tables in $tablesStillCarryingMask (rather than unconditionally checking all of + * {@see BACKFILL_CHECKS}): once a table's mask column is actually dropped, the column this + * check's SQL references no longer exists, so re-running it unconditionally on a later retry + * would fail with an unrelated "unknown column" error instead of the intended abort message. + * + * @param string[] $tablesStillCarryingMask + */ + private function abortIfTranslationsNotBackfilled(array $tablesStillCarryingMask): void + { + foreach (self::BACKFILL_CHECKS as $maskTable => [$translationTable, $idColumn]) { + if (!in_array($maskTable, $tablesStillCarryingMask, true)) { + continue; + } + + $maskColumn = $maskTable === 'ibexa_url_alias_ml' ? 'lang_mask' : 'language_mask'; + + if ($idColumn !== null) { + $joinCondition = "t.{$idColumn} = m.id AND t.language_id = l.id"; + } else { + // ibexa_url_alias_ml's primary key is (parent, text_md5), not a single "id" column. + $joinCondition = 't.parent = m.parent AND t.text_md5 = m.text_md5 AND t.language_id = l.id'; + } + + $missingCount = (int)$this->connection->fetchOne( + "SELECT COUNT(*) FROM {$maskTable} m + JOIN " . self::LANGUAGE_TABLE . " l ON (m.{$maskColumn} & l.id) = l.id + WHERE NOT EXISTS (SELECT 1 FROM {$translationTable} t WHERE {$joinCondition})" + ); + + $this->abortIf( + $missingCount > 0, + "Refusing to drop \"{$maskColumn}\" from \"{$maskTable}\": {$missingCount} row(s) carry a " . + "real language bit with no matching row in \"{$translationTable}\" - run " . + '"ibexa:languages:backfill-translations" (and confirm ' . + '"ibexa:languages:verify-translations" reports clean) before this migration.' + ); + } + } +} diff --git a/src/bundle/RepositoryInstaller/Migration/NarrowLanguageIdColumnTypesMigration.php b/src/bundle/RepositoryInstaller/Migration/NarrowLanguageIdColumnTypesMigration.php new file mode 100644 index 0000000000..df0a40388d --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/NarrowLanguageIdColumnTypesMigration.php @@ -0,0 +1,128 @@ +hasTable()/hasColumn() would always report false there. + */ +final class NarrowLanguageIdColumnTypesMigration extends AbstractSqlMigration implements IbexaMigrationInterface +{ + private const OLD_LANGUAGE_TABLE = 'ibexa_content_language'; + private const LANGUAGE_TABLE = 'ibexa_language'; + private const LANGUAGE_TABLE_ID_COLUMN = 'id'; + + public function getDescription(): string + { + return 'Renames "ibexa_content_language" to "ibexa_language" and narrows "language_id"-shaped columns from BIGINT to INTEGER'; + } + + public static function getTargetVersion(): string + { + return '6.0.0'; + } + + public static function getCreationDate(): DateTimeImmutable + { + return new DateTimeImmutable('2026-08-09 00:00:06'); + } + + public function up(Schema $schema): void + { + $this->abortIfUnsupportedPlatform(SqlPlatform::MYSQL, SqlPlatform::POSTGRESQL, SqlPlatform::SQLITE); + + $schemaManager = $this->connection->createSchemaManager(); + + // Both branches are checked (rather than assuming "old name gone" means "already renamed by + // this migration") so re-running after a partial/interrupted execution - or a fresh install, + // which never has either at this point outside a schema.yaml-driven install - is a no-op. + $currentTableName = match (true) { + $schemaManager->tablesExist([self::OLD_LANGUAGE_TABLE]) => self::OLD_LANGUAGE_TABLE, + $schemaManager->tablesExist([self::LANGUAGE_TABLE]) => self::LANGUAGE_TABLE, + default => null, + }; + + if ($currentTableName === null) { + return; + } + + $needsRename = $currentTableName === self::OLD_LANGUAGE_TABLE; + $idColumn = $schemaManager->introspectTable($currentTableName)->getColumn(self::LANGUAGE_TABLE_ID_COLUMN); + $needsNarrowing = $idColumn->getType() instanceof BigIntType; + + if (!$needsRename && !$needsNarrowing) { + return; + } + + if ($this->isSqlite()) { + if ($needsRename) { + $this->addSql(sprintf('ALTER TABLE %s RENAME TO %s', self::OLD_LANGUAGE_TABLE, self::LANGUAGE_TABLE)); + } + + // No ALTER COLUMN TYPE support on SQLite - see class docblock. + return; + } + + if ($needsRename) { + $this->addSql( + $this->isMySQL() + ? sprintf('RENAME TABLE %s TO %s', self::OLD_LANGUAGE_TABLE, self::LANGUAGE_TABLE) + : sprintf('ALTER TABLE %s RENAME TO %s', self::OLD_LANGUAGE_TABLE, self::LANGUAGE_TABLE) + ); + } + + if ($needsNarrowing) { + if ($this->isMySQL()) { + $this->addSqlFile(__DIR__ . '/sql/narrow-language-id-column-types-mysql.sql'); + } elseif ($this->isPostgreSQL()) { + $this->addSqlFile(__DIR__ . '/sql/narrow-language-id-column-types-postgresql.sql'); + } + } + } +} diff --git a/src/bundle/RepositoryInstaller/Migration/sql/add-language-translation-tables-mysql.sql b/src/bundle/RepositoryInstaller/Migration/sql/add-language-translation-tables-mysql.sql new file mode 100644 index 0000000000..0d8f6ce46d --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/sql/add-language-translation-tables-mysql.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS ibexa_content_translation ( + content_id INT NOT NULL, + language_id BIGINT NOT NULL, + INDEX ibexa_content_translation_language (language_id, content_id), + PRIMARY KEY(content_id, language_id) +) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB; +-- ibexa:sql-statement-separator +CREATE TABLE IF NOT EXISTS ibexa_content_version_translation ( + content_version_id INT NOT NULL, + language_id BIGINT NOT NULL, + INDEX ibexa_content_version_translation_language (language_id, content_version_id), + PRIMARY KEY(content_version_id, language_id) +) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB; +-- ibexa:sql-statement-separator +CREATE TABLE IF NOT EXISTS ibexa_url_alias_ml_translation ( + parent INT NOT NULL, + text_md5 VARCHAR(32) NOT NULL, + language_id BIGINT NOT NULL, + INDEX ibexa_url_alias_ml_translation_language (language_id), + PRIMARY KEY(parent, text_md5, language_id) +) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB; diff --git a/src/bundle/RepositoryInstaller/Migration/sql/add-language-translation-tables-postgresql.sql b/src/bundle/RepositoryInstaller/Migration/sql/add-language-translation-tables-postgresql.sql new file mode 100644 index 0000000000..6ce52a1371 --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/sql/add-language-translation-tables-postgresql.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS ibexa_content_translation ( + content_id INT NOT NULL, + language_id BIGINT NOT NULL, + PRIMARY KEY(content_id, language_id) +); +-- ibexa:sql-statement-separator +CREATE INDEX IF NOT EXISTS ibexa_content_translation_language ON ibexa_content_translation (language_id, content_id); +-- ibexa:sql-statement-separator +CREATE TABLE IF NOT EXISTS ibexa_content_version_translation ( + content_version_id INT NOT NULL, + language_id BIGINT NOT NULL, + PRIMARY KEY(content_version_id, language_id) +); +-- ibexa:sql-statement-separator +CREATE INDEX IF NOT EXISTS ibexa_content_version_translation_language ON ibexa_content_version_translation (language_id, content_version_id); +-- ibexa:sql-statement-separator +CREATE TABLE IF NOT EXISTS ibexa_url_alias_ml_translation ( + parent INT NOT NULL, + text_md5 VARCHAR(32) NOT NULL, + language_id BIGINT NOT NULL, + PRIMARY KEY(parent, text_md5, language_id) +); +-- ibexa:sql-statement-separator +CREATE INDEX IF NOT EXISTS ibexa_url_alias_ml_translation_language ON ibexa_url_alias_ml_translation (language_id); diff --git a/src/bundle/RepositoryInstaller/Migration/sql/add-language-translation-tables-sqlite.sql b/src/bundle/RepositoryInstaller/Migration/sql/add-language-translation-tables-sqlite.sql new file mode 100644 index 0000000000..2d56c9fcfe --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/sql/add-language-translation-tables-sqlite.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS ibexa_content_translation ( + content_id INTEGER NOT NULL, + language_id BIGINT NOT NULL, + PRIMARY KEY(content_id, language_id), + CONSTRAINT ibexa_content_translation_content_fk + FOREIGN KEY (content_id) REFERENCES ibexa_content (id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT ibexa_content_translation_language_fk + FOREIGN KEY (language_id) REFERENCES ibexa_content_language (id) + ON DELETE RESTRICT ON UPDATE CASCADE +); +-- ibexa:sql-statement-separator +CREATE INDEX IF NOT EXISTS ibexa_content_translation_language ON ibexa_content_translation (language_id, content_id); +-- ibexa:sql-statement-separator +CREATE TABLE IF NOT EXISTS ibexa_content_version_translation ( + content_version_id INTEGER NOT NULL, + language_id BIGINT NOT NULL, + PRIMARY KEY(content_version_id, language_id), + CONSTRAINT ibexa_content_version_translation_version_fk + FOREIGN KEY (content_version_id) REFERENCES ibexa_content_version (id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT ibexa_content_version_translation_language_fk + FOREIGN KEY (language_id) REFERENCES ibexa_content_language (id) + ON DELETE RESTRICT ON UPDATE CASCADE +); +-- ibexa:sql-statement-separator +CREATE INDEX IF NOT EXISTS ibexa_content_version_translation_language ON ibexa_content_version_translation (language_id, content_version_id); +-- ibexa:sql-statement-separator +CREATE TABLE IF NOT EXISTS ibexa_url_alias_ml_translation ( + parent INTEGER NOT NULL, + text_md5 VARCHAR(32) NOT NULL, + language_id BIGINT NOT NULL, + PRIMARY KEY(parent, text_md5, language_id), + CONSTRAINT ibexa_url_alias_ml_translation_alias_fk + FOREIGN KEY (parent, text_md5) REFERENCES ibexa_url_alias_ml (parent, text_md5) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT ibexa_url_alias_ml_translation_language_fk + FOREIGN KEY (language_id) REFERENCES ibexa_content_language (id) + ON DELETE RESTRICT ON UPDATE CASCADE +); +-- ibexa:sql-statement-separator +CREATE INDEX IF NOT EXISTS ibexa_url_alias_ml_translation_language ON ibexa_url_alias_ml_translation (language_id); diff --git a/src/bundle/RepositoryInstaller/Migration/sql/narrow-language-id-column-types-mysql.sql b/src/bundle/RepositoryInstaller/Migration/sql/narrow-language-id-column-types-mysql.sql new file mode 100644 index 0000000000..7a363b3573 --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/sql/narrow-language-id-column-types-mysql.sql @@ -0,0 +1,53 @@ +ALTER TABLE ibexa_content_translation DROP FOREIGN KEY ibexa_content_translation_language_fk; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_version_translation DROP FOREIGN KEY ibexa_content_version_translation_language_fk; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_url_alias_ml_translation DROP FOREIGN KEY ibexa_url_alias_ml_translation_language_fk; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_type_field_definition_ml DROP FOREIGN KEY ibexa_content_type_field_definition_ml_lang_fk; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_language MODIFY COLUMN id INT NOT NULL DEFAULT 0; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_translation MODIFY COLUMN language_id INT NOT NULL; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_version_translation MODIFY COLUMN language_id INT NOT NULL; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_url_alias_ml_translation MODIFY COLUMN language_id INT NOT NULL; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_type_field_definition_ml MODIFY COLUMN language_id INT NOT NULL; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content MODIFY COLUMN initial_language_id INT NOT NULL DEFAULT 0; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_version MODIFY COLUMN initial_language_id INT NOT NULL DEFAULT 0; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_type MODIFY COLUMN initial_language_id INT NOT NULL DEFAULT 0; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state MODIFY COLUMN default_language_id INT NOT NULL DEFAULT 0; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state_group MODIFY COLUMN default_language_id INT NOT NULL DEFAULT 0; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state_language MODIFY COLUMN language_id INT NOT NULL DEFAULT 0; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state_group_language MODIFY COLUMN language_id INT NOT NULL DEFAULT 0; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state_group_language MODIFY COLUMN real_language_id INT NOT NULL DEFAULT 0; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_translation + ADD CONSTRAINT ibexa_content_translation_language_fk + FOREIGN KEY (language_id) REFERENCES ibexa_language (id) + ON DELETE RESTRICT ON UPDATE CASCADE; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_version_translation + ADD CONSTRAINT ibexa_content_version_translation_language_fk + FOREIGN KEY (language_id) REFERENCES ibexa_language (id) + ON DELETE RESTRICT ON UPDATE CASCADE; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_url_alias_ml_translation + ADD CONSTRAINT ibexa_url_alias_ml_translation_language_fk + FOREIGN KEY (language_id) REFERENCES ibexa_language (id) + ON DELETE RESTRICT ON UPDATE CASCADE; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_type_field_definition_ml + ADD CONSTRAINT ibexa_content_type_field_definition_ml_lang_fk + FOREIGN KEY (language_id) REFERENCES ibexa_language (id) + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/bundle/RepositoryInstaller/Migration/sql/narrow-language-id-column-types-postgresql.sql b/src/bundle/RepositoryInstaller/Migration/sql/narrow-language-id-column-types-postgresql.sql new file mode 100644 index 0000000000..2df75213b3 --- /dev/null +++ b/src/bundle/RepositoryInstaller/Migration/sql/narrow-language-id-column-types-postgresql.sql @@ -0,0 +1,53 @@ +ALTER TABLE ibexa_content_translation DROP CONSTRAINT ibexa_content_translation_language_fk; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_version_translation DROP CONSTRAINT ibexa_content_version_translation_language_fk; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_url_alias_ml_translation DROP CONSTRAINT ibexa_url_alias_ml_translation_language_fk; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_type_field_definition_ml DROP CONSTRAINT ibexa_content_type_field_definition_ml_lang_fk; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_language ALTER COLUMN id TYPE INTEGER USING id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_translation ALTER COLUMN language_id TYPE INTEGER USING language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_version_translation ALTER COLUMN language_id TYPE INTEGER USING language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_url_alias_ml_translation ALTER COLUMN language_id TYPE INTEGER USING language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_type_field_definition_ml ALTER COLUMN language_id TYPE INTEGER USING language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content ALTER COLUMN initial_language_id TYPE INTEGER USING initial_language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_version ALTER COLUMN initial_language_id TYPE INTEGER USING initial_language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_type ALTER COLUMN initial_language_id TYPE INTEGER USING initial_language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state ALTER COLUMN default_language_id TYPE INTEGER USING default_language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state_group ALTER COLUMN default_language_id TYPE INTEGER USING default_language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state_language ALTER COLUMN language_id TYPE INTEGER USING language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state_group_language ALTER COLUMN language_id TYPE INTEGER USING language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_object_state_group_language ALTER COLUMN real_language_id TYPE INTEGER USING real_language_id::INTEGER; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_translation + ADD CONSTRAINT ibexa_content_translation_language_fk + FOREIGN KEY (language_id) REFERENCES ibexa_language (id) + ON DELETE RESTRICT ON UPDATE CASCADE; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_version_translation + ADD CONSTRAINT ibexa_content_version_translation_language_fk + FOREIGN KEY (language_id) REFERENCES ibexa_language (id) + ON DELETE RESTRICT ON UPDATE CASCADE; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_url_alias_ml_translation + ADD CONSTRAINT ibexa_url_alias_ml_translation_language_fk + FOREIGN KEY (language_id) REFERENCES ibexa_language (id) + ON DELETE RESTRICT ON UPDATE CASCADE; +-- ibexa:sql-statement-separator +ALTER TABLE ibexa_content_type_field_definition_ml + ADD CONSTRAINT ibexa_content_type_field_definition_ml_lang_fk + FOREIGN KEY (language_id) REFERENCES ibexa_language (id) + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/contracts/Test/Persistence/Fixture/FixtureImporter.php b/src/contracts/Test/Persistence/Fixture/FixtureImporter.php index 973c6aa8cd..8c03746d67 100644 --- a/src/contracts/Test/Persistence/Fixture/FixtureImporter.php +++ b/src/contracts/Test/Persistence/Fixture/FixtureImporter.php @@ -10,8 +10,10 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception as DBALException; +use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Schema\Column; use Ibexa\Contracts\Core\Test\Persistence\Fixture; +use Ibexa\Contracts\DoctrineSchema\SchemaAssetsFilterBypassInterface; /** * Database fixture importer. @@ -22,12 +24,18 @@ final class FixtureImporter { private Connection $connection; + private SchemaAssetsFilterBypassInterface $schemaAssetsFilterBypass; + /** @var array */ private static array $resetSequenceStatements = []; - public function __construct(Connection $connection) + /** @var array */ + private static array $existingColumnsByTable = []; + + public function __construct(Connection $connection, SchemaAssetsFilterBypassInterface $schemaAssetsFilterBypass) { $this->connection = $connection; + $this->schemaAssetsFilterBypass = $schemaAssetsFilterBypass; } /** @@ -48,14 +56,219 @@ static function ($tableData): bool { } ); foreach ($nonEmptyTablesData as $table => $rows) { + // Fixtures predate columns being dropped over time (e.g. the language bitmask + // columns) - silently drop unknown keys rather than letting every fixture file need + // updating in lockstep with schema changes. + $existingColumns = $this->getExistingColumns($table); foreach ($rows as $row) { - $this->connection->insert($table, $row); + $this->connection->insert($table, array_intersect_key($row, array_flip($existingColumns))); } } if ($this->connection->getDatabasePlatform()->supportsSequences()) { $this->resetSequences($tablesList); } + + $this->backfillLanguageBitmaskColumns($nonEmptyTablesData); + } + + /** + * @return string[] + */ + private function getExistingColumns(string $table): array + { + if (!isset(self::$existingColumnsByTable[$table])) { + $columns = $this->connection->createSchemaManager()->listTableColumns($table); + self::$existingColumnsByTable[$table] = array_map( + static fn (Column $column): string => $column->getName(), + $columns + ); + } + + return self::$existingColumnsByTable[$table]; + } + + /** + * Fixture data predates "always_available"/"is_always_available" becoming plain columns and + * the "ibexa_content_translation"/"ibexa_content_version_translation"/ + * "ibexa_url_alias_ml_translation" join tables - it only ever set "language_mask"/"lang_mask", + * which import() now silently drops (see getExistingColumns()) since the column may no longer + * exist. Backfill the modern columns/tables from those same fixture values here, once, so every + * fixture-loading path behaves like rows actually written through the gateways - mirrors what + * the real Add*AlwaysAvailableColumns/AddLanguageTranslationTables/ + * AddSearchObjectWordLinkLanguageIdColumns migrations backfill for production upgrades. + * + * Reimplements the (small, stable) bitmask decode directly rather than depending on + * Persistence\Legacy's MaskGenerator - this is a test-only concern in a Contracts package that + * must not depend on a specific storage engine's internals. + * + * @param array>> $nonEmptyTablesData + */ + private function backfillLanguageBitmaskColumns(array $nonEmptyTablesData): void + { + if (!$this->tableExists('ibexa_content_translation')) { + // Schema predates this migration entirely (or has already dropped the join tables in + // some hypothetical future) - nothing to backfill into. + return; + } + + $validLanguageIds = null; + + // The join tables aren't part of the fixture's own table list, so import()'s + // truncate-then-insert loop never clears them directly; do it explicitly rather than rely + // on FK cascade behavior varying by platform/driver. + if (!empty($nonEmptyTablesData['ibexa_content'])) { + $this->connection->executeStatement('DELETE FROM ibexa_content_translation'); + } + if (!empty($nonEmptyTablesData['ibexa_content_version'])) { + $this->connection->executeStatement('DELETE FROM ibexa_content_version_translation'); + } + if (!empty($nonEmptyTablesData['ibexa_url_alias_ml']) && $this->tableExists('ibexa_url_alias_ml_translation')) { + $this->connection->executeStatement('DELETE FROM ibexa_url_alias_ml_translation'); + } + + if (!empty($nonEmptyTablesData['ibexa_content'])) { + $validLanguageIds = $this->loadValidLanguageIds(); + foreach ($nonEmptyTablesData['ibexa_content'] as $row) { + if (!array_key_exists('language_mask', $row)) { + continue; + } + $mask = (int)$row['language_mask']; + $this->connection->executeStatement( + 'UPDATE ibexa_content SET always_available = :alwaysAvailable WHERE id = :id', + ['alwaysAvailable' => ($mask & 1) === 1, 'id' => $row['id']], + ['alwaysAvailable' => ParameterType::BOOLEAN, 'id' => ParameterType::INTEGER] + ); + foreach ($this->extractLanguageIds($mask, $validLanguageIds) as $languageId) { + $this->connection->executeStatement( + 'INSERT INTO ibexa_content_translation (content_id, language_id) VALUES (:id, :languageId)', + ['id' => $row['id'], 'languageId' => $languageId], + ['id' => ParameterType::INTEGER, 'languageId' => ParameterType::INTEGER] + ); + } + } + } + + if (!empty($nonEmptyTablesData['ibexa_content_version'])) { + $validLanguageIds ??= $this->loadValidLanguageIds(); + foreach ($nonEmptyTablesData['ibexa_content_version'] as $row) { + if (!array_key_exists('language_mask', $row)) { + continue; + } + $mask = (int)$row['language_mask']; + $this->connection->executeStatement( + 'UPDATE ibexa_content_version SET always_available = :alwaysAvailable WHERE id = :id', + ['alwaysAvailable' => ($mask & 1) === 1, 'id' => $row['id']], + ['alwaysAvailable' => ParameterType::BOOLEAN, 'id' => ParameterType::INTEGER] + ); + foreach ($this->extractLanguageIds($mask, $validLanguageIds) as $languageId) { + $this->connection->executeStatement( + 'INSERT INTO ibexa_content_version_translation (content_version_id, language_id) VALUES (:id, :languageId)', + ['id' => $row['id'], 'languageId' => $languageId], + ['id' => ParameterType::INTEGER, 'languageId' => ParameterType::INTEGER] + ); + } + } + } + + if (!empty($nonEmptyTablesData['ibexa_url_alias_ml']) && $this->tableExists('ibexa_url_alias_ml_translation')) { + $validLanguageIds ??= $this->loadValidLanguageIds(); + foreach ($nonEmptyTablesData['ibexa_url_alias_ml'] as $row) { + if (!array_key_exists('lang_mask', $row)) { + continue; + } + $mask = (int)$row['lang_mask']; + $this->connection->executeStatement( + 'UPDATE ibexa_url_alias_ml SET is_always_available = :alwaysAvailable WHERE parent = :parent AND text_md5 = :textMd5', + ['alwaysAvailable' => ($mask & 1) === 1, 'parent' => $row['parent'], 'textMd5' => $row['text_md5']], + ['alwaysAvailable' => ParameterType::BOOLEAN, 'parent' => ParameterType::INTEGER, 'textMd5' => ParameterType::STRING] + ); + foreach ($this->extractLanguageIds($mask, $validLanguageIds) as $languageId) { + $this->connection->executeStatement( + 'INSERT INTO ibexa_url_alias_ml_translation (parent, text_md5, language_id) VALUES (:parent, :textMd5, :languageId)', + ['parent' => $row['parent'], 'textMd5' => $row['text_md5'], 'languageId' => $languageId], + ['parent' => ParameterType::INTEGER, 'textMd5' => ParameterType::STRING, 'languageId' => ParameterType::INTEGER] + ); + } + } + } + + if (!empty($nonEmptyTablesData['ibexa_search_object_word_link']) && $this->tableExists('ibexa_search_object_word_link') && in_array('language_id', $this->getExistingColumns('ibexa_search_object_word_link'), true)) { + foreach ($nonEmptyTablesData['ibexa_search_object_word_link'] as $row) { + if (!array_key_exists('language_mask', $row)) { + continue; + } + $mask = (int)$row['language_mask']; + $this->connection->executeStatement( + 'UPDATE ibexa_search_object_word_link + SET language_id = :languageId, is_main_and_always_available = :alwaysAvailable + WHERE id = :id', + [ + 'languageId' => $mask & ~1, + 'alwaysAvailable' => ($mask & 1) === 1, + 'id' => $row['id'], + ], + [ + 'languageId' => ParameterType::INTEGER, + 'alwaysAvailable' => ParameterType::BOOLEAN, + 'id' => ParameterType::INTEGER, + ] + ); + } + } + } + + /** + * tablesExist() goes through AbstractSchemaManager::listTableNames(), which is filtered by + * whatever schema assets filter is configured on the connection (e.g. + * ManagedTablesSchemaAssetFilter, which hides every table not backed by a registered ORM + * entity - i.e. all of Ibexa's own legacy/join tables). Bypass it, same as + * LegacySchemaImporter does, or this always reports these tables as absent and every backfill + * below silently no-ops. + */ + private function tableExists(string $table): bool + { + return $this->schemaAssetsFilterBypass->call( + $this->connection, + fn (): bool => $this->connection->createSchemaManager()->tablesExist([$table]) + ); + } + + /** + * @return int[] + */ + private function loadValidLanguageIds(): array + { + if (!$this->tableExists('ibexa_language')) { + return []; + } + + return array_map( + 'intval', + $this->connection->fetchFirstColumn('SELECT id FROM ibexa_language') + ); + } + + /** + * Decodes real (non-always-available) language ids out of a legacy bitmask, restricted to ids + * actually present in ibexa_language (mirrors the old SQL backfill's implicit + * JOIN ibexa_language filter, needed since ibexa_content_translation's language_id has + * a real FK to it). + * + * @param int[] $validLanguageIds + * + * @return int[] + */ + private function extractLanguageIds(int $mask, array $validLanguageIds): array + { + $languageIds = []; + for ($languageId = 2; $languageId <= $mask; $languageId *= 2) { + if (($mask & $languageId) === $languageId && in_array($languageId, $validLanguageIds, true)) { + $languageIds[] = $languageId; + } + } + + return $languageIds; } /** diff --git a/src/contracts/Test/Repository/SetupFactory/Legacy.php b/src/contracts/Test/Repository/SetupFactory/Legacy.php index 7854fd61af..61bc4d4aac 100644 --- a/src/contracts/Test/Repository/SetupFactory/Legacy.php +++ b/src/contracts/Test/Repository/SetupFactory/Legacy.php @@ -162,7 +162,7 @@ public function insertData(): void $connection = $this->getDatabaseConnection(); $this->cleanupVarDir($this->getInitialVarDir()); - $fixtureImporter = new FixtureImporter($connection); + $fixtureImporter = new FixtureImporter($connection, new SchemaAssetsFilterBypass()); $fixtureImporter->import($this->getInitialDataFixture()); } diff --git a/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php index 825b6f63ed..817dc173ee 100644 --- a/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php @@ -33,7 +33,6 @@ use Ibexa\Core\Base\Exceptions\NotFoundException as NotFound; use Ibexa\Core\Persistence\Legacy\Content\Gateway; use Ibexa\Core\Persistence\Legacy\Content\Gateway\DoctrineDatabase\QueryBuilder; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\Location\Gateway as LocationGateway; use Ibexa\Core\Persistence\Legacy\Content\StorageFieldValue; use Ibexa\Core\Persistence\Legacy\SharedGateway\Gateway as SharedGateway; @@ -49,18 +48,11 @@ */ final class DoctrineDatabase extends Gateway { - /** - * Pre-computed integer constant which, when combined with proper bit-wise operator, - * removes always available flag from the mask. - */ - private const int REMOVE_ALWAYS_AVAILABLE_LANG_MASK_OPERAND = -2; - public function __construct( protected Connection $connection, private readonly SharedGateway $sharedGateway, protected QueryBuilder $queryBuilder, - protected LanguageHandler $languageHandler, - protected LanguageMaskGenerator $languageMaskGenerator + protected LanguageHandler $languageHandler ) { } @@ -104,20 +96,22 @@ public function insertContentObject(CreateStruct $struct, int $currentVersionNo ContentInfo::STATUS_DRAFT, ParameterType::INTEGER ), - 'language_mask' => $query->createPositionalParameter( - $this->languageMaskGenerator->generateLanguageMaskForFields( - $struct->fields, - $initialLanguageCode, - $struct->alwaysAvailable - ), - ParameterType::INTEGER + 'always_available' => $query->createPositionalParameter( + $struct->alwaysAvailable, + ParameterType::BOOLEAN ), ] ); $query->executeStatement(); - return (int)$this->connection->lastInsertId(); + $contentId = (int)$this->connection->lastInsertId(); + $this->insertContentTranslations( + $contentId, + $this->collectFieldLanguageCodes($struct->fields, $initialLanguageCode) + ); + + return $contentId; } public function insertVersion(VersionInfo $versionInfo, array $fields): int @@ -157,20 +151,112 @@ public function insertVersion(VersionInfo $versionInfo, array $fields): int $versionInfo->contentInfo->id, ParameterType::INTEGER ), - 'language_mask' => $query->createPositionalParameter( - $this->languageMaskGenerator->generateLanguageMaskForFields( - $fields, - $versionInfo->initialLanguageCode, - $versionInfo->contentInfo->alwaysAvailable - ), - ParameterType::INTEGER + 'always_available' => $query->createPositionalParameter( + $versionInfo->contentInfo->alwaysAvailable, + ParameterType::BOOLEAN ), ] ); $query->executeStatement(); - return (int)$this->connection->lastInsertId(); + $versionId = (int)$this->connection->lastInsertId(); + $this->insertVersionTranslations( + $versionId, + $this->collectFieldLanguageCodes($fields, $versionInfo->initialLanguageCode) + ); + + return $versionId; + } + + /** + * Collects the unique language codes a Content/Version's fields are written in, always + * including $initialLanguageCode, for populating "ibexa_content_translation"/ + * "ibexa_content_version_translation". + * + * @param \Ibexa\Contracts\Core\Persistence\Content\Field[] $fields + * + * @return string[] + */ + private function collectFieldLanguageCodes(array $fields, string $initialLanguageCode): array + { + $languageCodes = [$initialLanguageCode => true]; + foreach ($fields as $field) { + $languageCodes[$field->languageCode] = true; + } + + return array_keys($languageCodes); + } + + /** + * @param string[] $languageCodes + */ + private function insertContentTranslations(int $contentId, array $languageCodes): void + { + foreach (array_unique($languageCodes) as $languageCode) { + $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id; + $this->connection->executeStatement( + 'INSERT INTO ibexa_content_translation (content_id, language_id) VALUES (:contentId, :languageId)', + ['contentId' => $contentId, 'languageId' => $languageId], + ['contentId' => ParameterType::INTEGER, 'languageId' => ParameterType::INTEGER] + ); + } + } + + /** + * @param string[] $languageCodes + */ + private function insertVersionTranslations(int $versionId, array $languageCodes): void + { + foreach (array_unique($languageCodes) as $languageCode) { + $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id; + $this->connection->executeStatement( + 'INSERT INTO ibexa_content_version_translation (content_version_id, language_id) VALUES (:versionId, :languageId)', + ['versionId' => $versionId, 'languageId' => $languageId], + ['versionId' => ParameterType::INTEGER, 'languageId' => ParameterType::INTEGER] + ); + } + } + + /** + * Adds $languageCodes to Version $versionId's translations, leaving any already present + * untouched - mirrors updateVersion()'s additive bit-OR merge onto "language_mask". + * + * @param string[] $languageCodes + */ + private function addVersionTranslationsIfMissing(int $versionId, array $languageCodes): void + { + foreach (array_unique($languageCodes) as $languageCode) { + $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id; + $this->connection->executeStatement( + 'INSERT INTO ibexa_content_version_translation (content_version_id, language_id) + SELECT :versionId, :languageId + WHERE NOT EXISTS ( + SELECT 1 FROM ibexa_content_version_translation + WHERE content_version_id = :versionId AND language_id = :languageId + )', + ['versionId' => $versionId, 'languageId' => $languageId], + ['versionId' => ParameterType::INTEGER, 'languageId' => ParameterType::INTEGER] + ); + } + } + + /** + * Replaces Content $contentId's translations with exactly $languageCodes - mirrors + * updateContent()'s publish-time replace of "language_mask" with the newly published + * version's language set (not a union with whatever the content previously had). + * + * @param string[] $languageCodes + */ + private function replaceContentTranslations(int $contentId, array $languageCodes): void + { + $this->connection->executeStatement( + 'DELETE FROM ibexa_content_translation WHERE content_id = :contentId', + ['contentId' => $contentId], + ['contentId' => ParameterType::INTEGER] + ); + + $this->insertContentTranslations($contentId, $languageCodes); } public function updateContent( @@ -226,13 +312,10 @@ public function updateContent( } if ($prePublishVersionInfo !== null) { - $mask = $this->languageMaskGenerator->generateLanguageMaskFromLanguageCodes( - $prePublishVersionInfo->languageCodes, - $struct->alwaysAvailable ?? $prePublishVersionInfo->contentInfo->alwaysAvailable - ); + $alwaysAvailable = $struct->alwaysAvailable ?? $prePublishVersionInfo->contentInfo->alwaysAvailable; $query->set( - 'language_mask', - $query->createNamedParameter($mask, ParameterType::INTEGER, ':languageMask') + 'always_available', + $query->createNamedParameter($alwaysAvailable, ParameterType::BOOLEAN, ':alwaysAvailable') ); $hasSetClause = true; } @@ -252,6 +335,10 @@ public function updateContent( if (isset($struct->alwaysAvailable) || isset($struct->mainLanguageId)) { $this->updateAlwaysAvailableFlag($contentId, $struct->alwaysAvailable); } + + if ($prePublishVersionInfo !== null) { + $this->replaceContentTranslations($contentId, $prePublishVersionInfo->languageCodes); + } } /** @@ -268,13 +355,6 @@ public function updateVersion(int $contentId, int $versionNo, UpdateStruct $stru ->set('creator_id', ':creator_id') ->set('modified', ':modified') ->set('initial_language_id', ':initial_language_id') - ->set( - 'language_mask', - $this->getDatabasePlatform()->getBitOrComparisonExpression( - 'language_mask', - ':language_mask' - ) - ) ->setParameter('creator_id', $struct->creatorId, ParameterType::INTEGER) ->setParameter('modified', $struct->modificationDate, ParameterType::INTEGER) ->setParameter( @@ -282,179 +362,54 @@ public function updateVersion(int $contentId, int $versionNo, UpdateStruct $stru $struct->initialLanguageId, ParameterType::INTEGER ) - ->setParameter( - 'language_mask', - $this->languageMaskGenerator->generateLanguageMaskForFields( - $struct->fields, - $this->languageHandler->load($struct->initialLanguageId)->languageCode, - false - ), - ParameterType::INTEGER - ) ->where('contentobject_id = :content_id') ->andWhere('version = :version_no') ->setParameter('content_id', $contentId, ParameterType::INTEGER) ->setParameter('version_no', $versionNo, ParameterType::INTEGER); $query->executeStatement(); + + $versionId = $this->connection->createQueryBuilder() + ->select('id') + ->from(self::CONTENT_VERSION_TABLE) + ->where('contentobject_id = :content_id') + ->andWhere('version = :version_no') + ->setParameter('content_id', $contentId, ParameterType::INTEGER) + ->setParameter('version_no', $versionNo, ParameterType::INTEGER) + ->executeQuery() + ->fetchOne(); + + $this->addVersionTranslationsIfMissing( + (int)$versionId, + $this->collectFieldLanguageCodes( + $struct->fields, + $this->languageHandler->load($struct->initialLanguageId)->languageCode + ) + ); } public function updateAlwaysAvailableFlag(int $contentId, ?bool $alwaysAvailable = null): void { - // We will need to know some info on the current language mask to update the flag - // everywhere needed - $contentInfoRow = $this->loadContentInfo($contentId); - $versionNo = (int)$contentInfoRow['current_version']; - $languageMask = (int)$contentInfoRow['language_mask']; - $initialLanguageId = (int)$contentInfoRow['initial_language_id']; if (!isset($alwaysAvailable)) { - $alwaysAvailable = 1 === ($languageMask & 1); + $contentInfoRow = $this->loadContentInfo($contentId); + $alwaysAvailable = (bool)$contentInfoRow['always_available']; } - $this->updateContentItemAlwaysAvailableFlag($contentId, $alwaysAvailable); - $this->updateContentNameAlwaysAvailableFlag( - $contentId, - $versionNo, - $alwaysAvailable - ); - $this->updateContentFieldsAlwaysAvailableFlag( - $contentId, - $versionNo, - $alwaysAvailable, - $languageMask, - $initialLanguageId - ); - } - - private function updateContentItemAlwaysAvailableFlag( - int $contentId, - bool $alwaysAvailable - ): void { $query = $this->connection->createQueryBuilder(); $expr = $query->expr(); $query - ->update(self::CONTENT_ITEM_TABLE); - $this - ->setLanguageMaskForUpdateQuery($alwaysAvailable, $query, 'language_mask') - ->where( - $expr->eq( - 'id', - $query->createNamedParameter($contentId, ParameterType::INTEGER, ':contentId') - ) - ); - $query->executeStatement(); - } - - private function updateContentNameAlwaysAvailableFlag( - int $contentId, - int $versionNo, - bool $alwaysAvailable - ): void { - $query = $this->connection->createQueryBuilder(); - $expr = $query->expr(); - $query - ->update(self::CONTENT_NAME_TABLE); - $this - ->setLanguageMaskForUpdateQuery($alwaysAvailable, $query, 'language_id') - ->where( - $expr->eq( - 'contentobject_id', - $query->createNamedParameter($contentId, ParameterType::INTEGER, ':contentId') - ) + ->update(self::CONTENT_ITEM_TABLE) + ->set( + 'always_available', + $query->createNamedParameter($alwaysAvailable, ParameterType::BOOLEAN, ':alwaysAvailable') ) - ->andWhere( - $expr->eq( - 'content_version', - $query->createNamedParameter($versionNo, ParameterType::INTEGER, ':versionNo') - ) - ); - $query->executeStatement(); - } - - private function updateContentFieldsAlwaysAvailableFlag( - int $contentId, - int $versionNo, - bool $alwaysAvailable, - int $languageMask, - int $initialLanguageId - ): void { - $query = $this->connection->createQueryBuilder(); - $expr = $query->expr(); - $query - ->update(self::CONTENT_FIELD_TABLE) ->where( $expr->eq( - 'contentobject_id', + 'id', $query->createNamedParameter($contentId, ParameterType::INTEGER, ':contentId') ) - ) - ->andWhere( - $expr->eq( - 'version', - $query->createNamedParameter($versionNo, ParameterType::INTEGER, ':versionNo') - ) ); - - // If there is only a single language, update all fields and return - if (!$this->languageMaskGenerator->isLanguageMaskComposite($languageMask)) { - $this->setLanguageMaskForUpdateQuery($alwaysAvailable, $query, 'language_id'); - - $query->executeStatement(); - - return; - } - - // Otherwise: - // 1. Remove always available flag on all fields - $query - ->set( - 'language_id', - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'language_id', - ':languageMaskOperand' - ) - ) - ->setParameter('languageMaskOperand', self::REMOVE_ALWAYS_AVAILABLE_LANG_MASK_OPERAND) - ; $query->executeStatement(); - - // 2. If Content is always available set the flag only on fields in main language - if ($alwaysAvailable) { - $mainLanguageQuery = $this->connection->createQueryBuilder(); - $mainLanguageExpr = $mainLanguageQuery->expr(); - $mainLanguageQuery - ->update(self::CONTENT_FIELD_TABLE) - ->where( - $mainLanguageExpr->eq( - 'contentobject_id', - $mainLanguageQuery->createNamedParameter($contentId, ParameterType::INTEGER, ':contentId') - ) - ) - ->andWhere( - $mainLanguageExpr->eq( - 'version', - $mainLanguageQuery->createNamedParameter($versionNo, ParameterType::INTEGER, ':versionNo') - ) - ) - ->set( - 'language_id', - $this->getDatabasePlatform()->getBitOrComparisonExpression( - 'language_id', - ':languageMaskOperand' - ) - ) - ->setParameter('languageMaskOperand', 1) - ->andWhere( - $mainLanguageExpr->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'language_id', - $mainLanguageQuery->createNamedParameter($initialLanguageId, ParameterType::INTEGER, ':initialLanguageId') - ), - $mainLanguageQuery->createNamedParameter(0, ParameterType::INTEGER, ':zero') - ) - ); - $mainLanguageQuery->executeStatement(); - } } public function setStatus(int $contentId, int $version, int $status): bool @@ -607,25 +562,11 @@ private function setInsertFieldValues( ) ->setParameter( 'language_id', - $this->languageMaskGenerator->generateLanguageIndicator( - $field->languageCode, - $this->isLanguageAlwaysAvailable($content, $field->languageCode) - ), + $this->languageHandler->loadByLanguageCode($field->languageCode)->id, ParameterType::INTEGER ); } - /** - * Check if $languageCode is always available in $content. - */ - private function isLanguageAlwaysAvailable(Content $content, string $languageCode): bool - { - return - $content->versionInfo->contentInfo->alwaysAvailable && - $content->versionInfo->contentInfo->mainLanguageCode === $languageCode - ; - } - public function updateField(Field $field, StorageFieldValue $value): void { // Note, no need to care for language_id here, since Content->$alwaysAvailable @@ -724,7 +665,7 @@ private function internalLoadContent( 'c.published AS content_published', 'c.status AS content_status', 'c.name AS content_name', - 'c.language_mask AS content_language_mask', + 'c.always_available AS content_always_available', 'c.is_hidden AS content_is_hidden', 'v.id AS content_version_id', 'v.version AS content_version_version', @@ -732,7 +673,7 @@ private function internalLoadContent( 'v.creator_id AS content_version_creator_id', 'v.created AS content_version_created', 'v.status AS content_version_status', - 'v.language_mask AS content_version_language_mask', + 'v.always_available AS content_version_always_available', 'v.initial_language_id AS content_version_initial_language_id', 'a.id AS content_field_id', 'a.content_type_field_definition_id AS content_field_content_type_field_definition_id', @@ -1379,7 +1320,7 @@ public function setName(int $contentId, int $version, string $name, string $lang 'content_version' => ':version_no', 'content_translation' => ':language_code', 'name' => ':name', - 'language_id' => $this->getSetNameLanguageMaskSubQuery(), + 'language_id' => ':language_id', 'real_translation' => ':language_code', ] ); @@ -1387,7 +1328,7 @@ public function setName(int $contentId, int $version, string $name, string $lang $query ->update(self::CONTENT_NAME_TABLE) ->set('name', ':name') - ->set('language_id', $this->getSetNameLanguageMaskSubQuery()) + ->set('language_id', ':language_id') ->set('real_translation', ':language_code') ->where('contentobject_id = :content_id') ->andWhere('content_version = :version_no') @@ -1397,19 +1338,6 @@ public function setName(int $contentId, int $version, string $name, string $lang $query->executeStatement(); } - /** - * Return a language sub select query for setName. - * - * The query generates the proper language mask at the runtime of the INSERT/UPDATE query - * generated by setName. - * - * @see setName - */ - private function getSetNameLanguageMaskSubQuery(): string - { - return $this->sharedGateway->getSetNameLanguageMaskSubQuery(); - } - public function deleteContent(int $contentId): void { $query = $this->connection->createQueryBuilder(); @@ -1973,7 +1901,7 @@ private function deleteTranslationFromContentNames( } /** - * Remove language from language_mask of ibexa_content. + * Remove language from ibexa_content_translation. * * @param int $contentId * @param int $languageId @@ -1982,36 +1910,38 @@ private function deleteTranslationFromContentNames( */ private function deleteTranslationFromContentObject($contentId, $languageId) { + $hasOtherLanguages = (bool)$this->connection->fetchOne( + 'SELECT 1 FROM ibexa_content_translation WHERE content_id = :contentId AND language_id != :languageId', + ['contentId' => $contentId, 'languageId' => $languageId], + ['contentId' => ParameterType::INTEGER, 'languageId' => ParameterType::INTEGER] + ); + + // most likely somehow it was the last remaining translation + if (!$hasOtherLanguages) { + throw new BadStateException( + '$languageCode', + 'The provided translation is the only translation in this version' + ); + } + $query = $this->connection->createQueryBuilder(); $query->update(Gateway::CONTENT_ITEM_TABLE) - // parameter for bitwise operation has to be placed verbatim (w/o binding) for this to work cross-DBMS - ->set('language_mask', 'language_mask & ~ ' . $languageId) ->set('modified', ':now') ->where('id = :contentId') - ->andWhere( - // make sure removed translation is not the last one (incl. alwaysAvailable) - $query->expr()->and( - 'language_mask & ~ ' . $languageId . ' <> 0', - 'language_mask & ~ ' . $languageId . ' <> 1' - ) - ) ->setParameter('now', time()) ->setParameter('contentId', $contentId) ; + $query->executeStatement(); - $rowCount = $query->executeQuery(); - - // no rows updated means that most likely somehow it was the last remaining translation - if ($rowCount === 0) { - throw new BadStateException( - '$languageCode', - 'The provided translation is the only translation in this version' - ); - } + $this->connection->executeStatement( + 'DELETE FROM ibexa_content_translation WHERE content_id = :contentId AND language_id = :languageId', + ['contentId' => $contentId, 'languageId' => $languageId], + ['contentId' => ParameterType::INTEGER, 'languageId' => ParameterType::INTEGER] + ); } /** - * Remove language from language_mask of ibexa_content_version and update initialLanguageId + * Remove language from ibexa_content_version_translation and update initialLanguageId * if it matches the removed one. * * @param int|null $versionNo optional, if specified, apply to this Version only. @@ -2024,10 +1954,9 @@ private function deleteTranslationFromContentVersions( ?int $versionNo = null ) { $contentTable = Gateway::CONTENT_ITEM_TABLE; + $versionTable = Gateway::CONTENT_VERSION_TABLE; $query = $this->connection->createQueryBuilder(); - $query->update(Gateway::CONTENT_VERSION_TABLE) - // parameter for bitwise operation has to be placed verbatim (w/o binding) for this to work cross-DBMS - ->set('language_mask', 'language_mask & ~ ' . $languageId) + $query->update($versionTable) ->set('modified', ':now') // update initial_language_id only if it matches removed translation languageId ->set( @@ -2039,10 +1968,7 @@ private function deleteTranslationFromContentVersions( ->where('contentobject_id = :contentId') ->andWhere( // make sure removed translation is not the last one (incl. alwaysAvailable) - $query->expr()->and( - 'language_mask & ~ ' . $languageId . ' <> 0', - 'language_mask & ~ ' . $languageId . ' <> 1' - ) + "EXISTS (SELECT 1 FROM ibexa_content_version_translation cvt WHERE cvt.content_version_id = {$versionTable}.id AND cvt.language_id != :languageId)" ) ->setParameter('now', time()) ->setParameter('contentId', $contentId) @@ -2056,47 +1982,57 @@ private function deleteTranslationFromContentVersions( ; } - $rowCount = $query->executeStatement(); + // Checked as its own query rather than via the UPDATE's affected-row count: MySQL's PDO + // driver reports "rows changed", not "rows matched", by default - if a version's + // "modified"/"initial_language_id" happen to already hold the values being set (e.g. two + // operations landing within the same time() second), MySQL reports 0 affected rows even + // though the WHERE/EXISTS clause matched, which would incorrectly look like "no other + // translation exists" here. + $hasOtherLanguagesQuery = $this->connection->createQueryBuilder(); + $hasOtherLanguagesQuery + ->select('1') + ->from($versionTable) + ->where('contentobject_id = :contentId') + ->andWhere( + "EXISTS (SELECT 1 FROM ibexa_content_version_translation cvt WHERE cvt.content_version_id = {$versionTable}.id AND cvt.language_id != :languageId)" + ) + ->setParameter('contentId', $contentId) + ->setParameter('languageId', $languageId) + ; + + if (null !== $versionNo) { + $hasOtherLanguagesQuery + ->andWhere('version = :versionNo') + ->setParameter('versionNo', $versionNo) + ; + } + + $hasOtherLanguages = (bool)$hasOtherLanguagesQuery->executeQuery()->fetchOne(); - // no rows updated means that most likely somehow it was the last remaining translation - if ($rowCount === 0) { + // most likely somehow it was the last remaining translation + if (!$hasOtherLanguages) { throw new BadStateException( '$languageCode', 'The provided translation is the only translation in this version' ); } - } - /** - * Compute language mask and append it to a QueryBuilder (both column and parameter). - * - * **Can be used on UPDATE queries only!** - */ - private function setLanguageMaskForUpdateQuery( - bool $alwaysAvailable, - DoctrineQueryBuilder $query, - string $languageMaskColumnName - ): DoctrineQueryBuilder { - if ($alwaysAvailable) { - $languageMaskExpr = $this->getDatabasePlatform()->getBitOrComparisonExpression( - $languageMaskColumnName, - ':languageMaskOperand' - ); - } else { - $languageMaskExpr = $this->getDatabasePlatform()->getBitAndComparisonExpression( - $languageMaskColumnName, - ':languageMaskOperand' - ); - } + $query->executeStatement(); - $query - ->set($languageMaskColumnName, $languageMaskExpr) - ->setParameter( - 'languageMaskOperand', - $alwaysAvailable ? 1 : self::REMOVE_ALWAYS_AVAILABLE_LANG_MASK_OPERAND - ); + $deleteQuery = 'DELETE FROM ibexa_content_version_translation + WHERE language_id = :languageId + AND content_version_id IN ( + SELECT id FROM ibexa_content_version WHERE contentobject_id = :contentId' + . (null !== $versionNo ? ' AND version = :versionNo' : '') . ')'; - return $query; + $deleteParams = ['contentId' => $contentId, 'languageId' => $languageId]; + $deleteTypes = ['contentId' => ParameterType::INTEGER, 'languageId' => ParameterType::INTEGER]; + if (null !== $versionNo) { + $deleteParams['versionNo'] = $versionNo; + $deleteTypes['versionNo'] = ParameterType::INTEGER; + } + + $this->connection->executeStatement($deleteQuery, $deleteParams, $deleteTypes); } /** diff --git a/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase/QueryBuilder.php b/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase/QueryBuilder.php index 79f048af82..df56b29650 100644 --- a/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase/QueryBuilder.php +++ b/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase/QueryBuilder.php @@ -148,7 +148,7 @@ public function createVersionInfoFindQueryBuilder(): DoctrineQueryBuilder 'v.status AS content_version_status', 'v.contentobject_id AS content_version_contentobject_id', 'v.initial_language_id AS content_version_initial_language_id', - 'v.language_mask AS content_version_language_mask', + 'v.always_available AS content_version_always_available', // Content main location 't.main_node_id AS content_tree_main_node_id', // Content object @@ -163,7 +163,7 @@ public function createVersionInfoFindQueryBuilder(): DoctrineQueryBuilder 'c.published AS content_published', 'c.status AS content_status', 'c.name AS content_name', - 'c.language_mask AS content_language_mask', + 'c.always_available AS content_always_available', 'c.is_hidden AS content_is_hidden' ) ->from(Gateway::CONTENT_VERSION_TABLE, 'v') diff --git a/src/lib/Persistence/Legacy/Content/Language/Gateway.php b/src/lib/Persistence/Legacy/Content/Language/Gateway.php index eba63900a7..52eec4ec76 100644 --- a/src/lib/Persistence/Legacy/Content/Language/Gateway.php +++ b/src/lib/Persistence/Legacy/Content/Language/Gateway.php @@ -12,7 +12,6 @@ use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; use Ibexa\Core\Persistence\Legacy\Content\ObjectState\Gateway as ObjectStateGateway; use Ibexa\Core\Persistence\Legacy\Content\Type\Gateway as ContentTypeGateway; -use Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway as UrlAliasGateway; /** * Content Model language gateway. @@ -21,30 +20,29 @@ */ abstract class Gateway { - public const CONTENT_LANGUAGE_TABLE = 'ibexa_content_language'; + public const CONTENT_LANGUAGE_TABLE = 'ibexa_language'; /** - * A map of language-related table name to its language column. + * A map of language-related table name to the single column identifying a real language id + * that row references (an explicit id column, never a bitmask). * - * The first column is considered to be a language bitmask. - * The second, optional, column is an explicit language id. + * "ibexa_content"/"ibexa_content_version" (their "initial_language_id"), the URL alias + * translations table, and the Legacy Search Engine's word index are checked explicitly in + * {@see DoctrineDatabase::canDeleteLanguage()} instead of via this map. * * It depends on the schema defined in * ./src/bundle/Core/Resources/config/storage/legacy/schema.yaml */ public const MULTILINGUAL_TABLES_COLUMNS = [ - ObjectStateGateway::OBJECT_STATE_TABLE => ['language_mask', 'default_language_id'], + ObjectStateGateway::OBJECT_STATE_TABLE => ['default_language_id'], ObjectStateGateway::OBJECT_STATE_GROUP_LANGUAGE_TABLE => ['language_id'], - ObjectStateGateway::OBJECT_STATE_GROUP_TABLE => ['language_mask', 'default_language_id'], + ObjectStateGateway::OBJECT_STATE_GROUP_TABLE => ['default_language_id'], ObjectStateGateway::OBJECT_STATE_LANGUAGE_TABLE => ['language_id'], ContentTypeGateway::MULTILINGUAL_FIELD_DEFINITION_TABLE => ['language_id'], ContentTypeGateway::CONTENT_TYPE_NAME_TABLE => ['language_id'], - ContentTypeGateway::CONTENT_TYPE_TABLE => ['language_mask', 'initial_language_id'], + ContentTypeGateway::CONTENT_TYPE_TABLE => ['initial_language_id'], ContentGateway::CONTENT_FIELD_TABLE => ['language_id'], ContentGateway::CONTENT_NAME_TABLE => ['language_id'], - ContentGateway::CONTENT_VERSION_TABLE => ['language_mask', 'initial_language_id'], - ContentGateway::CONTENT_ITEM_TABLE => ['language_mask', 'initial_language_id'], - UrlAliasGateway::TABLE => ['lang_mask'], ]; /** @@ -89,4 +87,25 @@ abstract public function deleteLanguage(int $id): void; * Check whether a language may be deleted. */ abstract public function canDeleteLanguage(int $id): bool; + + /** + * Loads which languages each of the given Content ids is translated into, from + * "ibexa_content_translation" (the relational replacement for "ibexa_content.language_mask"). + * + * @param int[] $contentIds + * + * @return array Content id => language ids + */ + abstract public function loadContentTranslations(array $contentIds): array; + + /** + * Loads which languages each of the given Content Version ids is translated into, from + * "ibexa_content_version_translation" (the relational replacement for + * "ibexa_content_version.language_mask"). + * + * @param int[] $versionIds + * + * @return array Version id => language ids + */ + abstract public function loadVersionTranslations(array $versionIds): array; } diff --git a/src/lib/Persistence/Legacy/Content/Language/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Content/Language/Gateway/DoctrineDatabase.php index a325d53c5f..ef51f9095d 100644 --- a/src/lib/Persistence/Legacy/Content/Language/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Content/Language/Gateway/DoctrineDatabase.php @@ -10,14 +10,11 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; use Doctrine\DBAL\ParameterType; -use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Query\QueryBuilder; use Ibexa\Contracts\Core\Persistence\Content\Language; -use Ibexa\Core\Base\Exceptions\DatabaseException; +use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway; -use RuntimeException; /** * Doctrine database based Language Gateway. @@ -43,14 +40,12 @@ public function insertLanguage(Language $language): int $lastId = (int)$statement->fetchOne(); - // Legacy only supports 8 * PHP_INT_SIZE - 2 languages: - // One bit cannot be used because PHP uses signed integers and a second one is reserved for the - // "always available flag". - if ($lastId == (2 ** (8 * PHP_INT_SIZE - 2))) { - throw new RuntimeException('Maximum number of languages reached.'); - } - // Next power of 2 for bit masks - $nextId = ($lastId !== 0 ? $lastId << 1 : 2); + // id 1 is permanently reserved (it was the legacy bitmask's "always available" sentinel, + // never a real language) - installs upgrading from that scheme may have existing ids that + // are powers of two, but nothing depends on that anymore, so new ids are just the next + // integer instead of the next power of two. This is what actually removes the old ~62 + // language ceiling. + $nextId = $lastId !== 0 ? $lastId + 1 : 2; $query = $this->connection->createQueryBuilder(); $query @@ -159,13 +154,39 @@ public function deleteLanguage(int $id): void public function canDeleteLanguage(int $id): bool { + $candidateIds = $this->getLegacyTaintToleranceCandidateIds($id); + + if ($this->existsWithColumnValue($candidateIds, 'ibexa_content_translation', 'language_id')) { + return false; + } + + if ($this->existsWithColumnValue($candidateIds, 'ibexa_content_version_translation', 'language_id')) { + return false; + } + + // "ibexa_content"/"ibexa_content_version" also count as referencing the language when it's + // their "initial_language_id" (main language), even without a matching translation row - + // e.g. right after ContentService::updateContentMetadata() changes the main language code + // without publishing a new version for it. + if ($this->existsWithColumnValue($candidateIds, ContentGateway::CONTENT_ITEM_TABLE, 'initial_language_id')) { + return false; + } + + if ($this->existsWithColumnValue($candidateIds, ContentGateway::CONTENT_VERSION_TABLE, 'initial_language_id')) { + return false; + } + + if ($this->existsWithColumnValue($candidateIds, 'ibexa_url_alias_ml_translation', 'language_id')) { + return false; + } + + if ($this->existsWithColumnValue($candidateIds, 'ibexa_search_object_word_link', 'language_id')) { + return false; + } + // note: at some point this should be delegated to specific gateways foreach (self::MULTILINGUAL_TABLES_COLUMNS as $tableName => $columns) { - $languageMaskColumn = $columns[0]; - $languageIdColumn = $columns[1] ?? null; - if ( - $this->countTableData($id, $tableName, $languageMaskColumn, $languageIdColumn) > 0 - ) { + if ($this->existsWithColumnValue($candidateIds, $tableName, $columns[0])) { return false; } } @@ -174,49 +195,119 @@ public function canDeleteLanguage(int $id): bool } /** - * Count table data rows related to the given language. + * Determines which column values would count as "this language is in use", tolerating the + * legacy "always available" bit 0 folded into indicator columns on rows written before + * always_available became a plain column (for real installs upgrading from that scheme and + * long-lived test fixtures captured from it). + * + * Only a genuine legacy power-of-two id could ever have been tainted this way - the old bitmask + * scheme only ever allocated powers of two, and only ORs in bit 0 for even ids - so a + * newly-allocated id (sequential post-migration, essentially never a power of two) never + * qualifies. Even for a power-of-two id, $languageId+1 is only treated as a tainted stand-in for + * $languageId when $languageId+1 isn't itself a real, independently-existing language: two + * sequentially-allocated ids are commonly adjacent post-migration, and treating a distinct + * language's own genuine usage as evidence that $languageId is "in use" would incorrectly block + * deleting an otherwise-unused $languageId (e.g. languages 64 and 65 both existing and 65 being + * in use must never make unrelated, unused 64 look undeletable). + * + * @return int[] + */ + private function getLegacyTaintToleranceCandidateIds(int $languageId): array + { + $isLegacyPowerOfTwoId = $languageId % 2 === 0 && ($languageId & ($languageId - 1)) === 0; + + if (!$isLegacyPowerOfTwoId || $this->languageExists($languageId + 1)) { + return [$languageId]; + } + + return [$languageId, $languageId + 1]; + } + + private function languageExists(int $id): bool + { + $query = $this->connection->createQueryBuilder(); + $query + ->select('1') + ->from(self::CONTENT_LANGUAGE_TABLE) + ->where( + $query->expr()->eq('id', $query->createPositionalParameter($id, ParameterType::INTEGER)) + ) + ->setMaxResults(1); + + return $query->executeQuery()->fetchOne() !== false; + } + + /** + * Checks whether $tableName has a row with $columnName equal to one of $candidateIds. * - * @param string|null $languageIdColumn optional column name containing explicit language id + * @param int[] $candidateIds */ - private function countTableData( - int $languageId, - string $tableName, - string $languageMaskColumn, - ?string $languageIdColumn = null - ): int { + private function existsWithColumnValue(array $candidateIds, string $tableName, string $columnName): bool + { $query = $this->connection->createQueryBuilder(); $query - // avoiding using "*" as count argument, but don't specify column name because it varies - ->select('COUNT(1)') + ->select('1') ->from($tableName) ->where( - $query->expr()->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression( - $languageMaskColumn, - $query->createPositionalParameter($languageId, ParameterType::INTEGER) - ), - 0 + $query->expr()->in( + $columnName, + $query->createPositionalParameter($candidateIds, ArrayParameterType::INTEGER) ) - ); - if (null !== $languageIdColumn) { - $query - ->orWhere( - $query->expr()->eq( - $languageIdColumn, - $query->createPositionalParameter($languageId, ParameterType::INTEGER) - ) - ); - } + ) + ->setMaxResults(1); + + return $query->executeQuery()->fetchOne() !== false; + } - return (int)$query->executeQuery()->fetchOne(); + /** + * @param int[] $contentIds + * + * @return array + */ + public function loadContentTranslations(array $contentIds): array + { + return $this->loadTranslations('ibexa_content_translation', 'content_id', $contentIds); + } + + /** + * @param int[] $versionIds + * + * @return array + */ + public function loadVersionTranslations(array $versionIds): array + { + return $this->loadTranslations('ibexa_content_version_translation', 'content_version_id', $versionIds); } - private function getDatabasePlatform(): AbstractPlatform + /** + * @param int[] $ids + * + * @return array + */ + private function loadTranslations(string $tableName, string $idColumn, array $ids): array { - try { - return $this->connection->getDatabasePlatform(); - } catch (Exception $e) { - throw DatabaseException::wrap($e); + if (empty($ids)) { + return []; } + + $query = $this->connection->createQueryBuilder(); + $rows = $query + ->select($idColumn, 'language_id') + ->from($tableName) + ->where( + $query->expr()->in( + $idColumn, + $query->createNamedParameter($ids, ArrayParameterType::INTEGER, ':ids') + ) + ) + ->executeQuery() + ->fetchAllAssociative(); + + $translations = []; + foreach ($rows as $row) { + $translations[(int)$row[$idColumn]][] = (int)$row['language_id']; + } + + return $translations; } } diff --git a/src/lib/Persistence/Legacy/Content/Language/Gateway/ExceptionConversion.php b/src/lib/Persistence/Legacy/Content/Language/Gateway/ExceptionConversion.php index 02ccc52276..ebdc97633c 100644 --- a/src/lib/Persistence/Legacy/Content/Language/Gateway/ExceptionConversion.php +++ b/src/lib/Persistence/Legacy/Content/Language/Gateway/ExceptionConversion.php @@ -96,4 +96,22 @@ public function canDeleteLanguage(int $id): bool throw DatabaseException::wrap($e); } } + + public function loadContentTranslations(array $contentIds): array + { + try { + return $this->innerGateway->loadContentTranslations($contentIds); + } catch (DBALException|PDOException $e) { + throw DatabaseException::wrap($e); + } + } + + public function loadVersionTranslations(array $versionIds): array + { + try { + return $this->innerGateway->loadVersionTranslations($versionIds); + } catch (DBALException|PDOException $e) { + throw DatabaseException::wrap($e); + } + } } diff --git a/src/lib/Persistence/Legacy/Content/Language/MaskGenerator.php b/src/lib/Persistence/Legacy/Content/Language/MaskGenerator.php deleted file mode 100644 index d5065670c2..0000000000 --- a/src/lib/Persistence/Legacy/Content/Language/MaskGenerator.php +++ /dev/null @@ -1,227 +0,0 @@ -languageHandler = $languageHandler; - } - - /** - * Generates a language mask from pre-loaded Language Ids. - * - * @param int[] $languageIds - * @param bool $alwaysAvailable - * - * @return int - */ - public function generateLanguageMaskFromLanguageIds(array $languageIds, $alwaysAvailable): int - { - // make sure alwaysAvailable part of bit mask always results in 1 or 0 - $languageMask = $alwaysAvailable ? 1 : 0; - - foreach ($languageIds as $languageId) { - $languageMask |= $languageId; - } - - return $languageMask; - } - - /** - * Generates a language indicator from $languageCode and $alwaysAvailable. - * - * @param string $languageCode - * @param bool $alwaysAvailable - * - * @return int - * - * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException - */ - public function generateLanguageIndicator($languageCode, $alwaysAvailable): int - { - return $this->languageHandler->loadByLanguageCode($languageCode)->id | ($alwaysAvailable ? 1 : 0); - } - - /** - * Checks if $language is always available in $languages;. - * - * @param string $language - * @param array $languages - * - * @return bool - */ - public function isLanguageAlwaysAvailable($language, array $languages): bool - { - return isset($languages['always-available']) - && ($languages['always-available'] == $language) - ; - } - - /** - * Checks if $languageMask contains the alwaysAvailable bit field. - * - * @param int $languageMask - * - * @return bool - */ - public function isAlwaysAvailable($languageMask): bool - { - return (bool)($languageMask & 1); - } - - /** - * Removes the alwaysAvailable flag from $languageId and returns cleaned up $languageId. - * - * @param int $languageId - * - * @return int - */ - public function removeAlwaysAvailableFlag($languageId): int - { - return $languageId & ~1; - } - - /** - * Extracts every language Ids contained in $languageMask. - * - * @param int $languageMask - * - * @return array Array of language Id - */ - public function extractLanguageIdsFromMask($languageMask): array - { - $exp = 2; - $result = []; - - // Decomposition of $languageMask into its binary components. - // check if $exp has not overflown and became float (happens for the last possible language in the mask) - while (is_int($exp) && $exp <= $languageMask) { - if ($languageMask & $exp) { - $result[] = $exp; - } - - $exp *= 2; - } - - return $result; - } - - /** - * Extracts Language codes contained in given $languageMask. - * - * @param int $languageMask - * - * @return array - */ - public function extractLanguageCodesFromMask($languageMask): array - { - $languageCodes = []; - $languageList = $this->languageHandler->loadList( - $this->extractLanguageIdsFromMask($languageMask) - ); - foreach ($languageList as $language) { - $languageCodes[] = $language->languageCode; - } - - return $languageCodes; - } - - /** - * Checks if given $languageMask consists of multiple languages. - * - * @param int $languageMask - * - * @return bool - */ - public function isLanguageMaskComposite($languageMask): bool - { - // Ignore first bit - $languageMask = $this->removeAlwaysAvailableFlag($languageMask); - - // Special case - if ($languageMask === 0) { - return false; - } - - // Return false if power of 2 - return (bool)($languageMask & ($languageMask - 1)); - } - - /** - * Generates a language mask from plain array of language codes and always available flag. - * - * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException If language(s) in $languageCodes was not be found - * - * @param string[] $languageCodes - * @param bool $isAlwaysAvailable - * - * @return int - */ - public function generateLanguageMaskFromLanguageCodes(array $languageCodes, bool $isAlwaysAvailable = false): int - { - $mask = $isAlwaysAvailable ? 1 : 0; - - $languageList = $this->languageHandler->loadListByLanguageCodes($languageCodes); - foreach ($languageList as $language) { - $mask |= $language->id; - } - - if ($missing = array_diff($languageCodes, array_keys($languageList))) { - throw new NotFoundException('Language', implode(', ', $missing)); - } - - return $mask; - } - - /** - * Collect all translations of the given Persistence Fields and generate language mask. - * - * @param \Ibexa\Contracts\Core\Persistence\Content\Field[] $fields - * - * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException - */ - public function generateLanguageMaskForFields( - array $fields, - string $initialLanguageCode, - bool $isAlwaysAvailable - ): int { - $languages = [$initialLanguageCode => true]; - foreach ($fields as $field) { - if (isset($languages[$field->languageCode])) { - continue; - } - - $languages[$field->languageCode] = true; - } - - return $this->generateLanguageMaskFromLanguageCodes( - array_keys($languages), - $isAlwaysAvailable - ); - } -} diff --git a/src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php index 81e52d6d06..7cbcd86018 100644 --- a/src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabase.php @@ -9,21 +9,17 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; use Doctrine\DBAL\ParameterType; -use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Query\QueryBuilder; use Ibexa\Contracts\Core\Persistence\Content\ContentInfo; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Persistence\Content\Location; use Ibexa\Contracts\Core\Persistence\Content\Location\CreateStruct; use Ibexa\Contracts\Core\Persistence\Content\Location\UpdateStruct; use Ibexa\Contracts\Core\Persistence\Filter\Query\CountQueryBuilder; -use Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException; use Ibexa\Contracts\Core\Repository\Values\Content\Query\CriterionInterface; -use Ibexa\Core\Base\Exceptions\DatabaseException; use Ibexa\Core\Base\Exceptions\NotFoundException as NotFound; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\Location\Gateway; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriteriaConverter; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\SortClauseConverter; @@ -43,7 +39,7 @@ final class DoctrineDatabase extends Gateway public function __construct( private readonly Connection $connection, - private readonly MaskGenerator $languageMaskGenerator, + private readonly LanguageHandler $languageHandler, private readonly CriteriaConverter $trashCriteriaConverter, private readonly SortClauseConverter $trashSortClauseConverter, private readonly CountQueryBuilder $countQueryBuilder @@ -1437,14 +1433,11 @@ private function appendContentItemTranslationsConstraint( bool $useAlwaysAvailable ): void { $expr = $queryBuilder->expr(); - try { - $mask = $this->languageMaskGenerator->generateLanguageMaskFromLanguageCodes( - $translations, - $useAlwaysAvailable - ); - } catch (NotFoundException $e) { + $languages = iterator_to_array($this->languageHandler->loadListByLanguageCodes($translations)); + if (array_diff($translations, array_keys($languages)) !== []) { return; } + $languageIds = array_map(static fn ($language) => $language->id, array_values($languages)); $queryBuilder->leftJoin( 't', @@ -1453,13 +1446,35 @@ private function appendContentItemTranslationsConstraint( $expr->eq('t.contentobject_id', 'c.id') ); + $translationSubQuery = $this->connection->createQueryBuilder(); + $translationSubQuery + ->select('1') + ->from('ibexa_content_translation', 'ct') + ->where( + $translationSubQuery->expr()->and( + 'ct.content_id = c.id', + $translationSubQuery->expr()->in( + 'ct.language_id', + $queryBuilder->createNamedParameter($languageIds, ArrayParameterType::INTEGER) + ) + ) + ); + + $translationConditions = [ + sprintf('EXISTS (%s)', $translationSubQuery->getSQL()), + ]; + + if ($useAlwaysAvailable) { + $translationConditions[] = $expr->eq( + 'c.always_available', + $queryBuilder->createNamedParameter(true, ParameterType::BOOLEAN) + ); + } + $queryBuilder->andWhere( $expr->or( - $expr->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression('c.language_mask', $mask), - 0 - ), - // Root location doesn't have language mask + $expr->or(...$translationConditions), + // Root location doesn't have a translation row $expr->eq( 't.node_id', 't.parent_node_id' @@ -1586,13 +1601,4 @@ private function addSort(?array $sort, QueryBuilder $query, array $languageSetti $this->trashSortClauseConverter->applyJoin($query, $sort, $languageSettings); $this->trashSortClauseConverter->applyOrderBy($query); } - - private function getDatabasePlatform(): AbstractPlatform - { - try { - return $this->connection->getDatabasePlatform(); - } catch (Exception $e) { - throw DatabaseException::wrap($e); - } - } } diff --git a/src/lib/Persistence/Legacy/Content/Mapper.php b/src/lib/Persistence/Legacy/Content/Mapper.php index 1cdc1a614d..3e78aa14e7 100644 --- a/src/lib/Persistence/Legacy/Content/Mapper.php +++ b/src/lib/Persistence/Legacy/Content/Mapper.php @@ -21,6 +21,7 @@ use Ibexa\Core\Base\Exceptions\NotFoundException; use Ibexa\Core\FieldType\FieldTypeAliasResolverInterface; use Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry as Registry; +use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway as LanguageGateway; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -69,18 +70,22 @@ class Mapper private FieldTypeAliasResolverInterface $fieldTypeAliasResolver; + private LanguageGateway $languageGateway; + public function __construct( Registry $converterRegistry, LanguageHandler $languageHandler, ContentTypeHandler $contentTypeHandler, EventDispatcherInterface $eventDispatcher, - FieldTypeAliasResolverInterface $fieldTypeAliasResolver + FieldTypeAliasResolverInterface $fieldTypeAliasResolver, + LanguageGateway $languageGateway ) { $this->converterRegistry = $converterRegistry; $this->languageHandler = $languageHandler; $this->contentTypeHandler = $contentTypeHandler; $this->eventDispatcher = $eventDispatcher; $this->fieldTypeAliasResolver = $fieldTypeAliasResolver; + $this->languageGateway = $languageGateway; } /** @@ -229,10 +234,15 @@ public function extractContentFromRows( $versionInfos = []; $fields = []; + $versionLanguageIds = $this->languageGateway->loadVersionTranslations( + array_unique(array_map(static fn (array $row): int => (int)$row["{$prefix}version_id"], $rows)) + ); + $fieldDefinitions = $this->loadCachedVersionFieldDefinitionsPerLanguage( $rows, $prefix, - $translations + $translations, + $versionLanguageIds ); foreach ($rows as $row) { @@ -248,7 +258,11 @@ public function extractContentFromRows( } if (!isset($versionInfos[$contentId][$versionId])) { - $versionInfos[$contentId][$versionId] = $this->extractVersionInfoFromRow($row); + $versionInfos[$contentId][$versionId] = $this->extractVersionInfoFromRow( + $row, + [], + $versionLanguageIds[$versionId] ?? [] + ); } $fieldId = (int)$row["{$prefix}field_id"]; @@ -332,6 +346,8 @@ private function buildContentObjects( * @phpstan-param TRawContentRow[] $rows * * @param string[]|null $translations + * @param array $versionLanguageIds Version id => language ids, as returned by + * {@see \Ibexa\Core\Persistence\Legacy\Content\Language\Gateway::loadVersionTranslations()} * * @phpstan-return TVersionedLanguageFieldDefinitionsMap * @@ -340,7 +356,8 @@ private function buildContentObjects( private function loadCachedVersionFieldDefinitionsPerLanguage( array $rows, string $prefix, - ?array $translations = null + ?array $translations, + array $versionLanguageIds ): array { $fieldDefinitions = []; $contentTypes = []; @@ -350,13 +367,12 @@ private function loadCachedVersionFieldDefinitionsPerLanguage( $contentId = (int)$row["{$prefix}id"]; $versionId = (int)$row["{$prefix}version_id"]; $contentTypeId = (int)$row["{$prefix}content_type_id"]; - $languageMask = (int)$row["{$prefix}version_language_mask"]; if (isset($fieldDefinitions[$contentId][$versionId])) { continue; } - $allLanguagesCodes = $this->extractLanguageCodesFromMask($languageMask, $allLanguages); + $allLanguagesCodes = $this->mapLanguageIdsToCodes($versionLanguageIds[$versionId] ?? [], $allLanguages); $languageCodes = empty($translations) ? $allLanguagesCodes : array_intersect($translations, $allLanguagesCodes); $contentTypes[$contentTypeId] = $contentTypes[$contentTypeId] ?? $this->contentTypeHandler->load($contentTypeId); $contentType = $contentTypes[$contentTypeId]; @@ -393,7 +409,7 @@ public function extractContentInfoFromRow(array $row, $prefix = '', $treePrefix $contentInfo->ownerId = (int)$row["{$prefix}owner_id"]; $contentInfo->publicationDate = (int)$row["{$prefix}published"]; $contentInfo->modificationDate = (int)$row["{$prefix}modified"]; - $contentInfo->alwaysAvailable = 1 === ((int)$row["{$prefix}language_mask"] & 1); + $contentInfo->alwaysAvailable = (bool)$row["{$prefix}always_available"]; $contentInfo->mainLanguageCode = $this->languageHandler->load($row["{$prefix}initial_language_id"])->languageCode; $contentInfo->remoteId = (string)$row["{$prefix}remote_id"]; $contentInfo->mainLocationId = ($row["{$treePrefix}main_node_id"] !== null ? (int)$row["{$treePrefix}main_node_id"] : null); @@ -430,10 +446,12 @@ public function extractContentInfoFromRows(array $rows, $prefix = '', $treePrefi * * @param array $row * @param array $names + * @param int[] $languageIds Language ids this version is translated into, as returned by + * {@see \Ibexa\Core\Persistence\Legacy\Content\Language\Gateway::loadVersionTranslations()} * * @return \Ibexa\Contracts\Core\Persistence\Content\VersionInfo */ - private function extractVersionInfoFromRow(array $row, array $names = []) + private function extractVersionInfoFromRow(array $row, array $names, array $languageIds) { $versionInfo = new VersionInfo(); $versionInfo->id = (int)$row['content_version_id']; @@ -447,11 +465,7 @@ private function extractVersionInfoFromRow(array $row, array $names = []) // Map language codes $allLanguages = $this->loadAllLanguagesWithIdKey(); - $versionInfo->languageCodes = $this->extractLanguageCodesFromMask( - (int)$row['content_version_language_mask'], - $allLanguages, - $missing - ); + $versionInfo->languageCodes = $this->mapLanguageIdsToCodes($languageIds, $allLanguages, $missing); $initialLanguageId = (int)$row['content_version_initial_language_id']; if (isset($allLanguages[$initialLanguageId])) { $versionInfo->initialLanguageCode = $allLanguages[$initialLanguageId]->languageCode; @@ -486,6 +500,9 @@ public function extractVersionInfoListFromRows(array $rows, array $nameRows): ar } $allLanguages = $this->loadAllLanguagesWithIdKey(); + $versionLanguageIds = $this->languageGateway->loadVersionTranslations( + array_unique(array_map(static fn (array $row): int => (int)$row['content_version_id'], $rows)) + ); $versionInfoList = []; foreach ($rows as $row) { $versionId = $row['content_id'] . '_' . $row['content_version_version']; @@ -500,8 +517,8 @@ public function extractVersionInfoListFromRows(array $rows, array $nameRows): ar $versionInfo->status = (int)$row['content_version_status']; $versionInfo->names = $nameData[$versionId]; $versionInfoList[$versionId] = $versionInfo; - $versionInfo->languageCodes = $this->extractLanguageCodesFromMask( - (int)$row['content_version_language_mask'], + $versionInfo->languageCodes = $this->mapLanguageIdsToCodes( + $versionLanguageIds[$versionInfo->id] ?? [], $allLanguages, $missing ); @@ -525,29 +542,21 @@ public function extractVersionInfoListFromRows(array $rows, array $nameRows): ar } /** - * @param int $languageMask - * @param \Ibexa\Contracts\Core\Persistence\Content\Language[] $allLanguages + * @param int[] $languageIds + * @param \Ibexa\Contracts\Core\Persistence\Content\Language[] $allLanguages Keyed by language id * @param int[] &$missing * * @return string[] */ - private function extractLanguageCodesFromMask(int $languageMask, array $allLanguages, &$missing = []) + private function mapLanguageIdsToCodes(array $languageIds, array $allLanguages, &$missing = []) { - $exp = 2; $result = []; - - // Decomposition of $languageMask into its binary components to extract language codes - // check if $exp has not overflown and became float (happens for the last possible language in the mask) - while (is_int($exp) && $exp <= $languageMask) { - if ($languageMask & $exp) { - if (isset($allLanguages[$exp])) { - $result[] = $allLanguages[$exp]->languageCode; - } else { - $missing[] = $exp; - } + foreach ($languageIds as $languageId) { + if (isset($allLanguages[$languageId])) { + $result[] = $allLanguages[$languageId]->languageCode; + } else { + $missing[] = $languageId; } - - $exp *= 2; } return $result; diff --git a/src/lib/Persistence/Legacy/Content/ObjectState/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Content/ObjectState/Gateway/DoctrineDatabase.php index 5336ade5a9..e842cfddb8 100644 --- a/src/lib/Persistence/Legacy/Content/ObjectState/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Content/ObjectState/Gateway/DoctrineDatabase.php @@ -11,9 +11,9 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\QueryBuilder; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Persistence\Content\ObjectState; use Ibexa\Contracts\Core\Persistence\Content\ObjectState\Group; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\ObjectState\Gateway; /** @@ -27,7 +27,7 @@ final class DoctrineDatabase extends Gateway { public function __construct( private readonly Connection $connection, - private readonly MaskGenerator $maskGenerator + private readonly LanguageHandler $languageHandler ) { } @@ -148,23 +148,13 @@ public function insertObjectState(ObjectState $objectState, int $groupId): void ParameterType::INTEGER ), 'default_language_id' => $query->createPositionalParameter( - $this->maskGenerator->generateLanguageIndicator( - $objectState->defaultLanguage, - false - ), + $this->languageHandler->loadByLanguageCode($objectState->defaultLanguage)->id, ParameterType::INTEGER ), 'identifier' => $query->createPositionalParameter( $objectState->identifier, ParameterType::STRING ), - 'language_mask' => $query->createPositionalParameter( - $this->maskGenerator->generateLanguageMaskFromLanguageCodes( - $objectState->languageCodes, - true - ), - ParameterType::INTEGER - ), 'priority' => $query->createPositionalParameter( $objectState->priority, ParameterType::INTEGER @@ -194,16 +184,13 @@ public function insertObjectState(ObjectState $objectState, int $groupId): void } /** - * @param string[] $languageCodes - * * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException */ private function updateObjectStateCommonFields( string $tableName, int $id, string $identifier, - string $defaultLanguageCode, - array $languageCodes + string $defaultLanguageCode ): void { $query = $this->connection->createQueryBuilder(); $query @@ -211,10 +198,7 @@ private function updateObjectStateCommonFields( ->set( 'default_language_id', $query->createPositionalParameter( - $this->maskGenerator->generateLanguageIndicator( - $defaultLanguageCode, - false - ), + $this->languageHandler->loadByLanguageCode($defaultLanguageCode)->id, ParameterType::INTEGER ) ) @@ -225,16 +209,6 @@ private function updateObjectStateCommonFields( ParameterType::STRING ) ) - ->set( - 'language_mask', - $query->createPositionalParameter( - $this->maskGenerator->generateLanguageMaskFromLanguageCodes( - $languageCodes, - true - ), - ParameterType::INTEGER - ) - ) ->where( $query->expr()->eq( 'id', @@ -252,8 +226,7 @@ public function updateObjectState(ObjectState $objectState): void self::OBJECT_STATE_TABLE, $objectState->id, $objectState->identifier, - $objectState->defaultLanguage, - $objectState->languageCodes + $objectState->defaultLanguage ); // And then refresh object state translations @@ -359,23 +332,13 @@ public function insertObjectStateGroup(Group $objectStateGroup): void ->values( [ 'default_language_id' => $query->createPositionalParameter( - $this->maskGenerator->generateLanguageIndicator( - $objectStateGroup->defaultLanguage, - false - ), + $this->languageHandler->loadByLanguageCode($objectStateGroup->defaultLanguage)->id, ParameterType::INTEGER ), 'identifier' => $query->createPositionalParameter( $objectStateGroup->identifier, ParameterType::STRING ), - 'language_mask' => $query->createPositionalParameter( - $this->maskGenerator->generateLanguageMaskFromLanguageCodes( - $objectStateGroup->languageCodes, - true - ), - ParameterType::INTEGER - ), ] ) ; @@ -394,8 +357,7 @@ public function updateObjectStateGroup(Group $objectStateGroup): void self::OBJECT_STATE_GROUP_TABLE, $objectStateGroup->id, $objectStateGroup->identifier, - $objectStateGroup->defaultLanguage, - $objectStateGroup->languageCodes + $objectStateGroup->defaultLanguage ); // And then refresh group translations @@ -509,7 +471,6 @@ private function createObjectStateFindQuery(): QueryBuilder 'state.group_id AS ibexa_object_state_group_id', 'state.id AS ibexa_object_state_id', 'state.identifier AS ibexa_object_state_identifier', - 'state.language_mask AS ibexa_object_state_language_mask', 'state.priority AS ibexa_object_state_priority', // Object state language 'lang.description AS ibexa_object_state_language_description', @@ -539,7 +500,6 @@ private function createObjectStateGroupFindQuery(): QueryBuilder 'state_group.default_language_id AS ibexa_object_state_group_default_language_id', 'state_group.id AS ibexa_object_state_group_id', 'state_group.identifier AS ibexa_object_state_group_identifier', - 'state_group.language_mask AS ibexa_object_state_group_language_mask', // Object state group language 'state_group_lang.description AS ibexa_object_state_group_language_description', 'state_group_lang.language_id AS ibexa_object_state_group_language_language_id', @@ -583,10 +543,7 @@ private function insertObjectStateTranslations(ObjectState $objectState): void ParameterType::STRING ), 'language_id' => $query->createPositionalParameter( - $this->maskGenerator->generateLanguageIndicator( - $languageCode, - $languageCode === $objectState->defaultLanguage - ), + $this->languageHandler->loadByLanguageCode($languageCode)->id, ParameterType::INTEGER ), ] @@ -637,16 +594,13 @@ private function insertObjectStateGroupTranslations(Group $objectStateGroup): vo ) ; foreach ($objectStateGroup->languageCodes as $languageCode) { - $languageId = $this->maskGenerator->generateLanguageIndicator( - $languageCode, - $languageCode === $objectStateGroup->defaultLanguage - ); + $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id; $query ->setParameter('contentobject_state_group_id', $objectStateGroup->id, ParameterType::INTEGER) ->setParameter('description', $objectStateGroup->description[$languageCode], ParameterType::STRING) ->setParameter('name', $objectStateGroup->name[$languageCode], ParameterType::STRING) ->setParameter('language_id', $languageId, ParameterType::INTEGER) - ->setParameter('real_language_id', $languageId & ~1, ParameterType::INTEGER); + ->setParameter('real_language_id', $languageId, ParameterType::INTEGER); $query->executeStatement(); } diff --git a/src/lib/Persistence/Legacy/Content/ObjectState/Mapper.php b/src/lib/Persistence/Legacy/Content/ObjectState/Mapper.php index ebf99c96b5..821dd1bbc4 100644 --- a/src/lib/Persistence/Legacy/Content/ObjectState/Mapper.php +++ b/src/lib/Persistence/Legacy/Content/ObjectState/Mapper.php @@ -47,9 +47,14 @@ public function createObjectStateFromData(array $data) $languageIds = [(int)$data[0]['ibexa_object_state_default_language_id']]; foreach ($data as $stateTranslation) { - $languageIds[] = (int)$stateTranslation['ibexa_object_state_language_language_id'] & ~1; + $rawLanguageId = (int)$stateTranslation['ibexa_object_state_language_language_id']; + $languageIds[] = $rawLanguageId; + // Fixtures predating always_available becoming a plain column may still carry the + // legacy "always available" bit 0 folded into this id - load both forms and prefer + // whichever actually resolves (see resolveLanguageId()). + $languageIds[] = $rawLanguageId & ~1; } - $languages = iterator_to_array($this->languageHandler->loadList($languageIds)); + $languages = iterator_to_array($this->languageHandler->loadList(array_unique($languageIds))); $objectState->id = (int)$data[0]['ibexa_object_state_id']; $objectState->groupId = (int)$data[0]['ibexa_object_state_group_id']; @@ -62,7 +67,10 @@ public function createObjectStateFromData(array $data) $objectState->description = []; foreach ($data as $stateTranslation) { - $languageCode = $languages[$stateTranslation['ibexa_object_state_language_language_id'] & ~1]->languageCode; + $languageCode = $languages[$this->resolveLanguageId( + (int)$stateTranslation['ibexa_object_state_language_language_id'], + $languages + )]->languageCode; $objectState->languageCodes[] = $languageCode; $objectState->name[$languageCode] = $stateTranslation['ibexa_object_state_language_name']; $objectState->description[$languageCode] = $stateTranslation['ibexa_object_state_language_description']; @@ -71,6 +79,14 @@ public function createObjectStateFromData(array $data) return $objectState; } + /** + * @param array $languages + */ + private function resolveLanguageId(int $rawLanguageId, array $languages): int + { + return isset($languages[$rawLanguageId]) ? $rawLanguageId : ($rawLanguageId & ~1); + } + /** * Creates ObjectState array of objects from provided $data. * diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway.php b/src/lib/Persistence/Legacy/Content/Type/Gateway.php index b04971814d..be100af460 100644 --- a/src/lib/Persistence/Legacy/Content/Type/Gateway.php +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway.php @@ -130,6 +130,17 @@ abstract public function loadTypeDataByIdentifier(string $identifier, int $statu abstract public function loadTypeDataByRemoteId(string $remoteId, int $status): array; + /** + * Loads the language codes each of the given (Type id, status) pairs is translated into, from + * ibexa_content_type_name - the relational replacement for decoding + * ibexa_content_type.language_mask. + * + * @param array $typeIdStatusPairs + * + * @return array "{typeId}:{status}" => language codes + */ + abstract public function loadContentTypeTranslations(array $typeIdStatusPairs): array; + abstract public function countInstancesOfType(int $typeId): int; /** diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabase.php index 91c695d00a..ecf44f2b8f 100644 --- a/src/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabase.php @@ -12,6 +12,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\QueryBuilder; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Persistence\Content\Type; use Ibexa\Contracts\Core\Persistence\Content\Type\FieldDefinition; use Ibexa\Contracts\Core\Persistence\Content\Type\Group; @@ -21,7 +22,6 @@ use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\Base\Exceptions\NotFoundException; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\MultilingualStorageFieldDefinition; use Ibexa\Core\Persistence\Legacy\Content\StorageFieldDefinition; use Ibexa\Core\Persistence\Legacy\Content\Type\Gateway; @@ -54,7 +54,6 @@ final class DoctrineDatabase extends Gateway 'identifier', 'initial_language_id', 'is_container', - 'language_mask', 'remote_id', 'serialized_description_list', 'serialized_name_list', @@ -97,7 +96,7 @@ final class DoctrineDatabase extends Gateway public function __construct( private readonly Connection $connection, private readonly SharedGateway $sharedGateway, - private readonly MaskGenerator $languageMaskGenerator, + private readonly LanguageHandler $languageHandler, private readonly Gateway\CriterionVisitor\CriterionVisitor $criterionVisitor ) { } @@ -285,13 +284,7 @@ private function insertTypeNameData(int $typeId, int $typeStatus, array $languag ParameterType::INTEGER ), 'language_id' => $query->createPositionalParameter( - $this->languageMaskGenerator->generateLanguageIndicator( - $language, - $this->languageMaskGenerator->isLanguageAlwaysAvailable( - $language, - $languages - ) - ), + $this->languageHandler->loadByLanguageCode($language)->id, ParameterType::INTEGER ), 'language_locale' => $query->createPositionalParameter( @@ -410,13 +403,6 @@ private function mapCommonContentTypeColumnsToQueryValuesAndTypes(Type $type): a 'url_alias_name' => [$type->urlAliasSchema, ParameterType::STRING], 'contentobject_name' => [$type->nameSchema, ParameterType::STRING], 'is_container' => [(int)$type->isContainer, ParameterType::INTEGER], - 'language_mask' => [ - $this->languageMaskGenerator->generateLanguageMaskFromLanguageCodes( - $type->languageCodes, - array_key_exists('always-available', $type->name) - ), - ParameterType::INTEGER, - ], 'initial_language_id' => [$type->initialLanguageId, ParameterType::INTEGER], 'sort_field' => [$type->sortField, ParameterType::INTEGER], 'sort_order' => [$type->sortOrder, ParameterType::INTEGER], @@ -1006,6 +992,35 @@ public function loadTypeDataByRemoteId(string $remoteId, int $status): array return $query->executeQuery()->fetchAllAssociative(); } + public function loadContentTypeTranslations(array $typeIdStatusPairs): array + { + if (empty($typeIdStatusPairs)) { + return []; + } + + $typeIds = array_unique(array_column($typeIdStatusPairs, 'id')); + $statuses = array_unique(array_column($typeIdStatusPairs, 'status')); + + $query = $this->connection->createQueryBuilder(); + $query + ->select('content_type_id', 'content_type_status', 'language_locale') + ->from(self::CONTENT_TYPE_NAME_TABLE) + ->where( + $query->expr()->in('content_type_id', $query->createNamedParameter($typeIds, ArrayParameterType::INTEGER)) + ) + ->andWhere( + $query->expr()->in('content_type_status', $query->createNamedParameter($statuses, ArrayParameterType::INTEGER)) + ); + + $translations = []; + foreach ($query->executeQuery()->fetchAllAssociative() as $row) { + $key = $row['content_type_id'] . ':' . $row['content_type_status']; + $translations[$key][] = $row['language_locale']; + } + + return $translations; + } + /** * Return a basic query to retrieve Type data. */ @@ -1032,7 +1047,6 @@ private function getLoadTypeQueryBuilder(): QueryBuilder 'c.always_available AS content_type_always_available', 'c.sort_field AS content_type_sort_field', 'c.sort_order AS content_type_sort_order', - 'c.language_mask AS content_type_language_mask', 'a.id AS content_type_field_definition_id', 'a.serialized_name_list AS content_type_field_definition_serialized_name_list', 'a.serialized_description_list AS content_type_field_definition_serialized_description_list', @@ -1360,9 +1374,7 @@ public function removeFieldDefinitionTranslation( string $languageCode, int $status ): void { - $languageId = $this->languageMaskGenerator->generateLanguageMaskFromLanguageCodes( - [$languageCode] - ); + $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id; $deleteQuery = $this->connection->createQueryBuilder(); $deleteQuery diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/ExceptionConversion.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/ExceptionConversion.php index f13e54aea3..1f76131eb3 100644 --- a/src/lib/Persistence/Legacy/Content/Type/Gateway/ExceptionConversion.php +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/ExceptionConversion.php @@ -255,6 +255,15 @@ public function loadTypeDataByRemoteId(string $remoteId, int $status): array } } + public function loadContentTypeTranslations(array $typeIdStatusPairs): array + { + try { + return $this->innerGateway->loadContentTypeTranslations($typeIdStatusPairs); + } catch (DBALException|PDOException $e) { + throw DatabaseException::wrap($e); + } + } + public function loadTypesDataByFieldDefinitionIdentifier(string $identifier): array { try { diff --git a/src/lib/Persistence/Legacy/Content/Type/Mapper.php b/src/lib/Persistence/Legacy/Content/Type/Mapper.php index 1aa365a799..f47a3b7ac7 100644 --- a/src/lib/Persistence/Legacy/Content/Type/Mapper.php +++ b/src/lib/Persistence/Legacy/Content/Type/Mapper.php @@ -7,6 +7,7 @@ namespace Ibexa\Core\Persistence\Legacy\Content\Type; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Persistence\Content\Type; use Ibexa\Contracts\Core\Persistence\Content\Type\CreateStruct; use Ibexa\Contracts\Core\Persistence\Content\Type\FieldDefinition; @@ -15,7 +16,6 @@ use Ibexa\Contracts\Core\Persistence\Content\Type\UpdateStruct; use Ibexa\Core\FieldType\FieldTypeAliasResolverInterface; use Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\MultilingualStorageFieldDefinition; use Ibexa\Core\Persistence\Legacy\Content\StorageFieldDefinition; @@ -33,25 +33,22 @@ class Mapper */ protected $converterRegistry; - /** @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator */ - private $maskGenerator; + private Gateway $gateway; + + private LanguageHandler $languageHandler; private StorageDispatcherInterface $storageDispatcher; - /** - * Creates a new content type mapper. - * - * @param \Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry $converterRegistry - * @param \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator $maskGenerator - */ public function __construct( ConverterRegistry $converterRegistry, - MaskGenerator $maskGenerator, + Gateway $gateway, + LanguageHandler $languageHandler, StorageDispatcherInterface $storageDispatcher, private readonly FieldTypeAliasResolverInterface $fieldTypeAliasResolver ) { $this->converterRegistry = $converterRegistry; - $this->maskGenerator = $maskGenerator; + $this->gateway = $gateway; + $this->languageHandler = $languageHandler; $this->storageDispatcher = $storageDispatcher; } @@ -132,10 +129,20 @@ public function extractTypesFromRows(array $rows, bool $keepTypeIdAsKey = false) $rowsByAttributeId[$attributeId][] = $row; } + $typeIdStatusPairs = []; + foreach ($rows as $row) { + $typeIdStatusPairs[$row['content_type_id'] . ':' . $row['content_type_status']] = [ + 'id' => (int)$row['content_type_id'], + 'status' => (int)$row['content_type_status'], + ]; + } + $typeTranslations = $this->gateway->loadContentTypeTranslations(array_values($typeIdStatusPairs)); + foreach ($rows as $row) { $typeId = (int)$row['content_type_id']; if (!isset($types[$typeId])) { - $types[$typeId] = $this->extractTypeFromRow($row); + $languageCodes = $typeTranslations[$row['content_type_id'] . ':' . $row['content_type_status']] ?? []; + $types[$typeId] = $this->extractTypeFromRow($row, $languageCodes); } $fieldId = (int)$row['content_type_field_definition_id']; @@ -183,10 +190,9 @@ public function extractMultilingualData(array $fieldDefinitionRows): array * Creates a Type from the data in $row. * * @param array $row - * - * @return \Ibexa\Contracts\Core\Persistence\Content\Type + * @param string[] $languageCodes */ - protected function extractTypeFromRow(array $row) + protected function extractTypeFromRow(array $row, array $languageCodes = []): Type { $type = new Type(); @@ -214,7 +220,7 @@ protected function extractTypeFromRow(array $row) $type->defaultAlwaysAvailable = ($row['content_type_always_available'] == 1); $type->sortField = (int)$row['content_type_sort_field']; $type->sortOrder = (int)$row['content_type_sort_order']; - $type->languageCodes = $this->maskGenerator->extractLanguageCodesFromMask((int)$row['content_type_language_mask']); + $type->languageCodes = $languageCodes; $type->groupIds = []; $type->fieldDefinitions = []; @@ -261,8 +267,7 @@ public function extractFieldFromRow(array $row, array $multilingualData = [], in $field->isSearchable = (bool)$row['content_type_field_definition_is_searchable']; $field->position = (int)$row['content_type_field_definition_placement']; - $mainLanguageCode = $this->maskGenerator->extractLanguageCodesFromMask((int)$row['content_type_initial_language_id']); - $field->mainLanguageCode = array_shift($mainLanguageCode); + $field->mainLanguageCode = $this->languageHandler->load((int)$row['content_type_initial_language_id'])->languageCode; $this->toFieldDefinition($storageFieldDef, $field, $status); @@ -313,12 +318,12 @@ protected function extractStorageFieldFromRow(array $row, array $multilingualDat $storageFieldDef->serializedDataText = $row['content_type_field_definition_serialized_data_text']; foreach ($multilingualDataRow as $languageDataRow) { - $languageCodes = $this->maskGenerator->extractLanguageCodesFromMask((int)$languageDataRow['content_type_field_definition_multilingual_language_id']); + $multilingualLanguageId = (int)($languageDataRow['content_type_field_definition_multilingual_language_id'] ?? 0); - if (empty($languageCodes)) { + if ($multilingualLanguageId === 0) { continue; } - $languageCode = reset($languageCodes); + $languageCode = $this->languageHandler->load($multilingualLanguageId)->languageCode; $multilingualData = new MultilingualStorageFieldDefinition(); @@ -448,7 +453,7 @@ public function toStorageFieldDefinition( $multilingualData->name = $fieldDef->name[$languageCode]; $multilingualData->description = $fieldDef->description[$languageCode] ?? null; $multilingualData->languageId = - $this->maskGenerator->generateLanguageMaskFromLanguageCodes([$languageCode]); + $this->languageHandler->loadByLanguageCode($languageCode)->id; $storageFieldDef->multilingualData[$languageCode] = $multilingualData; } diff --git a/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway.php b/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway.php index f06aa72ba9..4e81c55480 100644 --- a/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway.php +++ b/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway.php @@ -70,14 +70,16 @@ abstract public function isRootEntry(int $id): bool; /** * Update single row data matched by composite primary key. * - * @param array $values associative array with column names as keys and column values as values + * @param array $values associative array with column names as keys and column values as + * values - "language_ids" (int[]) is a pseudo-column: it replaces the row's + * translations in "ibexa_url_alias_ml_translation" instead of mapping to a real column. */ abstract public function updateRow(int $parentId, string $textMD5, array $values): void; /** * Insert new row into urlalias_ml table. * - * @param array $values + * @param array $values see {@see updateRow()} for the "language_ids" pseudo-column */ abstract public function insertRow(array $values): int; @@ -90,8 +92,8 @@ abstract public function loadRow(int $parentId, string $textMD5): array; * Downgrade autogenerated entry matched by given $action and $languageId and negatively matched by * composite primary key. * - * If language mask of the found entry is composite (meaning it consists of multiple language ids) given - * $languageId will be removed from mask. Otherwise entry will be marked as history. + * If the found entry carries more than one language, $languageId is removed from its set of + * translations. Otherwise entry will be marked as history. */ abstract public function cleanupAfterPublish( string $action, @@ -102,12 +104,14 @@ abstract public function cleanupAfterPublish( ): void; /** - * Archive entry with $action by $languageMask. + * Archive entries with $action carrying any of $languageIds. * - * Used when swapping Location aliases, this ensures that given $languageMask matches a + * Used when swapping Location aliases, this ensures that given $languageIds match a * single entry (database row). + * + * @param int[] $languageIds */ - abstract public function historizeBeforeSwap(string $action, int $languageMask): void; + abstract public function historizeBeforeSwap(string $action, array $languageIds): void; /** * Mark all entries with given $id as history entries. @@ -131,6 +135,15 @@ abstract public function reparent(int $oldParentId, int $newParentId): void; */ abstract public function loadPathData(int $id): array; + /** + * Loads the real (non-always-available) language ids a specific alias row is translated + * into, from "ibexa_url_alias_ml_translation" - the relational replacement for decoding + * "lang_mask". + * + * @return int[] + */ + abstract public function loadTranslationLanguageIds(int $parent, string $textMD5): array; + /** * Load path data identified by given ordered array of hierarchy data. * @@ -186,7 +199,7 @@ abstract public function getNextId(): int; abstract public function getLocationContentMainLanguageId(int $locationId): int; /** - * Remove languageId of removed translation from lang_mask and deletes single language rows for multiple Locations. + * Removes $languageId's translation rows for multiple Locations. * * @param string[] $actions actions for which to perform the update */ diff --git a/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabase.php index c22f98dd4e..eb185231a4 100644 --- a/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabase.php @@ -15,10 +15,10 @@ use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Core\Base\Exceptions\BadStateException; use Ibexa\Core\Base\Exceptions\DatabaseException; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\Location\Gateway as LocationGateway; use Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway; use RuntimeException; @@ -46,17 +46,17 @@ final class DoctrineDatabase extends Gateway 'is_original' => ParameterType::INTEGER, 'action' => ParameterType::STRING, 'action_type' => ParameterType::STRING, - 'lang_mask' => ParameterType::INTEGER, 'text' => ParameterType::STRING, 'parent' => ParameterType::INTEGER, 'text_md5' => ParameterType::STRING, + 'is_always_available' => ParameterType::BOOLEAN, ]; private string $table; public function __construct( private Connection $connection, - private LanguageMaskGenerator $languageMaskGenerator + private LanguageHandler $languageHandler ) { $this->table = static::TABLE; } @@ -96,14 +96,14 @@ public function loadLocationEntries( 'link', 'is_alias', 'alias_redirects', - 'lang_mask', + 'is_always_available', 'is_original', 'parent', 'text', 'text_md5', 'action' ) - ->from($this->connection->quoteIdentifier($this->table)) + ->from($this->connection->quoteIdentifier($this->table), 'u') ->where( $expr->eq( 'action', @@ -128,15 +128,7 @@ public function loadLocationEntries( ; if (null !== $languageId) { - $query->andWhere( - $expr->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'lang_mask', - $query->createPositionalParameter($languageId, ParameterType::INTEGER) - ), - 0 - ) - ); + $query->andWhere($this->buildTranslationExistsCondition($query, 'u.parent', 'u.text_md5', [$languageId])); } $statement = $query->executeQuery(); @@ -160,12 +152,12 @@ public function listGlobalEntries( 'link', 'is_alias', 'alias_redirects', - 'lang_mask', + 'is_always_available', 'is_original', 'parent', 'text_md5' ) - ->from($this->connection->quoteIdentifier($this->table)) + ->from($this->connection->quoteIdentifier($this->table), 'u') ->where( $expr->eq( 'action_type', @@ -193,21 +185,8 @@ public function listGlobalEntries( ->setFirstResult($offset); if (isset($languageCode)) { - $query->andWhere( - $expr->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'lang_mask', - $query->createPositionalParameter( - $this->languageMaskGenerator->generateLanguageIndicator( - $languageCode, - false - ), - ParameterType::INTEGER - ) - ), - 0 - ) - ); + $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id; + $query->andWhere($this->buildTranslationExistsCondition($query, 'u.parent', 'u.text_md5', [$languageId])); } $statement = $query->executeQuery(); @@ -252,10 +231,9 @@ public function cleanupAfterPublish( $query ->select( 'parent', - 'text_md5', - 'lang_mask' + 'text_md5' ) - ->from($this->connection->quoteIdentifier($this->table)) + ->from($this->connection->quoteIdentifier($this->table), 'u') // 1) Autogenerated aliases that match action and language... ->where( $expr->eq( @@ -275,15 +253,7 @@ public function cleanupAfterPublish( $query->createPositionalParameter(0, ParameterType::INTEGER) ) ) - ->andWhere( - $expr->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'lang_mask', - $query->createPositionalParameter($languageId, ParameterType::INTEGER) - ), - 0 - ) - ) + ->andWhere($this->buildTranslationExistsCondition($query, 'u.parent', 'u.text_md5', [$languageId])) // 2) ...but not newly published entry ->andWhere( sprintf( @@ -307,10 +277,9 @@ public function cleanupAfterPublish( if (!empty($row)) { $this->archiveUrlAliasForDeletedTranslation( - (int)$row['lang_mask'], - (int)$languageId, (int)$row['parent'], $row['text_md5'], + (int)$languageId, (int)$newId ); } @@ -319,19 +288,24 @@ public function cleanupAfterPublish( /** * Archive (remove or historize) obsolete URL aliases (for translations that were removed). * - * @param int $languageMask all languages bit mask - * @param int $languageId removed language Id - * @param string $textMD5 checksum + * If the alias still carries other real (non-always-available) languages besides the removed + * one, only that language's translation row is removed; otherwise the whole entry is obsolete + * and gets historized instead. */ private function archiveUrlAliasForDeletedTranslation( - int $languageMask, - int $languageId, int $parent, string $textMD5, + int $languageId, int $linkId ): void { - // If language mask is composite (consists of multiple languages) then remove given language from entry - if ($languageMask & ~($languageId | 1)) { + $hasOtherLanguages = (bool)$this->connection->fetchOne( + 'SELECT 1 FROM ibexa_url_alias_ml_translation + WHERE parent = :parent AND text_md5 = :textMd5 AND language_id != :languageId', + ['parent' => $parent, 'textMd5' => $textMD5, 'languageId' => $languageId], + ['parent' => ParameterType::INTEGER, 'textMd5' => ParameterType::STRING, 'languageId' => ParameterType::INTEGER] + ); + + if ($hasOtherLanguages) { $this->removeTranslation($parent, $textMD5, $languageId); } else { // Otherwise mark entry as history @@ -339,11 +313,16 @@ private function archiveUrlAliasForDeletedTranslation( } } - public function historizeBeforeSwap(string $action, int $languageMask): void + /** + * @param int[] $languageIds real (non-always-available) language ids the swapped entry carries + */ + public function historizeBeforeSwap(string $action, array $languageIds): void { + $tableName = $this->connection->quoteIdentifier($this->table); + $query = $this->connection->createQueryBuilder(); $query - ->update($this->connection->quoteIdentifier($this->table)) + ->update($tableName) ->set( 'is_original', $query->createPositionalParameter(0, ParameterType::INTEGER) @@ -365,16 +344,7 @@ public function historizeBeforeSwap(string $action, int $languageMask): void 'is_original', $query->createPositionalParameter(1, ParameterType::INTEGER) ), - $query->expr()->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'lang_mask', - $query->createPositionalParameter( - $languageMask & ~1, - ParameterType::INTEGER - ) - ), - 0 - ) + $this->buildTranslationExistsCondition($query, "{$tableName}.parent", "{$tableName}.text_md5", $languageIds) ) ); @@ -429,45 +399,15 @@ private function historize(int $parentId, string $textMD5, int $newId): void } /** - * Update single row data matched by composite primary key. - * - * Removes given $languageId from entry's language mask + * Removes given $languageId from entry's set of translations. */ private function removeTranslation(int $parentId, string $textMD5, int $languageId): void { - $query = $this->connection->createQueryBuilder(); - $query - ->update($this->connection->quoteIdentifier($this->table)) - ->set( - 'lang_mask', - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'lang_mask', - $query->createPositionalParameter( - ~$languageId, - ParameterType::INTEGER - ) - ) - ) - ->where( - $query->expr()->eq( - 'parent', - $query->createPositionalParameter( - $parentId, - ParameterType::INTEGER - ) - ) - ) - ->andWhere( - $query->expr()->eq( - 'text_md5', - $query->createPositionalParameter( - $textMD5, - ParameterType::STRING - ) - ) - ) - ; - $query->executeStatement(); + $this->connection->executeStatement( + 'DELETE FROM ibexa_url_alias_ml_translation WHERE parent = :parent AND text_md5 = :textMd5 AND language_id = :languageId', + ['parent' => $parentId, 'textMd5' => $textMD5, 'languageId' => $languageId], + ['parent' => ParameterType::INTEGER, 'textMd5' => ParameterType::STRING, 'languageId' => ParameterType::INTEGER] + ); } public function historizeId(int $id, int $link): void @@ -536,8 +476,17 @@ public function reparent(int $oldParentId, int $newParentId): void $query->executeStatement(); } + /** + * @param array $values associative array with column names as keys and column + * values as values - "language_ids" (int[]) is a pseudo-column: it does not map to a + * real table column, and instead replaces the row's translations in + * "ibexa_url_alias_ml_translation". + */ public function updateRow(int $parentId, string $textMD5, array $values): void { + $languageIds = $values['language_ids'] ?? null; + unset($values['language_ids']); + $query = $this->connection->createQueryBuilder(); $query->update($this->connection->quoteIdentifier($this->table)); foreach ($values as $columnName => $value) { @@ -564,6 +513,10 @@ public function updateRow(int $parentId, string $textMD5, array $values): void ) ); $query->executeStatement(); + + if (null !== $languageIds) { + $this->syncUrlAliasTranslations($parentId, $textMD5, $languageIds); + } } public function insertRow(array $values): int @@ -596,6 +549,9 @@ public function insertRow(array $values): int $values['is_original'] = 1; } + $languageIds = $values['language_ids'] ?? null; + unset($values['language_ids']); + $query = $this->connection->createQueryBuilder(); $query->insert($this->connection->quoteIdentifier($this->table)); foreach ($values as $columnName => $value) { @@ -610,9 +566,35 @@ public function insertRow(array $values): int } $query->executeStatement(); + if (null !== $languageIds) { + $this->syncUrlAliasTranslations((int)$values['parent'], (string)$values['text_md5'], $languageIds); + } + return (int)$values['id']; } + /** + * Replaces $parentId/$textMD5's rows in "ibexa_url_alias_ml_translation" with $languageIds. + * + * @param int[] $languageIds + */ + private function syncUrlAliasTranslations(int $parentId, string $textMD5, array $languageIds): void + { + $this->connection->executeStatement( + 'DELETE FROM ibexa_url_alias_ml_translation WHERE parent = :parent AND text_md5 = :textMd5', + ['parent' => $parentId, 'textMd5' => $textMD5], + ['parent' => ParameterType::INTEGER, 'textMd5' => ParameterType::STRING] + ); + + foreach ($languageIds as $languageId) { + $this->connection->executeStatement( + 'INSERT INTO ibexa_url_alias_ml_translation (parent, text_md5, language_id) VALUES (:parent, :textMd5, :languageId)', + ['parent' => $parentId, 'textMd5' => $textMD5, 'languageId' => $languageId], + ['parent' => ParameterType::INTEGER, 'textMd5' => ParameterType::STRING, 'languageId' => ParameterType::INTEGER] + ); + } + } + public function getNextId(): int { $query = $this->connection->createQueryBuilder(); @@ -761,7 +743,8 @@ public function loadPathData(int $id): array $query = $this->connection->createQueryBuilder(); $query->select( 'parent', - 'lang_mask', + 'text_md5', + 'is_always_available', 'text' )->from( $this->connection->quoteIdentifier($this->table) @@ -834,7 +817,9 @@ public function loadPathDataByHierarchy(array $hierarchyData): array $query->select( 'action', - 'lang_mask', + 'parent', + 'text_md5', + 'is_always_available', 'text' )->from( $this->connection->quoteIdentifier($this->table) @@ -999,21 +984,28 @@ public function getLocationContentMainLanguageId(int $locationId): int public function bulkRemoveTranslation(int $languageId, array $actions): void { - $query = $this->connection->createQueryBuilder(); - $query - ->update($this->connection->quoteIdentifier($this->table)) - // parameter for bitwise operation has to be placed verbatim (w/o binding) for this to work cross-DBMS - ->set('lang_mask', 'lang_mask & ~ ' . $languageId) - ->where('action IN (:actions)') - ->setParameter('actions', $actions, ArrayParameterType::STRING); - $query->executeStatement(); + $this->connection->executeStatement( + 'DELETE FROM ibexa_url_alias_ml_translation + WHERE language_id = :languageId + AND EXISTS ( + SELECT 1 FROM ' . $this->connection->quoteIdentifier($this->table) . ' u + WHERE u.parent = ibexa_url_alias_ml_translation.parent + AND u.text_md5 = ibexa_url_alias_ml_translation.text_md5 + AND u.action IN (:actions) + )', + ['languageId' => $languageId, 'actions' => $actions], + ['languageId' => ParameterType::INTEGER, 'actions' => ArrayParameterType::STRING] + ); - // cleanup: delete single language rows (including alwaysAvailable) + // cleanup: delete rows left with no real (non-always-available) language at all + $tableName = $this->connection->quoteIdentifier($this->table); $query = $this->connection->createQueryBuilder(); $query - ->delete($this->connection->quoteIdentifier($this->table)) + ->delete($tableName) ->where('action IN (:actions)') - ->andWhere('lang_mask IN (0, 1)') + ->andWhere( + "NOT EXISTS (SELECT 1 FROM ibexa_url_alias_ml_translation ut WHERE ut.parent = {$tableName}.parent AND ut.text_md5 = {$tableName}.text_md5)" + ) ->setParameter('actions', $actions, ArrayParameterType::STRING); $query->executeStatement(); } @@ -1035,16 +1027,10 @@ public function archiveUrlAliasesForDeletedTranslations( $languageIds ); - // remove specific languages from a bit mask + // remove each row's actually-present removed languages foreach ($rows as $row) { - // filter mask to reduce the number of calls to storage engine - $rowLanguageMask = (int)$row['lang_mask']; - $languageIdsToBeRemoved = array_filter( - $languageIds, - static function ($languageId) use ($rowLanguageMask): int { - return $languageId & $rowLanguageMask; - } - ); + $rowLanguageIds = $this->loadTranslationLanguageIds((int)$row['parent'], $row['text_md5']); + $languageIdsToBeRemoved = array_intersect($languageIds, $rowLanguageIds); if (empty($languageIdsToBeRemoved)) { continue; @@ -1056,16 +1042,27 @@ static function ($languageId) use ($rowLanguageMask): int { : (int)$row['id']; foreach ($languageIdsToBeRemoved as $languageId) { $this->archiveUrlAliasForDeletedTranslation( - (int)$row['lang_mask'], - (int)$languageId, (int)$row['parent'], $row['text_md5'], + (int)$languageId, $linkToId ); } } } + public function loadTranslationLanguageIds(int $parent, string $textMD5): array + { + return array_map( + 'intval', + $this->connection->fetchFirstColumn( + 'SELECT language_id FROM ibexa_url_alias_ml_translation WHERE parent = :parent AND text_md5 = :textMd5', + ['parent' => $parent, 'textMd5' => $textMD5], + ['parent' => ParameterType::INTEGER, 'textMd5' => ParameterType::STRING] + ) + ); + } + /** * Load list of aliases for given $locationId matching any of the specified Languages. * @@ -1075,22 +1072,18 @@ private function loadLocationEntriesMatchingMultipleLanguages( int $locationId, array $languageIds ): array { - // note: alwaysAvailable for this use case is not relevant - $languageMask = $this->languageMaskGenerator->generateLanguageMaskFromLanguageIds( - $languageIds, - false - ); - - /** @var \Doctrine\DBAL\Connection $connection */ $query = $this->connection->createQueryBuilder(); $query - ->select('id', 'lang_mask', 'parent', 'text_md5') - ->from($this->connection->quoteIdentifier($this->table)) - ->where('action = :action') + ->select('id', 'parent', 'text_md5') + ->from($this->connection->quoteIdentifier($this->table), 'u') + ->where( + $query->expr()->eq( + 'action', + $query->createPositionalParameter('eznode:' . $locationId, ParameterType::STRING) + ) + ) // fetch rows matching any of the given Languages - ->andWhere('lang_mask & :languageMask <> 0') - ->setParameter('action', 'eznode:' . $locationId) - ->setParameter('languageMask', $languageMask); + ->andWhere($this->buildTranslationExistsCondition($query, 'u.parent', 'u.text_md5', $languageIds)); $statement = $query->executeQuery(); @@ -1214,12 +1207,13 @@ public function repairBrokenUrlAliasesForLocation(int $locationId): void ->setParameter('action', "eznode:{$locationId}"); foreach ($urlAliasesData as $urlAliasData) { - if ($urlAliasData['is_original'] === 1 || !isset($originalUrlAliases[$urlAliasData['lang_mask']])) { + $languageSetKey = $this->buildLanguageSetKey((int)$urlAliasData['parent'], $urlAliasData['text_md5']); + if ($urlAliasData['is_original'] === 1 || !isset($originalUrlAliases[$languageSetKey])) { // ignore non-archived entries and deleted Translations continue; } - $originalUrlAlias = $originalUrlAliases[$urlAliasData['lang_mask']]; + $originalUrlAlias = $originalUrlAliases[$languageSetKey]; if ($urlAliasData['link'] === $originalUrlAlias['link']) { // ignore correct entries to avoid unnecessary updates @@ -1323,9 +1317,9 @@ public function getAllChildrenAliases(int $parentId): array } /** - * Filter from the given result set original (current) only URL aliases and index them by language_mask. - * - * Note: each language_mask can have one URL Alias. + * Filter from the given result set original (current) only URL aliases and index them by their + * real (non-always-available) language id set - the relational replacement for indexing by + * "lang_mask" (each distinct language set can have one URL Alias). * * @param array $urlAliasesData */ @@ -1339,11 +1333,26 @@ static function ($urlAliasData): bool { } ); - // return language_mask-indexed array - return array_combine( - array_column($originalUrlAliases, 'lang_mask'), - $originalUrlAliases - ); + $keyedUrlAliases = []; + foreach ($originalUrlAliases as $urlAliasData) { + $languageSetKey = $this->buildLanguageSetKey((int)$urlAliasData['parent'], $urlAliasData['text_md5']); + $keyedUrlAliases[$languageSetKey] = $urlAliasData; + } + + return $keyedUrlAliases; + } + + /** + * Builds a stable identity key for the real (non-always-available) language id set a specific + * alias row is translated into - used to match an archived alias row to the still-current alias + * row covering the same languages. + */ + private function buildLanguageSetKey(int $parent, string $textMD5): string + { + $languageIds = $this->loadTranslationLanguageIds($parent, $textMD5); + sort($languageIds); + + return implode(',', $languageIds); } /** @@ -1386,7 +1395,6 @@ private function getUrlAliasesForLocation(int $locationId): array ->select( 't1.id', 't1.is_original', - 't1.lang_mask', 't1.link', 't1.parent', // show existing parent only if its row exists, special case for root parent @@ -1445,4 +1453,47 @@ private function getDatabasePlatform(): AbstractPlatform throw DatabaseException::wrap($e); } } + + /** + * Builds an "at least one of $languageIds is a real translation of this row" condition against + * "ibexa_url_alias_ml_translation", correlated via $parentColumn/$textMd5Column (must be + * qualified with $query's own table alias) - the relational replacement for bitwise-AND-ing a + * row's "lang_mask". + * + * $parentColumn/$textMd5Column must be alias-qualified (e.g. "u.parent"), never bare column + * names - "ibexa_url_alias_ml_translation" has its own same-named columns, so an unqualified + * reference inside the subquery would resolve to its own column (the innermost scope), silently + * turning the correlation into a tautology instead of a real join back to the outer row. + * + * @param int[] $languageIds + */ + private function buildTranslationExistsCondition( + \Doctrine\DBAL\Query\QueryBuilder $query, + string $parentColumn, + string $textMd5Column, + array $languageIds + ): string { + if (empty($languageIds)) { + // No language can ever match an empty set - mirrors the old mask check being + // vacuously false for a zero mask. + return '1 = 0'; + } + + $translationExistsSubQuery = $this->connection->createQueryBuilder(); + $translationExistsSubQuery + ->select('1') + ->from('ibexa_url_alias_ml_translation', 'ut') + ->where( + $translationExistsSubQuery->expr()->and( + "ut.parent = {$parentColumn}", + "ut.text_md5 = {$textMd5Column}", + $translationExistsSubQuery->expr()->in( + 'ut.language_id', + $query->createPositionalParameter($languageIds, ArrayParameterType::INTEGER) + ) + ) + ); + + return sprintf('EXISTS (%s)', $translationExistsSubQuery->getSQL()); + } } diff --git a/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/ExceptionConversion.php b/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/ExceptionConversion.php index 79183277e0..767d39310c 100644 --- a/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/ExceptionConversion.php +++ b/src/lib/Persistence/Legacy/Content/UrlAlias/Gateway/ExceptionConversion.php @@ -88,10 +88,10 @@ public function cleanupAfterPublish( } } - public function historizeBeforeSwap(string $action, int $languageMask): void + public function historizeBeforeSwap(string $action, array $languageIds): void { try { - $this->innerGateway->historizeBeforeSwap($action, $languageMask); + $this->innerGateway->historizeBeforeSwap($action, $languageIds); } catch (DBALException|PDOException $e) { throw DatabaseException::wrap($e); } @@ -199,6 +199,15 @@ public function loadPathData(int $id): array } } + public function loadTranslationLanguageIds(int $parent, string $textMD5): array + { + try { + return $this->innerGateway->loadTranslationLanguageIds($parent, $textMD5); + } catch (DBALException|PDOException $e) { + throw DatabaseException::wrap($e); + } + } + public function loadPathDataByHierarchy(array $hierarchyData): array { try { diff --git a/src/lib/Persistence/Legacy/Content/UrlAlias/Handler.php b/src/lib/Persistence/Legacy/Content/UrlAlias/Handler.php index fcdebfa7ce..1609bf54a4 100644 --- a/src/lib/Persistence/Legacy/Content/UrlAlias/Handler.php +++ b/src/lib/Persistence/Legacy/Content/UrlAlias/Handler.php @@ -18,7 +18,7 @@ use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\Base\Exceptions\NotFoundException; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; +use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway as LanguageGateway; use Ibexa\Core\Persistence\Legacy\Content\Location\Gateway as LocationGateway; use Ibexa\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties; use Ibexa\Core\Persistence\Legacy\Content\UrlAlias\DTO\UrlAliasForSwappedLocation; @@ -96,16 +96,11 @@ class Handler implements UrlAliasHandlerInterface */ protected $contentGateway; - /** - * Language mask generator. - * - * @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator - */ - protected $maskGenerator; - /** @var \Ibexa\Contracts\Core\Persistence\TransactionHandler */ private $transactionHandler; + private LanguageGateway $languageGateway; + /** * Creates a new UrlAlias Handler. * @@ -115,7 +110,6 @@ class Handler implements UrlAliasHandlerInterface * @param \Ibexa\Contracts\Core\Persistence\Content\Language\Handler $languageHandler * @param \Ibexa\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter $slugConverter * @param \Ibexa\Core\Persistence\Legacy\Content\Gateway $contentGateway - * @param \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator $maskGenerator * @param \Ibexa\Contracts\Core\Persistence\TransactionHandler $transactionHandler */ public function __construct( @@ -125,8 +119,8 @@ public function __construct( LanguageHandler $languageHandler, SlugConverter $slugConverter, ContentGateway $contentGateway, - MaskGenerator $maskGenerator, - TransactionHandler $transactionHandler + TransactionHandler $transactionHandler, + LanguageGateway $languageGateway ) { $this->gateway = $gateway; $this->mapper = $mapper; @@ -134,8 +128,8 @@ public function __construct( $this->languageHandler = $languageHandler; $this->slugConverter = $slugConverter; $this->contentGateway = $contentGateway; - $this->maskGenerator = $maskGenerator; $this->transactionHandler = $transactionHandler; + $this->languageGateway = $languageGateway; } public function publishUrlAliasForLocation( @@ -184,7 +178,7 @@ private function internalPublishUrlAliasForLocation( $parentId = $this->getRealAliasId($parentLocationId); $name = $this->slugConverter->convert($name, 'location_' . $locationId); $uniqueCounter = $this->slugConverter->getUniqueCounterValue($name, $parentId == 0); - $languageMask = $languageId | (int)$alwaysAvailable; + $languageIds = [$languageId]; $action = 'eznode:' . $locationId; $cleanup = false; @@ -215,7 +209,8 @@ private function internalPublishUrlAliasForLocation( 'link' => $newId, 'parent' => $parentId, 'action' => $action, - 'lang_mask' => $languageMask, + 'language_ids' => $languageIds, + 'is_always_available' => $alwaysAvailable, 'text' => $newText, 'text_md5' => $newTextMD5, ] @@ -254,8 +249,11 @@ private function internalPublishUrlAliasForLocation( $cleanup = true; $newId = $existingLocationEntry['id']; if ($existingLocationEntry['id'] == $row['id']) { - // If we are reusing existing location entry merge existing language mask - $languageMask |= ($row['lang_mask'] & ~1); + // If we are reusing existing location entry merge its existing real languages + $languageIds = array_unique(array_merge( + $languageIds, + $this->gateway->loadTranslationLanguageIds($parentId, $newTextMD5) + )); } } elseif ($newId === null) { // Use reused row ID only if publishing normally, else use given $newId @@ -269,7 +267,8 @@ private function internalPublishUrlAliasForLocation( 'action' => $action, // In case when NOP row was reused 'action_type' => 'eznode', - 'lang_mask' => $languageMask, + 'language_ids' => $languageIds, + 'is_always_available' => $alwaysAvailable, // Updating text ensures that letter case changes are stored 'text' => $newText, // Set "id" and "link" for case when reusable entry is history @@ -441,13 +440,15 @@ protected function createUrlAlias($action, $path, $forward, $languageCode, $alwa // If nothing was returned perform insert if ($isPathNew || empty($row)) { - $data['lang_mask'] = $languageId | (int)$alwaysAvailable; + $data['language_ids'] = [$languageId]; + $data['is_always_available'] = $alwaysAvailable; $id = $this->gateway->insertRow($data); } elseif ($row['action'] === Gateway::NOP_ACTION || (int)$row['is_original'] === 0) { // Row exists, check if it is reusable. There are 2 cases when this is possible: // 1. NOP entry // 2. history entry - $data['lang_mask'] = $languageId | (int)$alwaysAvailable; + $data['language_ids'] = [$languageId]; + $data['is_always_available'] = $alwaysAvailable; // If history is reused move link to id $data['link'] = $id = $row['id']; $this->gateway->updateRow( @@ -458,11 +459,15 @@ protected function createUrlAlias($action, $path, $forward, $languageCode, $alwa } elseif ( $row['action'] === $action && (int)$row['is_alias'] === 1 && - 0 === ((int)$row['lang_mask'] & $languageId) + !in_array($languageId, $this->gateway->loadTranslationLanguageIds($parentId, $topElementMD5), true) ) { // add another language to the same custom alias $data['link'] = $id = $row['id']; - $data['lang_mask'] = $row['lang_mask'] | $languageId | (int)$alwaysAvailable; + $data['language_ids'] = array_unique(array_merge( + $this->gateway->loadTranslationLanguageIds($parentId, $topElementMD5), + [$languageId] + )); + $data['is_always_available'] = $alwaysAvailable || (bool)$row['is_always_available']; $this->gateway->updateRow( $parentId, $topElementMD5, @@ -493,7 +498,8 @@ protected function insertNopEntry($parentId, $text, $textMD5) { return $this->gateway->insertRow( [ - 'lang_mask' => 1, + 'language_ids' => [], + 'is_always_available' => true, 'action' => Gateway::NOP_ACTION, 'parent' => $parentId, 'text' => $text, @@ -732,8 +738,8 @@ public function locationSwapped($location1Id, $location1ParentId, $location2Id, $names1 = $this->getNamesForAllLanguages($contentInfo1); $names2 = $this->getNamesForAllLanguages($contentInfo2); - $location1->isAlwaysAvailable = $this->maskGenerator->isAlwaysAvailable($contentInfo1['language_mask']); - $location2->isAlwaysAvailable = $this->maskGenerator->isAlwaysAvailable($contentInfo2['language_mask']); + $location1->isAlwaysAvailable = (bool)$contentInfo1['always_available']; + $location2->isAlwaysAvailable = (bool)$contentInfo2['always_available']; $languages = $this->languageHandler->loadAll(); @@ -761,8 +767,18 @@ public function locationSwapped($location1Id, $location1ParentId, $location2Id, } } - $this->internalPublishCustomUrlAliasForLocation($location1, $contentInfo1['language_mask']); - $this->internalPublishCustomUrlAliasForLocation($location2, $contentInfo2['language_mask']); + $contentTranslations = $this->languageGateway->loadContentTranslations( + [(int)$contentInfo1['id'], (int)$contentInfo2['id']] + ); + + $this->internalPublishCustomUrlAliasForLocation( + $location1, + $contentTranslations[(int)$contentInfo1['id']] ?? [] + ); + $this->internalPublishCustomUrlAliasForLocation( + $location2, + $contentTranslations[(int)$contentInfo2['id']] ?? [] + ); } /** @@ -805,11 +821,17 @@ private function getNamesForAllLanguages(array $contentInfo) private function historizeBeforeSwap($location1Entries, $location2Entries) { foreach ($location1Entries as $row) { - $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']); + $this->gateway->historizeBeforeSwap( + $row['action'], + $this->gateway->loadTranslationLanguageIds((int)$row['parent'], $row['text_md5']) + ); } foreach ($location2Entries as $row) { - $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']); + $this->gateway->historizeBeforeSwap( + $row['action'], + $this->gateway->loadTranslationLanguageIds((int)$row['parent'], $row['text_md5']) + ); } } @@ -923,8 +945,12 @@ private function getLocationEntryInLanguage(array $locationEntries, $languageId) { $entries = array_filter( $locationEntries, - static function (array $row) use ($languageId): bool { - return (bool) ($row['lang_mask'] & $languageId); + function (array $row) use ($languageId): bool { + return in_array( + $languageId, + $this->gateway->loadTranslationLanguageIds((int)$row['parent'], $row['text_md5']), + true + ); } ); @@ -988,6 +1014,7 @@ protected function copySubtree($actionMap, $oldParentAliasId, $newParentAliasId, $newIdsMap[$oldParentAliasId] = $this->gateway->getNextId(); } + $row['language_ids'] = $this->gateway->loadTranslationLanguageIds((int)$row['parent'], $row['text_md5']); $row['action'] = $actionMap[$row['action']]; $row['parent'] = $newParentAliasId; $row['id'] = $row['link'] = $newIdsMap[$oldParentAliasId]; @@ -1030,6 +1057,8 @@ public function locationDeleted($locationId): array $action = 'eznode:' . $locationId; $entry = $this->gateway->loadAutogeneratedEntry($action); $entryId = $entry['id']; + // captured before removeSubtree() deletes the row (and cascades its translation rows away) + $entry['language_ids'] = $this->gateway->loadTranslationLanguageIds((int)$entry['parent'], $entry['text_md5']); $this->removeSubtree($entryId, $action, $entry['is_original']); @@ -1182,19 +1211,28 @@ private function insertAliasEntryAsNop(array $aliasEntry): void } /** - * Internal publish custom aliases method, accepting language mask to set correct language mask on url aliases - * new alias ID (used when swapping Locations). + * Internal publish custom aliases method, accepting the swapped Location's Content's real + * (non-always-available) language ids to set the correct languages on url aliases new alias ID + * (used when swapping Locations). + * + * $contentLanguageIds are the new Content's own real translation languages (from + * ibexa_content_translation) - $location->isAlwaysAvailable (set from the always_available + * column by locationSwapped()) is combined separately, since always-available is no longer part + * of a language id set. + * + * @param int[] $contentLanguageIds */ - private function internalPublishCustomUrlAliasForLocation(SwappedLocationProperties $location, int $languageMask) + private function internalPublishCustomUrlAliasForLocation(SwappedLocationProperties $location, array $contentLanguageIds) { foreach ($location->entries as $entry) { if ((int)$entry['is_alias'] === 0) { continue; } - $mask = (int)$entry['lang_mask'] & $languageMask; + $entryLanguageIds = $this->gateway->loadTranslationLanguageIds((int)$entry['parent'], $entry['text_md5']); + $intersectedLanguageIds = array_intersect($entryLanguageIds, $contentLanguageIds); - if ($mask <= 1) { + if (empty($intersectedLanguageIds) && !$location->isAlwaysAvailable) { continue; } @@ -1205,7 +1243,8 @@ private function internalPublishCustomUrlAliasForLocation(SwappedLocationPropert 'id' => (int)$entry['id'], 'is_original' => 1, 'is_alias' => 1, - 'lang_mask' => $mask, + 'language_ids' => $intersectedLanguageIds, + 'is_always_available' => $location->isAlwaysAvailable, ] ); } diff --git a/src/lib/Persistence/Legacy/Content/UrlAlias/Mapper.php b/src/lib/Persistence/Legacy/Content/UrlAlias/Mapper.php index 91cc9cf67e..dfce5b5f3f 100644 --- a/src/lib/Persistence/Legacy/Content/UrlAlias/Mapper.php +++ b/src/lib/Persistence/Legacy/Content/UrlAlias/Mapper.php @@ -7,29 +7,37 @@ namespace Ibexa\Core\Persistence\Legacy\Content\UrlAlias; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Persistence\Content\UrlAlias; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator; /** * UrlAlias Mapper. */ class Mapper { - /** - * Language mask generator. - * - * @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator - */ - protected $languageMaskGenerator; + private Gateway $gateway; + + private LanguageHandler $languageHandler; + + public function __construct(Gateway $gateway, LanguageHandler $languageHandler) + { + $this->gateway = $gateway; + $this->languageHandler = $languageHandler; + } /** - * Creates a new UrlWildcard Handler. + * @param int[] $languageIds * - * @param \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator $languageMaskGenerator + * @return string[] */ - public function __construct(LanguageMaskGenerator $languageMaskGenerator) + private function loadLanguageCodes(array $languageIds): array { - $this->languageMaskGenerator = $languageMaskGenerator; + $languageCodes = []; + foreach ($this->languageHandler->loadList($languageIds) as $language) { + $languageCodes[] = $language->languageCode; + } + + return $languageCodes; } /** @@ -46,8 +54,10 @@ public function extractUrlAliasFromData($data) list($type, $destination) = $this->matchTypeAndDestination($data['action']); $urlAlias->id = $this->generateIdentityKey((int)$data['parent'], $data['text_md5']); $urlAlias->pathData = $this->normalizePathData($data['raw_path_data']); - $urlAlias->languageCodes = $this->languageMaskGenerator->extractLanguageCodesFromMask($data['lang_mask']); - $urlAlias->alwaysAvailable = $this->languageMaskGenerator->isAlwaysAvailable($data['lang_mask']); + $urlAlias->languageCodes = $this->loadLanguageCodes( + $this->gateway->loadTranslationLanguageIds((int)$data['parent'], $data['text_md5']) + ); + $urlAlias->alwaysAvailable = (bool)$data['is_always_available']; $urlAlias->isHistory = isset($data['is_path_history']) ? $data['is_path_history'] : !$data['is_original']; $urlAlias->isCustom = (bool)$data['is_alias']; $urlAlias->forward = $data['is_alias'] && $data['alias_redirects']; @@ -83,12 +93,12 @@ public function extractUrlAliasListFromData(array $rows) */ public function extractLanguageCodesFromData(array $rows): array { - $languageMask = 0; + $languageIds = []; foreach ($rows as $row) { - $languageMask |= $row['lang_mask']; + $languageIds[] = $this->gateway->loadTranslationLanguageIds((int)$row['parent'], $row['text_md5']); } - return $this->languageMaskGenerator->extractLanguageCodesFromMask($languageMask); + return $this->loadLanguageCodes(array_unique(array_merge([], ...$languageIds))); } public function generateIdentityKey(int $parentId, string $hash): string @@ -163,8 +173,10 @@ protected function normalizePathData(array $pathData) */ protected function normalizePathDataRow(array &$pathElementData, array $row) { - $languageCodes = $this->languageMaskGenerator->extractLanguageCodesFromMask($row['lang_mask']); - $pathElementData['always-available'] = $this->languageMaskGenerator->isAlwaysAvailable($row['lang_mask']); + $languageCodes = $this->loadLanguageCodes( + $this->gateway->loadTranslationLanguageIds((int)$row['parent'], $row['text_md5']) + ); + $pathElementData['always-available'] = (bool)$row['is_always_available']; if (!empty($languageCodes)) { foreach ($languageCodes as $languageCode) { $pathElementData['translations'][$languageCode] = $row['text']; diff --git a/src/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilder.php b/src/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilder.php index b77af72573..a0605079a5 100644 --- a/src/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilder.php +++ b/src/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilder.php @@ -9,6 +9,7 @@ namespace Ibexa\Core\Persistence\Legacy\Filter\CriterionQueryBuilder\Content; use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\ParameterType; use Ibexa\Contracts\Core\Persistence\Filter\Doctrine\FilteringQueryBuilder; use Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion\LanguageCode; use Ibexa\Contracts\Core\Repository\Values\Filter\CriterionQueryBuilder; @@ -37,10 +38,15 @@ public function buildQueryConstraint( $queryBuilder ->joinOnce( 'version', + 'ibexa_content_version_translation', + 'version_translation', + 'version_translation.content_version_id = version.id' + ) + ->joinOnce( + 'version_translation', Gateway::CONTENT_LANGUAGE_TABLE, 'language', - // bitwise and for exact language ID match - 'language.id & version.language_mask = language.id' + 'language.id = version_translation.language_id' ); // at this point $criterion->value is guaranteed to be an array @@ -53,7 +59,13 @@ public function buildQueryConstraint( ); if ($criterion->matchAlwaysAvailable) { - $expr = (string)$queryBuilder->expr()->or($expr, 'version.language_mask & 1 = 1'); + $expr = (string)$queryBuilder->expr()->or( + $expr, + $queryBuilder->expr()->eq( + 'version.always_available', + $queryBuilder->createNamedParameter(true, ParameterType::BOOLEAN) + ) + ); } return $expr; diff --git a/src/lib/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php b/src/lib/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php index e6f5c17017..beffd1a3da 100644 --- a/src/lib/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php +++ b/src/lib/Persistence/Legacy/Filter/Gateway/Content/Doctrine/DoctrineGateway.php @@ -16,6 +16,7 @@ use Ibexa\Contracts\Core\Persistence\Filter\SortClauseVisitor; use Ibexa\Contracts\Core\Repository\Values\Filter\FilteringCriterion; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; +use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway as LanguageGateway; use Ibexa\Core\Persistence\Legacy\Content\Location\Gateway as LocationGateway; use Ibexa\Core\Persistence\Legacy\Filter\Gateway\Gateway; use function iterator_to_array; @@ -32,7 +33,7 @@ final class DoctrineGateway implements Gateway 'content_type_id' => 'content.content_type_id', 'content_current_version' => 'content.current_version', 'content_initial_language_id' => 'content.initial_language_id', - 'content_language_mask' => 'content.language_mask', + 'content_always_available' => 'content.always_available', 'content_modified' => 'content.modified', 'content_name' => 'content.name', 'content_owner_id' => 'content.owner_id', @@ -48,7 +49,7 @@ final class DoctrineGateway implements Gateway 'content_version_created' => 'version.created', 'content_version_modified' => 'version.modified', 'content_version_status' => 'version.status', - 'content_version_language_mask' => 'version.language_mask', + 'content_version_always_available' => 'version.always_available', 'content_version_initial_language_id' => 'version.initial_language_id', // Main Location (nullable) 'content_main_location_id' => 'main_location.main_node_id', @@ -58,7 +59,8 @@ public function __construct( private readonly Connection $connection, private readonly CriterionVisitor $criterionVisitor, private readonly SortClauseVisitor $sortClauseVisitor, - private readonly CountQueryBuilder $countQueryBuilder + private readonly CountQueryBuilder $countQueryBuilder, + private readonly LanguageGateway $languageGateway ) { } @@ -93,6 +95,7 @@ public function find( // get additional data for the same query constraints $names = $this->bulkFetchVersionNames(clone $query); $fieldValues = $this->bulkFetchFieldValues(clone $query); + $translations = $this->bulkFetchVersionTranslations(clone $query); $query->setFirstResult($offset); if ($limit > 0) { @@ -113,6 +116,7 @@ public function find( $contentId, $versionNo ); + $row['content_version_translations'] = $translations[(int)$row['content_version_id']] ?? []; yield $row; } @@ -203,7 +207,15 @@ private function bulkFetchVersionNames(FilteringQueryBuilder $query): array (string)$query->expr()->and( 'content.id = content_name.contentobject_id', 'version.version = content_name.content_version', - 'version.language_mask & content_name.language_id > 0' + // content_name.language_id may still carry a stray pre-migration "always + // available" bit (+1) that was never cleaned up retroactively - tolerate it + // the same defensive way LanguagePriorityConditionBuilder does for + // ibexa_content_field.language_id. + 'EXISTS ( + SELECT 1 FROM ibexa_content_version_translation cvt + WHERE cvt.content_version_id = version.id + AND content_name.language_id IN (cvt.language_id, cvt.language_id + 1) + )' ) ) // reset not needed parts, keeping FROM, other JOINs, and WHERE constraints @@ -239,7 +251,14 @@ private function bulkFetchFieldValues(FilteringQueryBuilder $query): array (string)$query->expr()->and( 'content.id = content_field.contentobject_id', 'version.version = content_field.version', - 'version.language_mask & content_field.language_id = content_field.language_id' + // content_field.language_id may still carry a stray pre-migration "always + // available" bit (+1) that was never cleaned up retroactively - see the same + // tolerance in bulkFetchVersionNames() above. + 'EXISTS ( + SELECT 1 FROM ibexa_content_version_translation cvt + WHERE cvt.content_version_id = version.id + AND content_field.language_id IN (cvt.language_id, cvt.language_id + 1) + )' ) ) // reset not needed parts, keeping FROM, other JOINs, and WHERE constraints @@ -250,6 +269,40 @@ private function bulkFetchFieldValues(FilteringQueryBuilder $query): array return $query->executeQuery()->fetchAllAssociative(); } + /** + * Bulk-fetches, for the same query constraints, which languages each matched content version is + * translated into - avoids the N+1 that would result from calling + * {@see LanguageGateway::loadVersionTranslations()} once per row in the data mapper instead. + * + * Deliberately reuses that same gateway method (batched with every matching version id at + * once) rather than reimplementing the query as a join against the main criteria query, like + * {@see bulkFetchVersionNames()}/{@see bulkFetchFieldValues()} above do: VersionInfo::$languageCodes' + * element order is derived directly from whichever order this underlying, deliberately + * ORDER BY-less query returns rows in (itself an accepted, long-standing implementation detail, + * not a documented contract) - a structurally different join+DISTINCT query has no reason to + * reproduce that same incidental order on every platform, and on Postgres specifically, it doesn't. + * + * @return array Content version id => language ids + */ + private function bulkFetchVersionTranslations(FilteringQueryBuilder $query): array + { + $query + // completely reset SELECT part to get only needed data + ->select('version.id AS content_version_id') + ->distinct() + // reset not needed parts, keeping FROM, other JOINs, and WHERE constraints + ->setMaxResults(null) + ->setFirstResult(0) + ->resetOrderBy(); + + $versionIds = array_map( + 'intval', + $query->executeQuery()->fetchFirstColumn() + ); + + return $this->languageGateway->loadVersionTranslations($versionIds); + } + private function getColumns(): Traversable { foreach (self::COLUMN_MAP as $columnAlias => $columnName) { diff --git a/src/lib/Persistence/Legacy/Filter/Gateway/Content/Mapper/DoctrineGatewayDataMapper.php b/src/lib/Persistence/Legacy/Filter/Gateway/Content/Mapper/DoctrineGatewayDataMapper.php index 2b300710dd..8ac6d91d95 100644 --- a/src/lib/Persistence/Legacy/Filter/Gateway/Content/Mapper/DoctrineGatewayDataMapper.php +++ b/src/lib/Persistence/Legacy/Filter/Gateway/Content/Mapper/DoctrineGatewayDataMapper.php @@ -17,7 +17,6 @@ use Ibexa\Contracts\Core\Persistence\Content\VersionInfo; use Ibexa\Core\FieldType\FieldTypeAliasResolverInterface; use Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\StorageFieldValue; use Ibexa\Core\Persistence\Legacy\Filter\Gateway\Content\GatewayDataMapper; @@ -29,9 +28,6 @@ final class DoctrineGatewayDataMapper implements GatewayDataMapper /** @var \Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry */ private $converterRegistry; - /** @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator */ - private $languageMaskGenerator; - /** @var \Ibexa\Contracts\Core\Persistence\Content\Language\Handler */ private $languageHandler; @@ -40,12 +36,10 @@ final class DoctrineGatewayDataMapper implements GatewayDataMapper public function __construct( LanguageHandler $languageHandler, - MaskGenerator $languageMaskGenerator, ContentTypeHandler $contentTypeHandler, ConverterRegistry $converterRegistry, private readonly FieldTypeAliasResolverInterface $fieldTypeAliasResolver ) { - $this->languageMaskGenerator = $languageMaskGenerator; $this->languageHandler = $languageHandler; $this->contentTypeHandler = $contentTypeHandler; $this->converterRegistry = $converterRegistry; @@ -105,9 +99,12 @@ private function mapVersionDataToPersistenceVersionInfo(array $row): Content\Ver $versionInfo->status = (int)$row['content_version_status']; $versionInfo->names = $row['content_version_names']; - // Map language codes - $versionInfo->languageCodes = $this->languageMaskGenerator->extractLanguageCodesFromMask( - (int)$row['content_version_language_mask'] + // Map language codes - "content_version_translations" is bulk-fetched once for the whole + // result page by DoctrineGateway::find(), not queried per row here, to avoid an N+1. + $languageIds = $row['content_version_translations']; + $versionInfo->languageCodes = array_map( + fn (int $languageId): string => $this->languageHandler->load($languageId)->languageCode, + $languageIds ); $versionInfo->initialLanguageCode = $this->languageHandler->load( (int)$row['content_version_initial_language_id'] @@ -194,7 +191,7 @@ public function mapContentMetadataToPersistenceContentInfo(array $row): ContentI $contentInfo->ownerId = (int)$row['content_owner_id']; $contentInfo->publicationDate = (int)$row['content_published']; $contentInfo->modificationDate = (int)$row['content_modified']; - $contentInfo->alwaysAvailable = 1 === ($row['content_language_mask'] & 1); + $contentInfo->alwaysAvailable = (bool)$row['content_always_available']; $contentInfo->mainLanguageCode = $mainLanguage->languageCode; $contentInfo->remoteId = $row['content_remote_id']; $contentInfo->mainLocationId = $row['content_main_location_id'] !== null diff --git a/src/lib/Persistence/Legacy/Filter/Gateway/Location/Doctrine/DoctrineGateway.php b/src/lib/Persistence/Legacy/Filter/Gateway/Location/Doctrine/DoctrineGateway.php index cfecb0e0df..580b6efe96 100644 --- a/src/lib/Persistence/Legacy/Filter/Gateway/Location/Doctrine/DoctrineGateway.php +++ b/src/lib/Persistence/Legacy/Filter/Gateway/Location/Doctrine/DoctrineGateway.php @@ -92,7 +92,7 @@ private function buildQuery(FilteringCriterion $criterion): FilteringQueryBuilde 'content.content_type_id AS content_type_id', 'content.current_version AS content_current_version', 'content.initial_language_id AS content_initial_language_id', - 'content.language_mask AS content_language_mask', + 'content.always_available AS content_always_available', 'content.modified AS content_modified', 'content.name AS content_name', 'content.owner_id AS content_owner_id', diff --git a/src/lib/Persistence/Legacy/SharedGateway/DatabasePlatform/AbstractGateway.php b/src/lib/Persistence/Legacy/SharedGateway/DatabasePlatform/AbstractGateway.php index d1dcf2b5b4..beaf81ae24 100644 --- a/src/lib/Persistence/Legacy/SharedGateway/DatabasePlatform/AbstractGateway.php +++ b/src/lib/Persistence/Legacy/SharedGateway/DatabasePlatform/AbstractGateway.php @@ -31,28 +31,6 @@ public function getColumnNextIntegerValue( return null; } - /** - * Return a language sub select query for setName. - * - * The query generates the proper language mask at the runtime of the INSERT/UPDATE query - * generated by setName. - * - * @see setName - */ - public function getSetNameLanguageMaskSubQuery(): string - { - return << 0 ) - THEN (:language_id | 1) - ELSE :language_id - END - FROM ibexa_content - WHERE id = :content_id) - SQL; - } - public function getLastInsertedId(string $sequenceName): int { return (int)$this->connection->lastInsertId(); diff --git a/src/lib/Persistence/Legacy/SharedGateway/DatabasePlatform/PostgresqlGateway.php b/src/lib/Persistence/Legacy/SharedGateway/DatabasePlatform/PostgresqlGateway.php index 847ffc2e32..4c6bbecef2 100644 --- a/src/lib/Persistence/Legacy/SharedGateway/DatabasePlatform/PostgresqlGateway.php +++ b/src/lib/Persistence/Legacy/SharedGateway/DatabasePlatform/PostgresqlGateway.php @@ -10,25 +10,4 @@ final class PostgresqlGateway extends AbstractGateway { - /** - * Return a language sub select query for setName. - * - * The query generates the proper language mask at the runtime of the INSERT/UPDATE query - * generated by setName. - * - * @see setName - */ - public function getSetNameLanguageMaskSubQuery(): string - { - return << 0 ) - THEN (cast(:language_id as BIGINT) | 1) - ELSE :language_id - END - FROM ibexa_content - WHERE id = :content_id) - SQL; - } } diff --git a/src/lib/Persistence/Legacy/SharedGateway/Gateway.php b/src/lib/Persistence/Legacy/SharedGateway/Gateway.php index c417bd2761..0b327f29dc 100644 --- a/src/lib/Persistence/Legacy/SharedGateway/Gateway.php +++ b/src/lib/Persistence/Legacy/SharedGateway/Gateway.php @@ -41,9 +41,4 @@ public function getColumnNextIntegerValue( * It returns integer as all the IDs in the Ibexa Legacy Storage are (big)integers */ public function getLastInsertedId(string $sequenceName): int; - - /** - * Return a language sub select query for setName. - */ - public function getSetNameLanguageMaskSubQuery(): string; } diff --git a/src/lib/Resources/settings/search_engines/legacy.yml b/src/lib/Resources/settings/search_engines/legacy.yml index a028246951..0d145f19fa 100644 --- a/src/lib/Resources/settings/search_engines/legacy.yml +++ b/src/lib/Resources/settings/search_engines/legacy.yml @@ -67,6 +67,7 @@ services: $locationMapper: '@Ibexa\Core\Persistence\Legacy\Content\Location\Mapper' $languageHandler: '@Ibexa\Contracts\Core\Persistence\Content\Language\Handler' $mapper: '@ibexa.search.legacy.fulltext_mapper' + $languageGateway: '@ibexa.persistence.legacy.language.gateway' tags: - {name: ibexa.search.engine, alias: legacy} lazy: true diff --git a/src/lib/Resources/settings/search_engines/legacy/criterion_handlers_common.yml b/src/lib/Resources/settings/search_engines/legacy/criterion_handlers_common.yml index 0ac5cfd9ae..24c799a948 100644 --- a/src/lib/Resources/settings/search_engines/legacy/criterion_handlers_common.yml +++ b/src/lib/Resources/settings/search_engines/legacy/criterion_handlers_common.yml @@ -44,12 +44,18 @@ services: $connection: '@ibexa.persistence.connection' $joinedTablesTracker: '@Ibexa\Core\Persistence\Doctrine\JoinedTablesTracker' + Ibexa\Core\Search\Legacy\Content\Common\Gateway\LanguagePriorityConditionBuilder: + arguments: + $connection: '@ibexa.persistence.connection' + $languageHandler: '@Ibexa\Contracts\Core\Persistence\Content\Language\Handler' + Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler\FieldBase: parent: Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler abstract: true arguments: $contentTypeHandler: '@Ibexa\Contracts\Core\Persistence\Content\Type\Handler' $languageHandler: '@Ibexa\Contracts\Core\Persistence\Content\Language\Handler' + $languagePriorityConditionBuilder: '@Ibexa\Core\Search\Legacy\Content\Common\Gateway\LanguagePriorityConditionBuilder' Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler\FieldValue\Handler: abstract: true @@ -142,7 +148,7 @@ services: parent: Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler arguments: $processor: '@Ibexa\Core\Persistence\TransformationProcessor\PreprocessedBased' - $languageMaskGenerator: '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + $languageHandler: '@Ibexa\Contracts\Core\Persistence\Content\Language\Handler' $configuration: '%ibexa.search.legacy.criterion_handler.full_text.configuration%' tags: - {name: ibexa.search.legacy.gateway.criterion_handler.content} @@ -158,7 +164,7 @@ services: class: Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler\LanguageCode parent: Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler arguments: - $maskGenerator: '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + $languageHandler: '@ibexa.spi.persistence.legacy.language.handler' tags: - {name: ibexa.search.legacy.gateway.criterion_handler.content} - {name: ibexa.search.legacy.gateway.criterion_handler.location} diff --git a/src/lib/Resources/settings/search_engines/legacy/indexer.yml b/src/lib/Resources/settings/search_engines/legacy/indexer.yml index 1cf7757994..c94fc0a8de 100644 --- a/src/lib/Resources/settings/search_engines/legacy/indexer.yml +++ b/src/lib/Resources/settings/search_engines/legacy/indexer.yml @@ -5,7 +5,7 @@ services: $typeHandler: '@Ibexa\Contracts\Core\Persistence\Content\Type\Handler' $transformationProcessor: '@Ibexa\Core\Persistence\TransformationProcessor\PreprocessedBased' $searchIndex: '@Ibexa\Core\Search\Legacy\Content\WordIndexer\Repository\SearchIndex' - $languageMaskGenerator: '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + $languageHandler: '@Ibexa\Contracts\Core\Persistence\Content\Language\Handler' $fullTextSearchConfiguration: '%ibexa.search.legacy.criterion_handler.full_text.configuration%' Ibexa\Core\Search\Legacy\Content\WordIndexer\Repository\SearchIndex: diff --git a/src/lib/Resources/settings/search_engines/legacy/sort_clause_handlers_common.yml b/src/lib/Resources/settings/search_engines/legacy/sort_clause_handlers_common.yml index 9be7888533..55548e1307 100644 --- a/src/lib/Resources/settings/search_engines/legacy/sort_clause_handlers_common.yml +++ b/src/lib/Resources/settings/search_engines/legacy/sort_clause_handlers_common.yml @@ -38,6 +38,7 @@ services: arguments: $languageHandler: '@Ibexa\Contracts\Core\Persistence\Content\Language\Handler' $contentTypeHandler: '@Ibexa\Contracts\Core\Persistence\Content\Type\Handler' + $languagePriorityConditionBuilder: '@Ibexa\Core\Search\Legacy\Content\Common\Gateway\LanguagePriorityConditionBuilder' tags: - {name: ibexa.search.legacy.gateway.sort_clause_handler.content} - {name: ibexa.search.legacy.gateway.sort_clause_handler.location} diff --git a/src/lib/Resources/settings/storage_engines/legacy/content.yml b/src/lib/Resources/settings/storage_engines/legacy/content.yml index cff5ea998f..2b53c68e65 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/content.yml +++ b/src/lib/Resources/settings/storage_engines/legacy/content.yml @@ -12,6 +12,7 @@ services: - '@ibexa.spi.persistence.legacy.content_type.handler' - '@Symfony\Contracts\EventDispatcher\EventDispatcherInterface' - '@Ibexa\Core\FieldType\FieldTypeAliasResolverInterface' + - '@ibexa.persistence.legacy.language.gateway' Ibexa\Core\Persistence\Legacy\Content\Mapper\ResolveVirtualFieldSubscriber: arguments: @@ -28,7 +29,6 @@ services: - '@Ibexa\Core\Persistence\Legacy\SharedGateway\Gateway' - '@Ibexa\Core\Persistence\Legacy\Content\Gateway\DoctrineDatabase\QueryBuilder' - '@ibexa.spi.persistence.legacy.language.handler' - - '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' Ibexa\Core\Persistence\Legacy\Content\Gateway\ExceptionConversion: class: Ibexa\Core\Persistence\Legacy\Content\Gateway\ExceptionConversion diff --git a/src/lib/Resources/settings/storage_engines/legacy/content_type.yml b/src/lib/Resources/settings/storage_engines/legacy/content_type.yml index 1fe117e080..ede412e529 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/content_type.yml +++ b/src/lib/Resources/settings/storage_engines/legacy/content_type.yml @@ -18,7 +18,7 @@ services: class: Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\DoctrineDatabase arguments: $sharedGateway: '@Ibexa\Core\Persistence\Legacy\SharedGateway\Gateway' - $languageMaskGenerator: '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + $languageHandler: '@ibexa.spi.persistence.legacy.language.handler' $criterionVisitor: '@Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\CriterionVisitor\CriterionVisitor' Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\ExceptionConversion: @@ -34,7 +34,8 @@ services: class: Ibexa\Core\Persistence\Legacy\Content\Type\Mapper arguments: - '@Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry' - - '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + - '@ibexa.persistence.legacy.content_type.gateway' + - '@ibexa.spi.persistence.legacy.language.handler' - '@Ibexa\Core\Persistence\Legacy\Content\Type\StorageDispatcherInterface' - '@Ibexa\Core\FieldType\FieldTypeAliasResolverInterface' diff --git a/src/lib/Resources/settings/storage_engines/legacy/filter.yaml b/src/lib/Resources/settings/storage_engines/legacy/filter.yaml index 5b23edfb25..d90f1376a5 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/filter.yaml +++ b/src/lib/Resources/settings/storage_engines/legacy/filter.yaml @@ -30,7 +30,6 @@ services: Ibexa\Core\Persistence\Legacy\Filter\Gateway\Content\Mapper\DoctrineGatewayDataMapper: arguments: $languageHandler: '@Ibexa\Contracts\Core\Persistence\Content\Language\Handler' - $languageMaskGenerator: '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' $contentTypeHandler: '@Ibexa\Contracts\Core\Persistence\Content\Type\Handler' $converterRegistry: '@Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry' @@ -45,6 +44,7 @@ services: Ibexa\Core\Persistence\Legacy\Filter\Gateway\Content\Doctrine\DoctrineGateway: arguments: $connection: '@ibexa.persistence.connection' + $languageGateway: '@ibexa.persistence.legacy.language.gateway' Ibexa\Core\Persistence\Legacy\Filter\Handler\ContentFilteringHandler: arguments: diff --git a/src/lib/Resources/settings/storage_engines/legacy/language.yml b/src/lib/Resources/settings/storage_engines/legacy/language.yml index 711cafdbc4..a97b49b8de 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/language.yml +++ b/src/lib/Resources/settings/storage_engines/legacy/language.yml @@ -32,7 +32,3 @@ services: ibexa.spi.persistence.legacy.language.handler: alias: Ibexa\Core\Persistence\Legacy\Content\Language\CachingHandler - - Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator: - class: Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator - arguments: ['@ibexa.spi.persistence.legacy.language.handler'] diff --git a/src/lib/Resources/settings/storage_engines/legacy/location.yml b/src/lib/Resources/settings/storage_engines/legacy/location.yml index d6e86033d5..04aabbf418 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/location.yml +++ b/src/lib/Resources/settings/storage_engines/legacy/location.yml @@ -3,7 +3,7 @@ services: class: Ibexa\Core\Persistence\Legacy\Content\Location\Gateway\DoctrineDatabase arguments: - '@ibexa.api.storage_engine.legacy.connection' - - '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + - '@ibexa.spi.persistence.legacy.language.handler' - '@ibexa.core.trash.search.legacy.gateway.criteria_converter' - '@ibexa.core.trash.search.legacy.gateway.sort_clause_converter' - '@Ibexa\Contracts\Core\Persistence\Filter\Query\CountQueryBuilder' diff --git a/src/lib/Resources/settings/storage_engines/legacy/object_state.yml b/src/lib/Resources/settings/storage_engines/legacy/object_state.yml index 48de1bfe90..850d1d2da8 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/object_state.yml +++ b/src/lib/Resources/settings/storage_engines/legacy/object_state.yml @@ -3,7 +3,7 @@ services: class: Ibexa\Core\Persistence\Legacy\Content\ObjectState\Gateway\DoctrineDatabase arguments: - '@ibexa.api.storage_engine.legacy.connection' - - '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + - '@ibexa.spi.persistence.legacy.language.handler' Ibexa\Core\Persistence\Legacy\Content\ObjectState\Gateway\ExceptionConversion: class: Ibexa\Core\Persistence\Legacy\Content\ObjectState\Gateway\ExceptionConversion diff --git a/src/lib/Resources/settings/storage_engines/legacy/url_alias.yml b/src/lib/Resources/settings/storage_engines/legacy/url_alias.yml index 1e4e4f7571..652bb28252 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/url_alias.yml +++ b/src/lib/Resources/settings/storage_engines/legacy/url_alias.yml @@ -3,7 +3,7 @@ services: class: Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway\DoctrineDatabase arguments: - '@ibexa.api.storage_engine.legacy.connection' - - '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + - '@ibexa.spi.persistence.legacy.language.handler' Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway\ExceptionConversion: class: Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway\ExceptionConversion @@ -17,7 +17,8 @@ services: Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Mapper: class: Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Mapper arguments: - - '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + - '@ibexa.persistence.legacy.url_alias.gateway' + - '@ibexa.spi.persistence.legacy.language.handler' Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Handler: class: Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Handler @@ -28,6 +29,6 @@ services: - '@ibexa.spi.persistence.legacy.language.handler' - '@Ibexa\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter' - '@ibexa.persistence.legacy.content.gateway' - - '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' - '@Ibexa\Core\Persistence\Legacy\TransactionHandler' + - '@ibexa.persistence.legacy.language.gateway' lazy: true diff --git a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/Field.php b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/Field.php index ad288c4870..82919384a6 100644 --- a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/Field.php +++ b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/Field.php @@ -21,6 +21,7 @@ use Ibexa\Core\Persistence\TransformationProcessor; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriteriaConverter; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler\FieldValue\Converter as FieldValueConverter; +use Ibexa\Core\Search\Legacy\Content\Common\Gateway\LanguagePriorityConditionBuilder; /** * Field criterion handler. @@ -55,9 +56,16 @@ public function __construct( Registry $fieldConverterRegistry, FieldValueConverter $fieldValueConverter, TransformationProcessor $transformationProcessor, - JoinedTablesTracker $joinedTablesTracker + JoinedTablesTracker $joinedTablesTracker, + LanguagePriorityConditionBuilder $languagePriorityConditionBuilder ) { - parent::__construct($connection, $contentTypeHandler, $languageHandler, $joinedTablesTracker); + parent::__construct( + $connection, + $contentTypeHandler, + $languageHandler, + $joinedTablesTracker, + $languagePriorityConditionBuilder + ); $this->fieldConverterRegistry = $fieldConverterRegistry; $this->fieldValueConverter = $fieldValueConverter; diff --git a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FieldBase.php b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FieldBase.php index 8d4ff633de..a7904f65e2 100644 --- a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FieldBase.php +++ b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FieldBase.php @@ -8,13 +8,13 @@ namespace Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\QueryBuilder; use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Persistence\Content\Type\Handler as ContentTypeHandler; use Ibexa\Contracts\Core\Repository\Exceptions\NotImplementedException; use Ibexa\Core\Persistence\Doctrine\JoinedTablesTracker; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler; +use Ibexa\Core\Search\Legacy\Content\Common\Gateway\LanguagePriorityConditionBuilder; /** * Base criterion handler for field criteria. @@ -35,6 +35,8 @@ abstract class FieldBase extends CriterionHandler */ protected $languageHandler; + private LanguagePriorityConditionBuilder $languagePriorityConditionBuilder; + /** * @throws \Doctrine\DBAL\Exception */ @@ -42,12 +44,14 @@ public function __construct( Connection $connection, ContentTypeHandler $contentTypeHandler, LanguageHandler $languageHandler, - JoinedTablesTracker $joinedTablesTracker + JoinedTablesTracker $joinedTablesTracker, + LanguagePriorityConditionBuilder $languagePriorityConditionBuilder ) { parent::__construct($connection, $joinedTablesTracker); $this->contentTypeHandler = $contentTypeHandler; $this->languageHandler = $languageHandler; + $this->languagePriorityConditionBuilder = $languagePriorityConditionBuilder; } /** @@ -59,85 +63,10 @@ public function __construct( */ protected function getFieldCondition(QueryBuilder $query, array $languageSettings): string { - // 1. Use main language(s) by default - $expr = $query->expr(); - if (empty($languageSettings['languages'])) { - return $expr->gt( - $this->dbPlatform->getBitAndComparisonExpression( - 'c.initial_language_id', - 'f_def.language_id' - ), - $query->createNamedParameter(0, ParameterType::INTEGER) - ); - } - - // 2. Otherwise use prioritized languages - $leftSide = $this->dbPlatform->getBitAndComparisonExpression( - sprintf( - 'c.language_mask - %s', - $this->dbPlatform->getBitAndComparisonExpression( - 'c.language_mask', - 'f_def.language_id' - ) - ), - $query->createNamedParameter(1, ParameterType::INTEGER) - ); - $rightSide = $this->dbPlatform->getBitAndComparisonExpression( - 'f_def.language_id', - $query->createNamedParameter(1, ParameterType::INTEGER) - ); - - for ( - $index = count($languageSettings['languages']) - 1, - $multiplier = 2; - $index >= 0; - $index--, $multiplier *= 2 - ) { - $languageId = $this->languageHandler - ->loadByLanguageCode($languageSettings['languages'][$index])->id; - - $addToLeftSide = $this->dbPlatform->getBitAndComparisonExpression( - sprintf( - 'c.language_mask - %s', - $this->dbPlatform->getBitAndComparisonExpression( - 'c.language_mask', - 'f_def.language_id' - ) - ), - $languageId - ); - $addToRightSide = $this->dbPlatform->getBitAndComparisonExpression( - 'f_def.language_id', - $languageId - ); - - if ($multiplier > $languageId) { - $factor = $multiplier / $languageId; - for ($shift = 0; $factor > 1; $factor = $factor / 2, $shift++); - $factorTerm = ' << ' . $shift; - $addToLeftSide .= $factorTerm; - $addToRightSide .= $factorTerm; - } elseif ($multiplier < $languageId) { - $factor = $languageId / $multiplier; - for ($shift = 0; $factor > 1; $factor = $factor / 2, $shift++); - $factorTerm = ' >> ' . $shift; - $addToLeftSide .= $factorTerm; - $addToRightSide .= $factorTerm; - } - - $leftSide = "$leftSide + ($addToLeftSide)"; - $rightSide = "$rightSide + ($addToRightSide)"; - } - - return $expr->and( - $expr->gt( - $this->dbPlatform->getBitAndComparisonExpression( - 'c.language_mask', - 'f_def.language_id' - ), - $query->createNamedParameter(0, ParameterType::INTEGER) - ), - $expr->lt($leftSide, $rightSide) + return $this->languagePriorityConditionBuilder->buildCondition( + $query, + $languageSettings, + 'f_def.language_id' ); } diff --git a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FieldEmpty.php b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FieldEmpty.php index 5c0c5aa28b..e72308da07 100644 --- a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FieldEmpty.php +++ b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FieldEmpty.php @@ -21,6 +21,7 @@ use Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry as Registry; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriteriaConverter; +use Ibexa\Core\Search\Legacy\Content\Common\Gateway\LanguagePriorityConditionBuilder; /** * Field criterion handler. @@ -45,9 +46,16 @@ public function __construct( LanguageHandler $languageHandler, Registry $fieldConverterRegistry, FieldTypeService $fieldTypeService, - JoinedTablesTracker $joinedTablesTracker + JoinedTablesTracker $joinedTablesTracker, + LanguagePriorityConditionBuilder $languagePriorityConditionBuilder ) { - parent::__construct($connection, $contentTypeHandler, $languageHandler, $joinedTablesTracker); + parent::__construct( + $connection, + $contentTypeHandler, + $languageHandler, + $joinedTablesTracker, + $languagePriorityConditionBuilder + ); $this->fieldConverterRegistry = $fieldConverterRegistry; $this->fieldTypeService = $fieldTypeService; diff --git a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FullText.php b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FullText.php index 5bae939123..f013755271 100644 --- a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FullText.php +++ b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/FullText.php @@ -7,18 +7,16 @@ namespace Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; use Doctrine\DBAL\ParameterType; -use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Query\QueryBuilder; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion; use Ibexa\Contracts\Core\Repository\Values\Content\Query\CriterionInterface; -use Ibexa\Core\Base\Exceptions\DatabaseException; use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\Persistence\Doctrine\JoinedTablesTracker; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Persistence\TransformationProcessor; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriteriaConverter; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler; @@ -79,7 +77,7 @@ class FullText extends CriterionHandler public function __construct( Connection $connection, protected TransformationProcessor $processor, - private readonly MaskGenerator $languageMaskGenerator, + private readonly LanguageHandler $languageHandler, JoinedTablesTracker $joinedTablesTracker, array $configuration = [] ) { @@ -216,20 +214,27 @@ public function handle( ); if (!empty($languageSettings['languages'])) { - $languageMask = $this->languageMaskGenerator->generateLanguageMaskFromLanguageCodes( - $languageSettings['languages'], - $languageSettings['useAlwaysAvailable'] ?? true + $languageIds = array_map( + fn (string $languageCode): int => $this->languageHandler->loadByLanguageCode($languageCode)->id, + $languageSettings['languages'] ); - $subSelect->andWhere( - $expr->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'ibexa_search_object_word_link.language_mask', - $queryBuilder->createNamedParameter($languageMask, ParameterType::INTEGER) - ), - $queryBuilder->createNamedParameter(0, ParameterType::INTEGER) - ) + $languageCondition = $expr->in( + 'ibexa_search_object_word_link.language_id', + $queryBuilder->createNamedParameter($languageIds, ArrayParameterType::INTEGER) ); + + if ($languageSettings['useAlwaysAvailable'] ?? true) { + $languageCondition = $expr->or( + $languageCondition, + $expr->eq( + 'ibexa_search_object_word_link.is_main_and_always_available', + $queryBuilder->createNamedParameter(true, ParameterType::BOOLEAN) + ) + ); + } + + $subSelect->andWhere($languageCondition); } return $expr->in( @@ -269,13 +274,4 @@ protected function getStopWordThresholdValue(): int // Calculate the int stopWordThresholdValue based on count (first column) * factor return $this->stopWordThresholdValue = (int)($count * $this->configuration['stopWordThresholdFactor']); } - - private function getDatabasePlatform(): AbstractPlatform - { - try { - return $this->connection->getDatabasePlatform(); - } catch (Exception $e) { - throw DatabaseException::wrap($e); - } - } } diff --git a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/LanguageCode.php b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/LanguageCode.php index 2f4717c8cc..7ae3d410ad 100644 --- a/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/LanguageCode.php +++ b/src/lib/Search/Legacy/Content/Common/Gateway/CriterionHandler/LanguageCode.php @@ -7,12 +7,15 @@ namespace Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\QueryBuilder; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion; use Ibexa\Contracts\Core\Repository\Values\Content\Query\CriterionInterface; +use Ibexa\Core\Base\Exceptions\NotFoundException; use Ibexa\Core\Persistence\Doctrine\JoinedTablesTracker; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriteriaConverter; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriterionHandler; @@ -21,14 +24,14 @@ */ class LanguageCode extends CriterionHandler { - /** @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator */ - private $maskGenerator; + /** @var \Ibexa\Contracts\Core\Persistence\Content\Language\Handler */ + private $languageHandler; - public function __construct(Connection $connection, MaskGenerator $maskGenerator, JoinedTablesTracker $joinedTablesTracker) + public function __construct(Connection $connection, LanguageHandler $languageHandler, JoinedTablesTracker $joinedTablesTracker) { parent::__construct($connection, $joinedTablesTracker); - $this->maskGenerator = $maskGenerator; + $this->languageHandler = $languageHandler; } public function accept(CriterionInterface $criterion): bool @@ -46,15 +49,37 @@ public function handle( array $languageSettings ) { /* @var $criterion \Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion\LanguageCode */ - return $queryBuilder->expr()->gt( - $this->dbPlatform->getBitAndComparisonExpression( - 'c.language_mask', - $this->maskGenerator->generateLanguageMaskFromLanguageCodes( - $criterion->value, - $criterion->matchAlwaysAvailable + $expr = $queryBuilder->expr(); + $languageCodes = array_map('strval', (array)$criterion->value); + $languages = iterator_to_array($this->languageHandler->loadListByLanguageCodes($languageCodes)); + if ($missing = array_diff($languageCodes, array_keys($languages))) { + throw new NotFoundException('Language', implode(', ', $missing)); + } + $languageIds = array_map(static fn ($language) => $language->id, array_values($languages)); + + $translationSubQuery = $this->connection->createQueryBuilder(); + $translationSubQuery + ->select('1') + ->from('ibexa_content_translation', 'ct') + ->where( + $translationSubQuery->expr()->and( + 'ct.content_id = c.id', + $translationSubQuery->expr()->in( + 'ct.language_id', + $queryBuilder->createNamedParameter($languageIds, ArrayParameterType::INTEGER) + ) ) - ), - 0 - ); + ); + + $condition = sprintf('EXISTS (%s)', $translationSubQuery->getSQL()); + + if ($criterion->matchAlwaysAvailable) { + return $expr->or( + $condition, + $expr->eq('c.always_available', $queryBuilder->createNamedParameter(true, ParameterType::BOOLEAN)) + ); + } + + return $condition; } } diff --git a/src/lib/Search/Legacy/Content/Common/Gateway/LanguagePriorityConditionBuilder.php b/src/lib/Search/Legacy/Content/Common/Gateway/LanguagePriorityConditionBuilder.php new file mode 100644 index 0000000000..bf2dd5c4c4 --- /dev/null +++ b/src/lib/Search/Legacy/Content/Common/Gateway/LanguagePriorityConditionBuilder.php @@ -0,0 +1,161 @@ +matchesLanguageId($query, $languageIdColumn, $mainLanguageIdColumn, $contentIdColumn); + } + + $languageIds = array_map( + fn (string $languageCode): int => $this->languageHandler->loadByLanguageCode($languageCode)->id, + $languageSettings['languages'] + ); + + $priorityCase = 'CASE ct.language_id'; + foreach ($languageIds as $priority => $languageId) { + $priorityCase .= sprintf( + ' WHEN %s THEN %d', + $query->createNamedParameter($languageId, ParameterType::INTEGER), + $priority + ); + } + $priorityCase .= ' END'; + + $subQuery = $this->connection->createQueryBuilder(); + $subQuery + ->select('ct.language_id') + ->from('ibexa_content_translation', 'ct') + ->where( + $subQuery->expr()->and( + sprintf('ct.content_id = %s', $contentIdColumn), + $subQuery->expr()->in( + 'ct.language_id', + $query->createNamedParameter($languageIds, ArrayParameterType::INTEGER) + ) + ) + ) + ->orderBy($priorityCase) + ->setMaxResults(1); + + $priorityMatch = $this->matchesLanguageId($query, $languageIdColumn, sprintf('(%s)', $subQuery->getSQL()), $contentIdColumn); + + if (!($languageSettings['useAlwaysAvailable'] ?? true)) { + return $priorityMatch; + } + + // Content has none of the requested priority languages: an always-available Content falls + // back to matching its main language, same as the empty-$languageSettings branch above. + $hasRequestedLanguageSubQuery = $this->connection->createQueryBuilder(); + $hasRequestedLanguageSubQuery + ->select('1') + ->from('ibexa_content_translation', 'ct') + ->where( + $hasRequestedLanguageSubQuery->expr()->and( + sprintf('ct.content_id = %s', $contentIdColumn), + $hasRequestedLanguageSubQuery->expr()->in( + 'ct.language_id', + $query->createNamedParameter($languageIds, ArrayParameterType::INTEGER) + ) + ) + ); + + $alwaysAvailableFallback = $query->expr()->and( + sprintf('NOT EXISTS (%s)', $hasRequestedLanguageSubQuery->getSQL()), + $alwaysAvailableColumn, + $this->matchesLanguageId($query, $languageIdColumn, $mainLanguageIdColumn, $contentIdColumn) + ); + + return (string)$query->expr()->or($priorityMatch, $alwaysAvailableFallback); + } + + /** + * Matches $languageIdColumn (e.g. ibexa_content_field.language_id) against $targetIdExpression + * (a clean id, or an expression producing one, from ibexa_language/*_translation). + * + * $languageIdColumn may still carry the legacy "always available" bit 0 folded into it, from + * rows written before always_available became a plain column - both on real installs upgrading + * from that scheme and in long-lived test fixtures captured from it. Tolerate a "+1" tainted + * value, but only when: + * - the target id is even (only even ids are old-style; real ids were always powers of two, so + * an odd "+1" could only ever be that taint on an old install, never a genuinely different + * language on its own), and + * - $languageIdColumn's raw value isn't itself one of Content's actual translations (via + * ibexa_content_translation) - otherwise a real, separate, newly-allocated odd-id language + * that happens to equal target+1 would be wrongly folded into target instead of matching + * itself. + */ + private function matchesLanguageId( + QueryBuilder $query, + string $languageIdColumn, + string $targetIdExpression, + string $contentIdColumn + ): string { + $isGenuineTranslationSubQuery = $this->connection->createQueryBuilder(); + $isGenuineTranslationSubQuery + ->select('1') + ->from('ibexa_content_translation', 'ct_genuine') + ->where( + $isGenuineTranslationSubQuery->expr()->and( + sprintf('ct_genuine.content_id = %s', $contentIdColumn), + sprintf('ct_genuine.language_id = %s', $languageIdColumn) + ) + ); + + return (string) $query->expr()->or( + $query->expr()->eq($languageIdColumn, $targetIdExpression), + $query->expr()->and( + sprintf('(%s) %% 2 = 0', $targetIdExpression), + $query->expr()->eq($languageIdColumn, sprintf('((%s) + 1)', $targetIdExpression)), + sprintf('NOT EXISTS (%s)', $isGenuineTranslationSubQuery->getSQL()) + ) + ); + } +} diff --git a/src/lib/Search/Legacy/Content/Common/Gateway/SortClauseHandler/Field.php b/src/lib/Search/Legacy/Content/Common/Gateway/SortClauseHandler/Field.php index 0a1d4cfada..44b1f4f4dc 100644 --- a/src/lib/Search/Legacy/Content/Common/Gateway/SortClauseHandler/Field.php +++ b/src/lib/Search/Legacy/Content/Common/Gateway/SortClauseHandler/Field.php @@ -15,6 +15,7 @@ use Ibexa\Contracts\Core\Repository\Values\Content\Query\SortClause; use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\Persistence\Legacy\Content\Gateway; +use Ibexa\Core\Search\Legacy\Content\Common\Gateway\LanguagePriorityConditionBuilder; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\SortClauseHandler; /** @@ -36,15 +37,19 @@ class Field extends SortClauseHandler */ protected $contentTypeHandler; + private LanguagePriorityConditionBuilder $languagePriorityConditionBuilder; + public function __construct( Connection $connection, LanguageHandler $languageHandler, - ContentTypeHandler $contentTypeHandler + ContentTypeHandler $contentTypeHandler, + LanguagePriorityConditionBuilder $languagePriorityConditionBuilder ) { parent::__construct($connection); $this->languageHandler = $languageHandler; $this->contentTypeHandler = $contentTypeHandler; + $this->languagePriorityConditionBuilder = $languagePriorityConditionBuilder; } /** @@ -160,81 +165,10 @@ protected function getFieldCondition( array $languageSettings, $fieldTableName ) { - // 1. Use main language(s) by default - if (empty($languageSettings['languages'])) { - return $query->expr()->gt( - $this->dbPlatform->getBitAndComparisonExpression( - 'c.initial_language_id', - $fieldTableName . '.language_id' - ), - $query->createNamedParameter(0, ParameterType::INTEGER) - ); - } - - // 2. Otherwise use prioritized languages - $leftSide = $this->dbPlatform->getBitAndComparisonExpression( - sprintf( - 'c.language_mask - %s', - $this->dbPlatform->getBitAndComparisonExpression( - 'c.language_mask', - $fieldTableName . '.language_id' - ) - ), - $query->createNamedParameter(1, ParameterType::INTEGER) - ); - $rightSide = $this->dbPlatform->getBitAndComparisonExpression( - $fieldTableName . '.language_id', - $query->createNamedParameter(1, ParameterType::INTEGER) - ); - - for ($index = count( - $languageSettings['languages'] - ) - 1, $multiplier = 2; $index >= 0; $index--, $multiplier *= 2) { - $languageId = $this->languageHandler - ->loadByLanguageCode($languageSettings['languages'][$index])->id; - - $addToLeftSide = $this->dbPlatform->getBitAndComparisonExpression( - sprintf( - 'c.language_mask - %s', - $this->dbPlatform->getBitAndComparisonExpression( - 'c.language_mask', - $fieldTableName . '.language_id' - ) - ), - $query->createNamedParameter($languageId, ParameterType::INTEGER) - ); - $addToRightSide = $this->dbPlatform->getBitAndComparisonExpression( - $fieldTableName . '.language_id', - $query->createNamedParameter($languageId, ParameterType::INTEGER) - ); - - if ($multiplier > $languageId) { - $factor = $multiplier / $languageId; - for ($shift = 0; $factor > 1; $factor = $factor / 2, $shift++); - $factorTerm = ' << ' . $shift; - $addToLeftSide .= $factorTerm; - $addToRightSide .= $factorTerm; - } elseif ($multiplier < $languageId) { - $factor = $languageId / $multiplier; - for ($shift = 0; $factor > 1; $factor = $factor / 2, $shift++); - $factorTerm = ' >> ' . $shift; - $addToLeftSide .= $factorTerm; - $addToRightSide .= $factorTerm; - } - - $leftSide = "$leftSide + ($addToLeftSide)"; - $rightSide = "$rightSide + ($addToRightSide)"; - } - - return $query->expr()->and( - $query->expr()->gt( - $this->dbPlatform->getBitAndComparisonExpression( - 'c.language_mask', - $fieldTableName . '.language_id' - ), - $query->createNamedParameter(0, ParameterType::INTEGER) - ), - $query->expr()->lt($leftSide, $rightSide) + return $this->languagePriorityConditionBuilder->buildCondition( + $query, + $languageSettings, + $fieldTableName . '.language_id' ); } } diff --git a/src/lib/Search/Legacy/Content/Gateway/DoctrineDatabase.php b/src/lib/Search/Legacy/Content/Gateway/DoctrineDatabase.php index 4d4e14554e..093440616c 100644 --- a/src/lib/Search/Legacy/Content/Gateway/DoctrineDatabase.php +++ b/src/lib/Search/Legacy/Content/Gateway/DoctrineDatabase.php @@ -7,8 +7,8 @@ namespace Ibexa\Core\Search\Legacy\Content\Gateway; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\QueryBuilder; use Ibexa\Contracts\Core\Persistence\Content\ContentInfo; use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; @@ -29,9 +29,6 @@ final class DoctrineDatabase extends Gateway /** @var \Doctrine\DBAL\Connection */ private $connection; - /** @var \Doctrine\DBAL\Platforms\AbstractPlatform */ - private $dbPlatform; - /** * Criteria converter. * @@ -53,9 +50,6 @@ final class DoctrineDatabase extends Gateway */ private $languageHandler; - /** - * @throws \Doctrine\DBAL\Exception - */ public function __construct( Connection $connection, CriteriaConverter $criteriaConverter, @@ -63,7 +57,6 @@ public function __construct( LanguageHandler $languageHandler ) { $this->connection = $connection; - $this->dbPlatform = $connection->getDatabasePlatform(); $this->criteriaConverter = $criteriaConverter; $this->sortClauseConverter = $sortClauseConverter; $this->languageHandler = $languageHandler; @@ -95,29 +88,6 @@ public function find( ]; } - /** - * Generates a language mask from the given $languageSettings. - * - * @param array $languageSettings - * - * @return int - * - * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException - */ - private function getLanguageMask(array $languageSettings): int - { - $mask = 0; - if ($languageSettings['useAlwaysAvailable']) { - $mask |= 1; - } - - foreach ($languageSettings['languages'] as $languageCode) { - $mask |= $this->languageHandler->loadByLanguageCode($languageCode)->id; - } - - return $mask; - } - /** * @param array $languageFilter * @@ -145,20 +115,32 @@ private function getQueryCondition( // If not main-languages query if (!empty($languageFilter['languages'])) { - $condition = $expr->and( - $condition, - $expr->gt( - $this->dbPlatform->getBitAndComparisonExpression( - 'c.language_mask', - $query->createNamedParameter( - $this->getLanguageMask($languageFilter), - ParameterType::INTEGER, - ':language_mask' - ) - ), - $query->createNamedParameter(0, ParameterType::INTEGER, ':zero') - ) + $languageIds = array_map( + fn (string $languageCode): int => $this->languageHandler->loadByLanguageCode($languageCode)->id, + $languageFilter['languages'] ); + + $translationExistsSubQuery = $this->connection->createQueryBuilder(); + $translationExistsSubQuery + ->select('1') + ->from('ibexa_content_translation', 'ct') + ->where( + $translationExistsSubQuery->expr()->and( + 'ct.content_id = c.id', + $translationExistsSubQuery->expr()->in( + 'ct.language_id', + $query->createNamedParameter($languageIds, ArrayParameterType::INTEGER) + ) + ) + ); + + $languageCondition = sprintf('EXISTS (%s)', $translationExistsSubQuery->getSQL()); + + if (!empty($languageFilter['useAlwaysAvailable'])) { + $languageCondition = $expr->or($languageCondition, 'c.always_available'); + } + + $condition = $expr->and($condition, $languageCondition); } return $condition; diff --git a/src/lib/Search/Legacy/Content/Handler.php b/src/lib/Search/Legacy/Content/Handler.php index 9c5a33c568..f77d764f9e 100644 --- a/src/lib/Search/Legacy/Content/Handler.php +++ b/src/lib/Search/Legacy/Content/Handler.php @@ -20,6 +20,7 @@ use Ibexa\Contracts\Core\Search\VersatileHandler as SearchHandlerInterface; use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\Base\Exceptions\NotFoundException; +use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway as LanguageGateway; use Ibexa\Core\Persistence\Legacy\Content\Location\Mapper as LocationMapper; use Ibexa\Core\Persistence\Legacy\Content\Mapper as ContentMapper; use Ibexa\Core\Search\Legacy\Content\Location\Gateway as LocationGateway; @@ -100,6 +101,8 @@ class Handler implements SearchHandlerInterface */ protected $mapper; + private LanguageGateway $languageGateway; + public function __construct( Gateway $gateway, LocationGateway $locationGateway, @@ -107,7 +110,8 @@ public function __construct( ContentMapper $contentMapper, LocationMapper $locationMapper, LanguageHandler $languageHandler, - FullTextMapper $mapper + FullTextMapper $mapper, + LanguageGateway $languageGateway ) { $this->gateway = $gateway; $this->locationGateway = $locationGateway; @@ -116,6 +120,7 @@ public function __construct( $this->locationMapper = $locationMapper; $this->languageHandler = $languageHandler; $this->mapper = $mapper; + $this->languageGateway = $languageGateway; } public function findContent(Query $query, array $languageFilter = []): SearchResult @@ -149,14 +154,19 @@ public function findContent(Query $query, array $languageFilter = []): SearchRes 'main_tree_' ); + $contentTranslations = $this->languageGateway->loadContentTranslations( + array_map(static fn (Content\ContentInfo $contentInfo): int => $contentInfo->id, $contentInfoList) + ); + foreach ($contentInfoList as $index => $contentInfo) { /** @phpstan-var \Ibexa\Contracts\Core\Repository\Values\Content\Search\SearchHit<\Ibexa\Contracts\Core\Persistence\Content\ContentInfo> $searchHit */ $searchHit = new SearchHit(); $searchHit->valueObject = $contentInfo; $searchHit->matchedTranslation = $this->extractMatchedLanguage( - $data['rows'][$index]['language_mask'], + $contentTranslations[$contentInfo->id] ?? [], $data['rows'][$index]['initial_language_id'], - $languageFilter + $languageFilter, + (bool)$data['rows'][$index]['always_available'] ); $result->searchHits[] = $searchHit; @@ -165,19 +175,25 @@ public function findContent(Query $query, array $languageFilter = []): SearchRes return $result; } - protected function extractMatchedLanguage($languageMask, $mainLanguageId, $languageSettings) + /** + * @param int[] $languageIds Language ids the content/version is translated into, as returned + * by {@see \Ibexa\Core\Persistence\Legacy\Content\Language\Gateway::loadContentTranslations()}/ + * loadVersionTranslations(). + * @param array{languages?: string[]} $languageSettings + */ + protected function extractMatchedLanguage(array $languageIds, int $mainLanguageId, array $languageSettings, bool $alwaysAvailable = false): ?string { $languageList = !empty($languageSettings['languages']) ? $this->languageHandler->loadListByLanguageCodes($languageSettings['languages']) : []; foreach ($languageList as $language) { - if ($languageMask & $language->id) { + if (in_array($language->id, $languageIds, true)) { return $language->languageCode; } } - if ($languageMask & 1 || empty($languageSettings['languages'])) { + if ($alwaysAvailable || empty($languageSettings['languages'])) { return $this->languageHandler->load($mainLanguageId)->languageCode; } @@ -231,14 +247,19 @@ public function findLocations(LocationQuery $query, array $languageFilter = []): $result->totalCount = $data['count'] !== null ? (int)$data['count'] : null; $locationList = $this->locationMapper->createLocationsFromRows($data['rows']); + $contentTranslations = $this->languageGateway->loadContentTranslations( + array_map(static fn (Location $location): int => $location->contentId, $locationList) + ); + foreach ($locationList as $index => $location) { /** @phpstan-var \Ibexa\Contracts\Core\Repository\Values\Content\Search\SearchHit<\Ibexa\Contracts\Core\Persistence\Content\Location> $searchHit */ $searchHit = new SearchHit(); $searchHit->valueObject = $location; $searchHit->matchedTranslation = $this->extractMatchedLanguage( - $data['rows'][$index]['language_mask'], + $contentTranslations[$location->contentId] ?? [], $data['rows'][$index]['initial_language_id'], - $languageFilter + $languageFilter, + (bool)$data['rows'][$index]['always_available'] ); $result->searchHits[] = $searchHit; diff --git a/src/lib/Search/Legacy/Content/Location/Gateway/DoctrineDatabase.php b/src/lib/Search/Legacy/Content/Location/Gateway/DoctrineDatabase.php index 373fe6f173..06504a8391 100644 --- a/src/lib/Search/Legacy/Content/Location/Gateway/DoctrineDatabase.php +++ b/src/lib/Search/Legacy/Content/Location/Gateway/DoctrineDatabase.php @@ -7,13 +7,11 @@ namespace Ibexa\Core\Search\Legacy\Content\Location\Gateway; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; use Doctrine\DBAL\ParameterType; -use Doctrine\DBAL\Platforms\AbstractPlatform; use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Repository\Values\Content\Query\CriterionInterface; -use Ibexa\Core\Base\Exceptions\DatabaseException; use Ibexa\Core\Persistence\Legacy\Content\Gateway as ContentGateway; use Ibexa\Core\Persistence\Legacy\Content\Location\Gateway as LocationGateway; use Ibexa\Core\Search\Legacy\Content\Common\Gateway\CriteriaConverter; @@ -61,7 +59,7 @@ public function find( $selectQuery = $this->connection->createQueryBuilder(); $selectQuery->select( 't.*', - 'c.language_mask', + 'c.always_available', 'c.initial_language_id' ); @@ -108,18 +106,7 @@ public function find( // If not main-languages query if (!empty($languageFilter['languages'])) { - $selectQuery->andWhere( - $selectQuery->expr()->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'c.language_mask', - $selectQuery->createNamedParameter( - $this->getLanguageMask($languageFilter), - ParameterType::INTEGER - ) - ), - $selectQuery->createNamedParameter(0, ParameterType::INTEGER) - ) - ); + $selectQuery->andWhere($this->buildTranslationCondition($selectQuery, $languageFilter)); } if ($sortClauses !== null) { @@ -182,18 +169,7 @@ private function getTotalCount(CriterionInterface $criterion, array $languageFil // If not main-languages query if (!empty($languageFilter['languages'])) { - $query->andWhere( - $query->expr()->gt( - $this->getDatabasePlatform()->getBitAndComparisonExpression( - 'c.language_mask', - $query->createNamedParameter( - $this->getLanguageMask($languageFilter), - ParameterType::INTEGER - ) - ), - $query->createNamedParameter(0, ParameterType::INTEGER) - ) - ); + $query->andWhere($this->buildTranslationCondition($query, $languageFilter)); } $statement = $query->executeQuery(); @@ -202,38 +178,48 @@ private function getTotalCount(CriterionInterface $criterion, array $languageFil } /** - * Generates a language mask from the given $languageFilter. + * Builds the "content is translated into one of the requested languages, or it's + * always-available" condition shared by find() and getTotalCount() - kept as one place so the + * two queries can't drift out of sync on the always-available fallback. + * + * @param \Doctrine\DBAL\Query\QueryBuilder $queryBuilder + * @param array{languages?: string[], useAlwaysAvailable?: bool} $languageFilter * * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException */ - private function getLanguageMask(array $languageFilter): int + private function buildTranslationCondition($queryBuilder, array $languageFilter): string { - if (!isset($languageFilter['languages'])) { - $languageFilter['languages'] = []; - } + $languageIds = array_map( + fn (string $languageCode): int => $this->languageHandler->loadByLanguageCode($languageCode)->id, + $languageFilter['languages'] ?? [] + ); - if (!isset($languageFilter['useAlwaysAvailable'])) { - $languageFilter['useAlwaysAvailable'] = true; - } + $translationExistsSubQuery = $this->connection->createQueryBuilder(); + $translationExistsSubQuery + ->select('1') + ->from('ibexa_content_translation', 'ct') + ->where( + $translationExistsSubQuery->expr()->and( + 'ct.content_id = c.id', + $translationExistsSubQuery->expr()->in( + 'ct.language_id', + $queryBuilder->createNamedParameter($languageIds, ArrayParameterType::INTEGER) + ) + ) + ); - $mask = 0; - if ($languageFilter['useAlwaysAvailable']) { - $mask |= 1; - } + $translationCondition = sprintf('EXISTS (%s)', $translationExistsSubQuery->getSQL()); - foreach ($languageFilter['languages'] as $languageCode) { - $mask |= $this->languageHandler->loadByLanguageCode($languageCode)->id; + if ($languageFilter['useAlwaysAvailable'] ?? true) { + $translationCondition = $queryBuilder->expr()->or( + $translationCondition, + $queryBuilder->expr()->eq( + 'c.always_available', + $queryBuilder->createNamedParameter(true, ParameterType::BOOLEAN) + ) + ); } - return $mask; - } - - private function getDatabasePlatform(): AbstractPlatform - { - try { - return $this->connection->getDatabasePlatform(); - } catch (Exception $e) { - throw DatabaseException::wrap($e); - } + return $translationCondition; } } diff --git a/src/lib/Search/Legacy/Content/WordIndexer/Gateway/DoctrineDatabase.php b/src/lib/Search/Legacy/Content/WordIndexer/Gateway/DoctrineDatabase.php index 7db599f111..cb102bb140 100644 --- a/src/lib/Search/Legacy/Content/WordIndexer/Gateway/DoctrineDatabase.php +++ b/src/lib/Search/Legacy/Content/WordIndexer/Gateway/DoctrineDatabase.php @@ -8,8 +8,8 @@ namespace Ibexa\Core\Search\Legacy\Content\WordIndexer\Gateway; use Doctrine\DBAL\Connection; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Persistence\Content\Type\Handler as SPITypeHandler; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Persistence\TransformationProcessor; use Ibexa\Core\Search\Legacy\Content\FullTextData; use Ibexa\Core\Search\Legacy\Content\WordIndexer\Gateway; @@ -57,8 +57,8 @@ class DoctrineDatabase extends Gateway */ protected $searchIndex; - /** @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator */ - private $languageMaskGenerator; + /** @var \Ibexa\Contracts\Core\Persistence\Content\Language\Handler */ + private $languageHandler; /** * Full text search configuration options. @@ -72,7 +72,7 @@ public function __construct( SPITypeHandler $typeHandler, TransformationProcessor $transformationProcessor, SearchIndex $searchIndex, - MaskGenerator $languageMaskGenerator, + LanguageHandler $languageHandler, array $fullTextSearchConfiguration ) { $this->connection = $connection; @@ -80,7 +80,7 @@ public function __construct( $this->transformationProcessor = $transformationProcessor; $this->searchIndex = $searchIndex; $this->fullTextSearchConfiguration = $fullTextSearchConfiguration; - $this->languageMaskGenerator = $languageMaskGenerator; + $this->languageHandler = $languageHandler; } /** @@ -252,10 +252,7 @@ private function indexWords(FullTextData $fullTextData, array $indexArray, array $languageCode = $indexArray[$i]['language_code']; $wordId = $wordIDArray[$indexWord]; $isMainAndAlwaysAvailable = $indexArray[$i]['is_main_and_always_available']; - $languageMask = $this->languageMaskGenerator->generateLanguageMaskFromLanguageCodes( - [$languageCode], - $isMainAndAlwaysAvailable - ); + $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id; if (isset($indexArray[$i + 1])) { $nextIndexWord = $indexArray[$i + 1]['Word']; @@ -278,7 +275,8 @@ private function indexWords(FullTextData $fullTextData, array $indexArray, array $fullTextData->sectionId, $identifier, $integerValue, - $languageMask + $languageId, + $isMainAndAlwaysAvailable ); $prevWordId = $wordId; ++$placement; diff --git a/src/lib/Search/Legacy/Content/WordIndexer/Repository/SearchIndex.php b/src/lib/Search/Legacy/Content/WordIndexer/Repository/SearchIndex.php index 4d1397f626..b5c2cfb362 100644 --- a/src/lib/Search/Legacy/Content/WordIndexer/Repository/SearchIndex.php +++ b/src/lib/Search/Legacy/Content/WordIndexer/Repository/SearchIndex.php @@ -131,7 +131,8 @@ public function addObjectWordLink( int $sectionId, string $identifier, int $integerValue, - int $languageMask + int $languageId, + bool $isMainAndAlwaysAvailable ): void { $query = $this->connection->createQueryBuilder(); $query @@ -180,10 +181,14 @@ public function addObjectWordLink( $integerValue, ParameterType::INTEGER ), - 'language_mask' => $query->createPositionalParameter( - $languageMask, + 'language_id' => $query->createPositionalParameter( + $languageId, ParameterType::INTEGER ), + 'is_main_and_always_available' => $query->createPositionalParameter( + $isMainAndAlwaysAvailable, + ParameterType::BOOLEAN + ), ] ); diff --git a/tests/bundle/Core/Command/BackfillLanguageTranslationsCommandTest.php b/tests/bundle/Core/Command/BackfillLanguageTranslationsCommandTest.php new file mode 100644 index 0000000000..94795a9bea --- /dev/null +++ b/tests/bundle/Core/Command/BackfillLanguageTranslationsCommandTest.php @@ -0,0 +1,226 @@ +insertDatabaseFixture( + __DIR__ . '/../../../lib/Persistence/Legacy/Content/_fixtures/languages.php' + ); + + $connection = $this->getDatabaseConnection(); + + // These commands hardcode "ibexa_content_language" (the name it still has at the point in + // the real migration sequence where they're used, before NarrowLanguageIdColumnTypesMigration + // renames it to "ibexa_language") - rename it back after the fixture above (which inserts via + // the Gateway::CONTENT_LANGUAGE_TABLE constant, i.e. under the current name) has populated it. + $connection->executeStatement('ALTER TABLE ibexa_language RENAME TO ibexa_content_language'); + + // These commands exist specifically for installs upgrading from before the language + // bitmask columns were dropped - simulate that pre-drop schema state here, since the + // current schema.yaml (and therefore this test's own bootstrapped schema) no longer has + // them. + $connection->executeStatement('ALTER TABLE ibexa_content ADD COLUMN language_mask INTEGER DEFAULT 0 NOT NULL'); + $connection->executeStatement('ALTER TABLE ibexa_content_version ADD COLUMN language_mask INTEGER DEFAULT 0 NOT NULL'); + $connection->executeStatement('ALTER TABLE ibexa_url_alias_ml ADD COLUMN lang_mask INTEGER DEFAULT 0 NOT NULL'); + + // content id 1: eng-US only, not always available -> mask 2 + // content id 2: eng-US + eng-GB, always available -> mask 7 + $connection->insert('ibexa_content', [ + 'id' => 1, + 'content_type_id' => 1, + 'current_version' => 1, + 'initial_language_id' => 2, + 'language_mask' => 2, + 'always_available' => 0, + 'name' => 'Foo', + 'owner_id' => 14, + 'remote_id' => 'foo', + ]); + $connection->insert('ibexa_content', [ + 'id' => 2, + 'content_type_id' => 1, + 'current_version' => 1, + 'initial_language_id' => 2, + 'language_mask' => 7, + 'always_available' => 1, + 'name' => 'Bar', + 'owner_id' => 14, + 'remote_id' => 'bar', + ]); + + $connection->insert('ibexa_content_version', [ + 'id' => 1, + 'contentobject_id' => 1, + 'version' => 1, + 'initial_language_id' => 2, + 'language_mask' => 2, + ]); + $connection->insert('ibexa_content_version', [ + 'id' => 2, + 'contentobject_id' => 2, + 'version' => 1, + 'initial_language_id' => 2, + 'language_mask' => 7, + ]); + + $connection->insert('ibexa_url_alias_ml', [ + 'parent' => 0, + 'text_md5' => md5('foo'), + 'id' => 1, + 'text' => 'foo', + 'action' => 'eznode:1', + 'action_type' => 'eznode', + 'lang_mask' => 7, + ], ['lang_mask' => ParameterType::INTEGER]); + } + + protected function tearDown(): void + { + // The underlying SQLite connection is reused across tests (see + // DatabaseConnectionFactory's static connection pool) - restore the name the next test's + // schema.yaml-based setUp() expects to find. + $this->getDatabaseConnection()->executeStatement('ALTER TABLE ibexa_content_language RENAME TO ibexa_language'); + + parent::tearDown(); + } + + public function testBackfillPopulatesTranslationTablesFromMasks(): void + { + $exitCode = (new CommandTester(new BackfillLanguageTranslationsCommand($this->getDatabaseConnection()))) + ->execute(['--table' => 'all']) + ; + + self::assertSame(0, $exitCode); + + self::assertEqualsCanonicalizing( + [[1, 2], [2, 2], [2, 4]], + $this->fetchPairs('ibexa_content_translation', 'content_id') + ); + self::assertEqualsCanonicalizing( + [[1, 2], [2, 2], [2, 4]], + $this->fetchPairs('ibexa_content_version_translation', 'content_version_id') + ); + self::assertEqualsCanonicalizing( + [[2], [4]], + array_map( + static fn (array $row): array => [(int)$row['language_id']], + $this->getDatabaseConnection()->fetchAllAssociative( + 'SELECT language_id FROM ibexa_url_alias_ml_translation' + ) + ) + ); + } + + public function testBackfillIsIdempotent(): void + { + $command = new BackfillLanguageTranslationsCommand($this->getDatabaseConnection()); + (new CommandTester($command))->execute(['--table' => 'content']); + (new CommandTester($command))->execute(['--table' => 'content']); + + self::assertEqualsCanonicalizing( + [[1, 2], [2, 2], [2, 4]], + $this->fetchPairs('ibexa_content_translation', 'content_id') + ); + } + + public function testBackfillDryRunDoesNotWrite(): void + { + $command = new BackfillLanguageTranslationsCommand($this->getDatabaseConnection()); + (new CommandTester($command))->execute(['--table' => 'content', '--dry-run' => true]); + + self::assertSame([], $this->fetchPairs('ibexa_content_translation', 'content_id')); + } + + public function testBackfillDryRunReportsNothingRemainingAfterRealBackfill(): void + { + $connection = $this->getDatabaseConnection(); + (new CommandTester(new BackfillLanguageTranslationsCommand($connection))) + ->execute(['--table' => 'content']); + + // A dry-run preview after a real backfill must report that nothing is left to insert - not + // recount every mask-derived pair as if none of them already existed. + $tester = new CommandTester(new BackfillLanguageTranslationsCommand($connection)); + $tester->execute(['--table' => 'content', '--dry-run' => true]); + + self::assertStringContainsString('0 row(s) would be inserted', $tester->getDisplay()); + // The real rows from the earlier backfill must not have been touched by the dry-run. + self::assertEqualsCanonicalizing( + [[1, 2], [2, 2], [2, 4]], + $this->fetchPairs('ibexa_content_translation', 'content_id') + ); + } + + public function testVerifyReportsCleanAfterBackfill(): void + { + (new CommandTester(new BackfillLanguageTranslationsCommand($this->getDatabaseConnection()))) + ->execute(['--table' => 'all']); + + $tester = new CommandTester(new VerifyLanguageTranslationsCommand($this->getDatabaseConnection())); + $exitCode = $tester->execute(['--table' => 'all']); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('in sync', $tester->getDisplay()); + } + + public function testVerifyDetectsMissingAndOrphanedRowsAndFixesThem(): void + { + $connection = $this->getDatabaseConnection(); + + // Missing: content 1's translation was never backfilled. + $connection->insert('ibexa_content_translation', ['content_id' => 2, 'language_id' => 2]); + $connection->insert('ibexa_content_translation', ['content_id' => 2, 'language_id' => 4]); + // Orphaned: language 4 is not part of content 1's mask (2). + $connection->insert('ibexa_content_translation', ['content_id' => 1, 'language_id' => 4]); + + $tester = new CommandTester(new VerifyLanguageTranslationsCommand($connection)); + $exitCode = $tester->execute(['--table' => 'content']); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('1 missing, 1 orphaned', $tester->getDisplay()); + + $fixTester = new CommandTester(new VerifyLanguageTranslationsCommand($connection)); + $fixExitCode = $fixTester->execute(['--table' => 'content', '--fix' => true]); + + self::assertSame(0, $fixExitCode); + self::assertEqualsCanonicalizing( + [[1, 2], [2, 2], [2, 4]], + $this->fetchPairs('ibexa_content_translation', 'content_id') + ); + } + + /** + * @return list + */ + private function fetchPairs(string $table, string $idColumn): array + { + $rows = $this->getDatabaseConnection()->fetchAllAssociative( + "SELECT {$idColumn}, language_id FROM {$table}" + ); + + return array_map( + static fn (array $row): array => [(int)$row[$idColumn], (int)$row['language_id']], + $rows + ); + } +} diff --git a/tests/bundle/Core/Doctrine/ManagedTablesSchemaAssetFilterTest.php b/tests/bundle/Core/Doctrine/ManagedTablesSchemaAssetFilterTest.php index edf01ba496..01b0fe9550 100644 --- a/tests/bundle/Core/Doctrine/ManagedTablesSchemaAssetFilterTest.php +++ b/tests/bundle/Core/Doctrine/ManagedTablesSchemaAssetFilterTest.php @@ -60,7 +60,7 @@ public function testProtectsTablesNotBackedByAnyRegisteredEntity(): void { $filter = $this->createFilter(['ibexa_taxonomy_entry']); - self::assertFalse($filter('ibexa_content_language')); + self::assertFalse($filter('ibexa_language')); self::assertFalse($filter('ibexa_migrations')); } @@ -69,7 +69,7 @@ public function testAcceptsAnAbstractAssetInstance(): void $filter = $this->createFilter(['ibexa_taxonomy_entry']); self::assertTrue($filter(new Table('ibexa_taxonomy_entry'))); - self::assertFalse($filter(new Table('ibexa_content_language'))); + self::assertFalse($filter(new Table('ibexa_language'))); } public function testOnlyQueriesTheManagerRegistryOnce(): void @@ -89,6 +89,6 @@ public function testOnlyQueriesTheManagerRegistryOnce(): void $filter = new ManagedTablesSchemaAssetFilter($managerRegistry); $filter('ibexa_taxonomy_entry'); - $filter('ibexa_content_language'); + $filter('ibexa_language'); } } diff --git a/tests/bundle/Core/Resources/services/fixture-services.yaml b/tests/bundle/Core/Resources/services/fixture-services.yaml index 28c7437394..e05f4d73d9 100644 --- a/tests/bundle/Core/Resources/services/fixture-services.yaml +++ b/tests/bundle/Core/Resources/services/fixture-services.yaml @@ -23,6 +23,7 @@ services: public: true arguments: - '@doctrine.dbal.default_connection' + - '@Ibexa\Contracts\DoctrineSchema\SchemaAssetsFilterBypassInterface' Ibexa\DoctrineSchema\Database\DbPlatform\SqliteDbPlatform: calls: diff --git a/tests/bundle/RepositoryInstaller/DependencyInjection/Compiler/InstallerTagPassTest.php b/tests/bundle/RepositoryInstaller/DependencyInjection/Compiler/InstallerTagPassTest.php deleted file mode 100644 index 0dd8d5003b..0000000000 --- a/tests/bundle/RepositoryInstaller/DependencyInjection/Compiler/InstallerTagPassTest.php +++ /dev/null @@ -1,56 +0,0 @@ -setDefinition( - InstallPlatformCommand::class, - new Definition(InstallPlatformCommand::class, ['$installers' => []]) - ); - $definition = new Definition(); - $definition->addTag( - InstallerTagPass::INSTALLER_TAG, - [ - 'type' => 'installer_type', - ] - ); - - $this->setDefinition('service_id', $definition); - $this->compile(); - - $this->assertContainerBuilderHasServiceDefinitionWithArgument( - InstallPlatformCommand::class, - '$installers', - [ - 'installer_type' => new Reference('service_id'), - ] - ); - } - - protected function registerCompilerPass(ContainerBuilder $container): void - { - $container->addCompilerPass(new InstallerTagPass()); - } -} diff --git a/tests/bundle/RepositoryInstaller/Migration/LanguageBitmaskUpgradeSequenceTest.php b/tests/bundle/RepositoryInstaller/Migration/LanguageBitmaskUpgradeSequenceTest.php new file mode 100644 index 0000000000..85aa625146 --- /dev/null +++ b/tests/bundle/RepositoryInstaller/Migration/LanguageBitmaskUpgradeSequenceTest.php @@ -0,0 +1,467 @@ +getDatabaseConnection()->executeStatement('ALTER TABLE ibexa_language RENAME TO ibexa_content_language'); + + $schemaImporter = new LegacySchemaImporter($this->getDatabaseConnection(), new SchemaAssetsFilterBypass()); + $schemaImporter->importSchema( + __DIR__ . '/_fixtures/pre_language_bitmask_migration_schema.yaml' + ); + } + + protected function tearDown(): void + { + $connection = $this->getDatabaseConnection(); + + // Tests that expect the sequence to abort before NarrowLanguageIdColumnTypesMigration's + // rename step (e.g. testDropMigrationAbortsIfBackfillWasSkipped) never rename the table + // back to "ibexa_language". The underlying SQLite connection is reused across tests (see + // DatabaseConnectionFactory's static connection pool), so leaving it renamed would break the + // next test's setUp(), which expects parent::setUp()'s schema.yaml import to find + // "ibexa_language" under its current name rather than colliding with an orphaned table still + // carrying the same index name under the old one. + if ($connection->createSchemaManager()->tablesExist(['ibexa_content_language'])) { + $connection->executeStatement('ALTER TABLE ibexa_content_language RENAME TO ibexa_language'); + } + + parent::tearDown(); + } + + public function testFullSequenceMigratesExistingDataCorrectly(): void + { + $connection = $this->getDatabaseConnection(); + $this->seedPreMigrationData($connection); + + $this->runMigration(new AddContentAlwaysAvailableColumnsMigration($connection, new NullLogger())); + $this->runMigration(new AddLanguageTranslationTablesMigration($connection, new NullLogger())); + $this->runMigration(new BackfillLanguageTranslationsMigration($connection, new NullLogger())); + $this->runMigration(new AddSearchObjectWordLinkLanguageIdColumnsMigration($connection, new NullLogger())); + $this->runMigration(new AddUrlAliasAlwaysAvailableColumnMigration($connection, new NullLogger())); + $this->runMigration(new DropLanguageBitmaskColumnsMigration($connection, new NullLogger())); + // Renames "ibexa_content_language" to "ibexa_language" on every platform including SQLite. + // The column-narrowing half of this same migration is MySQL/PostgreSQL-only (SQLite has no + // ALTER COLUMN TYPE) and is verified against real databases, not here. + $this->runMigration(new NarrowLanguageIdColumnTypesMigration($connection, new NullLogger())); + + $schemaManager = $connection->createSchemaManager(); + self::assertFalse($schemaManager->tablesExist(['ibexa_content_language'])); + self::assertTrue($schemaManager->tablesExist(['ibexa_language'])); + self::assertFalse($schemaManager->introspectTable('ibexa_content')->hasColumn('language_mask')); + self::assertFalse($schemaManager->introspectTable('ibexa_content_version')->hasColumn('language_mask')); + self::assertFalse($schemaManager->introspectTable('ibexa_url_alias_ml')->hasColumn('lang_mask')); + self::assertFalse($schemaManager->introspectTable('ibexa_search_object_word_link')->hasColumn('language_mask')); + self::assertFalse($schemaManager->introspectTable('ibexa_object_state')->hasColumn('language_mask')); + self::assertFalse($schemaManager->introspectTable('ibexa_object_state_group')->hasColumn('language_mask')); + self::assertFalse($schemaManager->introspectTable('ibexa_content_type')->hasColumn('language_mask')); + + // Content 1: eng-US + eng-GB, not always available (mask 10 = 2|8) + self::assertEquals(0, $connection->fetchOne('SELECT always_available FROM ibexa_content WHERE id = 1')); + self::assertEqualsCanonicalizing( + [self::ENG_US, self::ENG_GB], + $this->fetchLanguageIds($connection, 'ibexa_content_translation', 'content_id', 1) + ); + self::assertEqualsCanonicalizing( + [self::ENG_US, self::ENG_GB], + $this->fetchLanguageIds($connection, 'ibexa_content_version_translation', 'content_version_id', 1) + ); + + // Content 2: ger-DE only, always available (mask 5 = 4|1) + self::assertEquals(1, $connection->fetchOne('SELECT always_available FROM ibexa_content WHERE id = 2')); + self::assertEqualsCanonicalizing( + [self::GER_DE], + $this->fetchLanguageIds($connection, 'ibexa_content_translation', 'content_id', 2) + ); + self::assertEqualsCanonicalizing( + [self::GER_DE], + $this->fetchLanguageIds($connection, 'ibexa_content_version_translation', 'content_version_id', 2) + ); + + // URL alias: eng-US + eng-GB, always available (mask 11 = 2|8|1) + self::assertEquals( + 1, + $connection->fetchOne( + "SELECT is_always_available FROM ibexa_url_alias_ml WHERE parent = 0 AND text_md5 = 'hash1'" + ) + ); + self::assertEqualsCanonicalizing( + [self::ENG_US, self::ENG_GB], + array_map( + 'intval', + $connection->fetchFirstColumn( + "SELECT language_id FROM ibexa_url_alias_ml_translation WHERE parent = 0 AND text_md5 = 'hash1'" + ) + ) + ); + + // Search word link: eng-GB, always available (mask 9 = 8|1) + $wordLinkRow = $connection->fetchAssociative( + 'SELECT language_id, is_main_and_always_available FROM ibexa_search_object_word_link WHERE id = 1' + ); + self::assertIsArray($wordLinkRow); + self::assertEquals(self::ENG_GB, $wordLinkRow['language_id']); + self::assertEquals(1, $wordLinkRow['is_main_and_always_available']); + } + + public function testDropMigrationAbortsIfBackfillWasSkipped(): void + { + $connection = $this->getDatabaseConnection(); + $this->seedPreMigrationData($connection); + + $this->runMigration(new AddContentAlwaysAvailableColumnsMigration($connection, new NullLogger())); + $this->runMigration(new AddLanguageTranslationTablesMigration($connection, new NullLogger())); + // Deliberately skip BackfillLanguageTranslationsMigration, simulating an interrupted or + // manually-mismanaged upgrade. + $this->runMigration(new AddSearchObjectWordLinkLanguageIdColumnsMigration($connection, new NullLogger())); + $this->runMigration(new AddUrlAliasAlwaysAvailableColumnMigration($connection, new NullLogger())); + + $this->expectException(AbortMigration::class); + $this->expectExceptionMessageMatches('/backfill-translations/'); + + $this->runMigration(new DropLanguageBitmaskColumnsMigration($connection, new NullLogger())); + } + + public function testDropMigrationAbortsIfBackfillWasOnlyPartial(): void + { + $connection = $this->getDatabaseConnection(); + $this->seedPreMigrationData($connection); + + $this->runMigration(new AddContentAlwaysAvailableColumnsMigration($connection, new NullLogger())); + $this->runMigration(new AddLanguageTranslationTablesMigration($connection, new NullLogger())); + $this->runMigration(new BackfillLanguageTranslationsMigration($connection, new NullLogger())); + $this->runMigration(new AddSearchObjectWordLinkLanguageIdColumnsMigration($connection, new NullLogger())); + $this->runMigration(new AddUrlAliasAlwaysAvailableColumnMigration($connection, new NullLogger())); + + // Content 1 carries mask 10 = eng-US(2)|eng-GB(8), fully backfilled by the migration above. + // Simulate an interrupted backfill that only wrote one of its two languages: a row with + // multiple set bits but only one matching translation row must still block the drop, not + // just a row with zero translation rows at all. + $connection->executeStatement( + 'DELETE FROM ibexa_content_translation WHERE content_id = 1 AND language_id = :languageId', + ['languageId' => self::ENG_GB], + ['languageId' => ParameterType::INTEGER] + ); + + $this->expectException(AbortMigration::class); + $this->expectExceptionMessageMatches('/backfill-translations/'); + + $this->runMigration(new DropLanguageBitmaskColumnsMigration($connection, new NullLogger())); + } + + public function testAddContentAlwaysAvailableColumnsMigrationRecoversFromPartialFailure(): void + { + $connection = $this->getDatabaseConnection(); + $this->seedPreMigrationData($connection); + + // Simulate a MySQL run that added the first column then died before the second one (and + // before either backfill): a retry must not mistake "ibexa_content already has the column" + // for "this migration already fully ran". + $connection->executeStatement('ALTER TABLE ibexa_content ADD COLUMN always_available BOOLEAN DEFAULT 0 NOT NULL'); + + $this->runMigration(new AddContentAlwaysAvailableColumnsMigration($connection, new NullLogger())); + + $schemaManager = $connection->createSchemaManager(); + self::assertTrue($schemaManager->introspectTable('ibexa_content_version')->hasColumn('always_available')); + + // Content 1: mask 10 (not always available), Content 2: mask 5 (always available). + self::assertEquals(0, $connection->fetchOne('SELECT always_available FROM ibexa_content WHERE id = 1')); + self::assertEquals(1, $connection->fetchOne('SELECT always_available FROM ibexa_content WHERE id = 2')); + self::assertEquals(0, $connection->fetchOne('SELECT always_available FROM ibexa_content_version WHERE id = 1')); + self::assertEquals(1, $connection->fetchOne('SELECT always_available FROM ibexa_content_version WHERE id = 2')); + } + + public function testAddLanguageTranslationTablesMigrationRecoversFromPartialFailure(): void + { + $connection = $this->getDatabaseConnection(); + $this->seedPreMigrationData($connection); + + // All 3 tables already exist by this point (created by the current schema.yaml import in + // the base TestCase::setUp()). Simulate a run that only got as far as creating the first + // one before dying (on SQLite, each table+its FKs is created atomically in one statement, + // so "table exists but a constraint is missing" can't happen there the way it can on + // MySQL/PostgreSQL - the meaningful partial state to simulate here is a missing table): a + // retry must not mistake "ibexa_content_translation already exists" for "everything already + // ran" and skip creating the other two. + $connection->executeStatement('DROP TABLE ibexa_content_version_translation'); + $connection->executeStatement('DROP TABLE ibexa_url_alias_ml_translation'); + + $this->runMigration(new AddLanguageTranslationTablesMigration($connection, new NullLogger())); + + $schemaManager = $connection->createSchemaManager(); + self::assertTrue($schemaManager->tablesExist(['ibexa_content_version_translation'])); + self::assertTrue($schemaManager->tablesExist(['ibexa_url_alias_ml_translation'])); + self::assertTrue( + $schemaManager->introspectTable('ibexa_content_version_translation') + ->hasForeignKey('ibexa_content_version_translation_language_fk') + ); + self::assertTrue( + $schemaManager->introspectTable('ibexa_url_alias_ml_translation') + ->hasForeignKey('ibexa_url_alias_ml_translation_language_fk') + ); + // The untouched table must survive re-running the migration undisturbed. + self::assertTrue( + $schemaManager->introspectTable('ibexa_content_translation') + ->hasForeignKey('ibexa_content_translation_language_fk') + ); + } + + public function testAddSearchObjectWordLinkLanguageIdColumnsMigrationRecoversFromPartialFailure(): void + { + $connection = $this->getDatabaseConnection(); + $this->seedPreMigrationData($connection); + + // Simulate a MySQL run that added "language_id" then died before adding + // "is_main_and_always_available" and before either backfill: a retry must not mistake + // "language_id already exists" for "this migration already fully ran". + $connection->executeStatement( + 'ALTER TABLE ibexa_search_object_word_link ADD COLUMN language_id INTEGER DEFAULT 0 NOT NULL' + ); + + $this->runMigration(new AddSearchObjectWordLinkLanguageIdColumnsMigration($connection, new NullLogger())); + + $schemaManager = $connection->createSchemaManager(); + self::assertTrue( + $schemaManager->introspectTable('ibexa_search_object_word_link') + ->hasColumn('is_main_and_always_available') + ); + + // Search word link row: mask 9 = eng-GB(8)|1, always available. + $wordLinkRow = $connection->fetchAssociative( + 'SELECT language_id, is_main_and_always_available FROM ibexa_search_object_word_link WHERE id = 1' + ); + self::assertIsArray($wordLinkRow); + self::assertEquals(self::ENG_GB, $wordLinkRow['language_id']); + self::assertEquals(1, $wordLinkRow['is_main_and_always_available']); + } + + public function testAddUrlAliasAlwaysAvailableColumnMigrationRecoversFromPartialFailure(): void + { + $connection = $this->getDatabaseConnection(); + $this->seedPreMigrationData($connection); + + // Simulate a MySQL run that added the column then died before the backfill UPDATE: the + // column's mere presence is not proof the backfill ever ran, so a retry must still backfill. + $connection->executeStatement( + 'ALTER TABLE ibexa_url_alias_ml ADD COLUMN is_always_available BOOLEAN DEFAULT 0 NOT NULL' + ); + + $this->runMigration(new AddUrlAliasAlwaysAvailableColumnMigration($connection, new NullLogger())); + + // URL alias: mask 11 = eng-US(2)|eng-GB(8)|1, always available. + self::assertEquals( + 1, + $connection->fetchOne( + "SELECT is_always_available FROM ibexa_url_alias_ml WHERE parent = 0 AND text_md5 = 'hash1'" + ) + ); + } + + public function testDropMigrationRecoversFromPartialFailure(): void + { + $connection = $this->getDatabaseConnection(); + $this->seedPreMigrationData($connection); + + $this->runMigration(new AddContentAlwaysAvailableColumnsMigration($connection, new NullLogger())); + $this->runMigration(new AddLanguageTranslationTablesMigration($connection, new NullLogger())); + $this->runMigration(new BackfillLanguageTranslationsMigration($connection, new NullLogger())); + $this->runMigration(new AddSearchObjectWordLinkLanguageIdColumnsMigration($connection, new NullLogger())); + $this->runMigration(new AddUrlAliasAlwaysAvailableColumnMigration($connection, new NullLogger())); + + // Simulate a MySQL run that dropped the first table's column (and index) then died before + // reaching any of the other six: a retry must not mistake "ibexa_object_state's mask column + // is already gone" for "this migration already fully ran" - every other table's column/index + // must still get dropped/replaced, not left dangling forever. + $connection->executeStatement('DROP INDEX ibexa_object_state_lmask'); + $connection->executeStatement('ALTER TABLE ibexa_object_state DROP COLUMN language_mask'); + + $this->runMigration(new DropLanguageBitmaskColumnsMigration($connection, new NullLogger())); + + $schemaManager = $connection->createSchemaManager(); + foreach ( + [ + 'ibexa_object_state_group', + 'ibexa_content_type', + 'ibexa_content', + 'ibexa_content_version', + 'ibexa_search_object_word_link', + ] as $table + ) { + self::assertFalse( + $schemaManager->introspectTable($table)->hasColumn('language_mask'), + "\"{$table}\" should no longer have a \"language_mask\" column" + ); + } + self::assertFalse($schemaManager->introspectTable('ibexa_url_alias_ml')->hasColumn('lang_mask')); + + // The replacement (text, parent) index must exist even though the DROP+CREATE happens in + // two separate statements. + self::assertTrue($schemaManager->introspectTable('ibexa_url_alias_ml')->hasIndex('ibexa_url_alias_ml_text_lang')); + self::assertEqualsCanonicalizing( + ['text', 'parent'], + $schemaManager->introspectTable('ibexa_url_alias_ml')->getIndex('ibexa_url_alias_ml_text_lang')->getColumns() + ); + } + + private function seedPreMigrationData(Connection $connection): void + { + foreach ( + [ + [self::ENG_US, 'eng-US', 'English (American)'], + [self::GER_DE, 'ger-DE', 'German'], + [self::ENG_GB, 'eng-GB', 'English (United Kingdom)'], + ] as [$id, $locale, $name] + ) { + $connection->insert( + 'ibexa_content_language', + ['id' => $id, 'locale' => $locale, 'name' => $name, 'disabled' => 0] + ); + } + + // Content 1: eng-US(2) + eng-GB(8), not always available -> mask 10 + $connection->insert('ibexa_content', [ + 'id' => 1, + 'content_type_id' => 1, + 'current_version' => 1, + 'initial_language_id' => self::ENG_US, + 'language_mask' => self::ENG_US | self::ENG_GB, + 'name' => 'Foo', + 'owner_id' => 14, + 'remote_id' => 'foo', + ]); + $connection->insert('ibexa_content_version', [ + 'id' => 1, + 'contentobject_id' => 1, + 'version' => 1, + 'initial_language_id' => self::ENG_US, + 'language_mask' => self::ENG_US | self::ENG_GB, + ]); + + // Content 2: ger-DE(4), always available -> mask 5 + $connection->insert('ibexa_content', [ + 'id' => 2, + 'content_type_id' => 1, + 'current_version' => 1, + 'initial_language_id' => self::GER_DE, + 'language_mask' => self::GER_DE | 1, + 'name' => 'Bar', + 'owner_id' => 14, + 'remote_id' => 'bar', + ]); + $connection->insert('ibexa_content_version', [ + 'id' => 2, + 'contentobject_id' => 2, + 'version' => 1, + 'initial_language_id' => self::GER_DE, + 'language_mask' => self::GER_DE | 1, + ]); + + // URL alias: eng-US + eng-GB, always available -> mask 11 + $connection->insert( + 'ibexa_url_alias_ml', + [ + 'parent' => 0, + 'text_md5' => 'hash1', + 'id' => 1, + 'text' => 'foo', + 'action' => 'eznode:1', + 'action_type' => 'eznode', + 'lang_mask' => self::ENG_US | self::ENG_GB | 1, + ], + ['lang_mask' => ParameterType::INTEGER] + ); + + // Search word link: eng-GB, always available -> mask 9 + $connection->insert('ibexa_search_object_word_link', [ + 'id' => 1, + 'contentobject_id' => 1, + 'word_id' => 1, + 'identifier' => 'foo', + 'language_mask' => self::ENG_GB | 1, + ]); + } + + /** + * @return int[] + */ + private function fetchLanguageIds(Connection $connection, string $table, string $idColumn, int $id): array + { + return array_map( + 'intval', + $connection->fetchFirstColumn( + "SELECT language_id FROM {$table} WHERE {$idColumn} = :id", + ['id' => $id], + ['id' => ParameterType::INTEGER] + ) + ); + } + + /** + * Runs a migration exactly as {@see \Ibexa\Bundle\RepositoryInstaller\Migration\TaggedMigrationsRunner} + * does: call up() to populate its queued SQL, then execute each queued statement in order. + */ + private function runMigration(AbstractSqlMigration $migration): void + { + $migration->up(new Schema()); + + $connection = $this->getDatabaseConnection(); + foreach ($migration->getSql() as $query) { + $connection->executeStatement($query->getStatement(), $query->getParameters(), $query->getTypes()); + } + } +} diff --git a/tests/bundle/RepositoryInstaller/Migration/_fixtures/pre_language_bitmask_migration_schema.yaml b/tests/bundle/RepositoryInstaller/Migration/_fixtures/pre_language_bitmask_migration_schema.yaml new file mode 100644 index 0000000000..e3939a7eca --- /dev/null +++ b/tests/bundle/RepositoryInstaller/Migration/_fixtures/pre_language_bitmask_migration_schema.yaml @@ -0,0 +1,662 @@ +tables: + ibexa_binary_file: + id: + contentobject_attribute_id: { type: integer, nullable: false, options: { default: '0' } } + version: { type: integer, nullable: false, options: { default: '0' } } + fields: + download_count: { type: integer, nullable: false, options: { default: '0' } } + filename: { type: string, nullable: false, length: 255, options: { default: '' } } + mime_type: { type: string, nullable: false, length: 255, options: { default: '' } } + original_filename: { type: string, nullable: false, length: 255, options: { default: '' } } + ibexa_object_state: + indexes: + ibexa_object_state_priority: { fields: [priority] } + ibexa_object_state_lmask: { fields: [language_mask] } + uniqueConstraints: + ibexa_object_state_identifier: { fields: [group_id, identifier] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + default_language_id: { type: bigint, nullable: false, options: { default: '0' } } + group_id: { type: integer, nullable: false, options: { default: '0' } } + identifier: { type: string, nullable: false, length: 45, options: { default: '' } } + language_mask: { type: bigint, nullable: false, options: { default: '0' } } + priority: { type: integer, nullable: false, options: { default: '0' } } + ibexa_object_state_group: + indexes: + ibexa_object_state_group_lmask: { fields: [language_mask] } + uniqueConstraints: + ibexa_object_state_group_identifier: { fields: [identifier] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + default_language_id: { type: bigint, nullable: false, options: { default: '0' } } + identifier: { type: string, nullable: false, length: 45, options: { default: '' } } + language_mask: { type: bigint, nullable: false, options: { default: '0' } } + ibexa_object_state_group_language: + id: + contentobject_state_group_id: { type: integer, nullable: false, options: { default: '0' } } + real_language_id: { type: bigint, nullable: false, options: { default: '0' } } + fields: + description: { type: text, nullable: false, length: 0 } + language_id: { type: bigint, nullable: false, options: { default: '0' } } + name: { type: string, nullable: false, length: 45, options: { default: '' } } + ibexa_object_state_language: + id: + contentobject_state_id: { type: integer, nullable: false, options: { default: '0' } } + language_id: { type: bigint, nullable: false, options: { default: '0' } } + fields: + description: { type: text, nullable: false, length: 0 } + name: { type: string, nullable: false, length: 45, options: { default: '' } } + ibexa_object_state_link: + id: + contentobject_id: { type: integer, nullable: false, options: { default: '0' } } + contentobject_state_id: { type: integer, nullable: false, options: { default: '0' } } + ibexa_user: + uniqueConstraints: + ibexa_user_login: { fields: [login] } + id: + contentobject_id: { type: integer, nullable: false, options: { default: '0' } } + fields: + email: { type: string, nullable: false, length: 150, options: { default: '' } } + login: { type: string, nullable: false, length: 150, options: { default: '' } } + password_hash: { type: string, nullable: true, length: 255 } + password_hash_type: { type: integer, nullable: false, options: { default: '1' } } + password_updated_at: { type: integer, nullable: true } + ibexa_content_tree: + indexes: + ibexa_content_tree_p_node_id: { fields: [parent_node_id] } + ibexa_content_tree_path_ident: { fields: [path_identification_string], options: { lengths: ['50'] } } + ibexa_content_tree_contentobject_id_path_string: { fields: [path_string, contentobject_id], options: { lengths: ['191', null] } } + ibexa_content_tree_co_id: { fields: [contentobject_id] } + ibexa_content_tree_depth: { fields: [depth] } + ibexa_content_tree_path: { fields: [path_string], options: { lengths: ['191'] } } + ibexa_content_modified_subnode: { fields: [modified_subnode] } + ibexa_content_tree_remote_id: { fields: [remote_id] } + id: + node_id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + contentobject_id: { type: integer, nullable: true } + contentobject_is_published: { type: integer, nullable: true } + contentobject_version: { type: integer, nullable: true } + depth: { type: integer, nullable: false, options: { default: '0' } } + is_hidden: { type: integer, nullable: false, options: { default: '0' } } + is_invisible: { type: integer, nullable: false, options: { default: '0' } } + main_node_id: { type: integer, nullable: true } + modified_subnode: { type: integer, nullable: true, options: { default: '0' } } + parent_node_id: { type: integer, nullable: false, options: { default: '0' } } + path_identification_string: { type: text, nullable: true, length: 0 } + path_string: { type: string, nullable: false, length: 255, options: { default: '' } } + priority: { type: integer, nullable: false, options: { default: '0' } } + remote_id: { type: string, nullable: false, length: 100, options: { default: '' } } + sort_field: { type: integer, nullable: true, options: { default: '1' } } + sort_order: { type: integer, nullable: true, options: { default: '1' } } + ibexa_content_bookmark: + indexes: + ibexa_content_bookmark_location: { fields: [node_id] } + ibexa_content_bookmark_user: { fields: [user_id] } + ibexa_content_bookmark_user_location: { fields: [user_id, node_id] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + node_id: { type: integer, nullable: false, options: { default: '0' } } + user_id: { type: integer, nullable: false, options: { default: '0' } } + name: { type: string, nullable: false, length: 255, options: { default: '' } } + foreignKeys: + ibexa_content_bookmark_location_fk: { fields: [node_id], foreignTable: ibexa_content_tree, foreignFields: [node_id], options: { onDelete: CASCADE, onUpdate: 'NO ACTION' } } + ibexa_content_bookmark_user_fk: { fields: [user_id], foreignTable: ibexa_user, foreignFields: [contentobject_id], options: { onDelete: CASCADE, onUpdate: 'NO ACTION' } } + ibexa_content_type: + indexes: + ibexa_content_type_status: { fields: [status] } + ibexa_content_type_identifier: { fields: [identifier, status] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + status: { type: integer, nullable: false, options: { default: '0' } } + fields: + always_available: { type: integer, nullable: false, options: { default: '0' } } + contentobject_name: { type: string, nullable: true, length: 255 } + created: { type: integer, nullable: false, options: { default: '0' } } + creator_id: { type: integer, nullable: false, options: { default: '0' } } + identifier: { type: string, nullable: false, length: 50, options: { default: '' } } + initial_language_id: { type: bigint, nullable: false, options: { default: '0' } } + is_container: { type: integer, nullable: false, options: { default: '0' } } + language_mask: { type: bigint, nullable: false, options: { default: '0' } } + modified: { type: integer, nullable: false, options: { default: '0' } } + modifier_id: { type: integer, nullable: false, options: { default: '0' } } + remote_id: { type: string, nullable: false, length: 100, options: { default: '' } } + serialized_description_list: { type: text, nullable: true, length: 0 } + serialized_name_list: { type: text, nullable: true, length: 0 } + sort_field: { type: integer, nullable: false, options: { default: '1' } } + sort_order: { type: integer, nullable: false, options: { default: '1' } } + url_alias_name: { type: string, nullable: true, length: 255 } + ibexa_content_type_field_definition: + indexes: + ibexa_content_type_field_definition_ctid: { fields: [content_type_id] } + ibexa_content_type_field_definition_dts: { fields: [data_type_string] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + status: { type: integer, nullable: false, options: { default: '0' } } + fields: + can_translate: { type: integer, nullable: true, options: { default: '1' } } + category: { type: string, nullable: false, length: 25, options: { default: '' } } + content_type_id: { type: integer, nullable: false, options: { default: '0' } } + data_float1: { type: float, nullable: true, length: 0 } + data_float2: { type: float, nullable: true, length: 0 } + data_float3: { type: float, nullable: true, length: 0 } + data_float4: { type: float, nullable: true, length: 0 } + data_int1: { type: integer, nullable: true } + data_int2: { type: integer, nullable: true } + data_int3: { type: integer, nullable: true } + data_int4: { type: integer, nullable: true } + data_text1: { type: string, nullable: true, length: 255 } + data_text2: { type: string, nullable: true, length: 50 } + data_text3: { type: string, nullable: true, length: 50 } + data_text4: { type: string, nullable: true, length: 255 } + data_text5: { type: text, nullable: true, length: 0 } + data_type_string: { type: string, nullable: false, length: 50, options: { default: '' } } + identifier: { type: string, nullable: false, length: 50, options: { default: '' } } + is_information_collector: { type: integer, nullable: false, options: { default: '0' } } + is_required: { type: integer, nullable: false, options: { default: '0' } } + is_searchable: { type: integer, nullable: false, options: { default: '0' } } + is_thumbnail: { type: boolean, nullable: false, options: { default: '0' } } + placement: { type: integer, nullable: false, options: { default: '0' } } + serialized_data_text: { type: text, nullable: true, length: 0 } + serialized_description_list: { type: text, nullable: true, length: 0 } + serialized_name_list: { type: text, nullable: false, length: 0 } + ibexa_content_type_field_definition_ml: + indexes: + ibexa_content_type_field_definition_ml_lang_fk: { fields: [language_id] } + id: + content_type_field_definition_id: { type: integer, nullable: false } + status: { type: integer, nullable: false } + language_id: { type: bigint, nullable: false } + fields: + name: { type: string, nullable: false, length: 255 } + description: { type: text, nullable: true, length: 65535 } + data_text: { type: text, nullable: true, length: 65535 } + data_json: { type: text, nullable: true, length: 65535 } + foreignKeys: + ibexa_content_type_field_definition_ml_lang_fk: { fields: [language_id], foreignTable: ibexa_content_language, foreignFields: [id], options: { onDelete: CASCADE, onUpdate: CASCADE } } + ibexa_content_type_group_assignment: + id: + content_type_id: { type: integer, nullable: false, options: { default: '0' } } + content_type_status: { type: integer, nullable: false, options: { default: '0' } } + group_id: { type: integer, nullable: false, options: { default: '0' } } + fields: + group_name: { type: string, nullable: true, length: 255 } + ibexa_content_type_name: + id: + content_type_id: { type: integer, nullable: false, options: { default: '0' } } + content_type_status: { type: integer, nullable: false, options: { default: '0' } } + language_id: { type: bigint, nullable: false, options: { default: '0' } } + fields: + language_locale: { type: string, nullable: false, length: 20, options: { default: '' } } + name: { type: string, nullable: false, length: 255, options: { default: '' } } + ibexa_content_type_group: + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + created: { type: integer, nullable: false, options: { default: '0' } } + creator_id: { type: integer, nullable: false, options: { default: '0' } } + modified: { type: integer, nullable: false, options: { default: '0' } } + modifier_id: { type: integer, nullable: false, options: { default: '0' } } + name: { type: string, nullable: true, length: 255 } + is_system: { type: boolean, nullable: false, options: { default: '0' } } + ibexa_content: + indexes: + ibexa_content_type_id: { fields: [content_type_id] } + ibexa_content_lmask: { fields: [language_mask] } + ibexa_content_pub: { fields: [published] } + ibexa_content_section: { fields: [section_id] } + ibexa_content_currentversion: { fields: [current_version] } + ibexa_content_owner: { fields: [owner_id] } + ibexa_content_status: { fields: [status] } + uniqueConstraints: + ibexa_content_remote_id: { fields: [remote_id] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + content_type_id: { type: integer, nullable: false, options: { default: '0' } } + current_version: { type: integer, nullable: true } + initial_language_id: { type: bigint, nullable: false, options: { default: '0' } } + language_mask: { type: bigint, nullable: false, options: { default: '0' } } + modified: { type: integer, nullable: false, options: { default: '0' } } + name: { type: string, nullable: true, length: 255 } + owner_id: { type: integer, nullable: false, options: { default: '0' } } + published: { type: integer, nullable: false, options: { default: '0' } } + remote_id: { type: string, nullable: true, length: 100 } + section_id: { type: integer, nullable: false, options: { default: '0' } } + status: { type: integer, nullable: true, options: { default: '0' } } + is_hidden: { type: boolean, nullable: false, options: { default: '0' } } + ibexa_content_field: + indexes: + ibexa_content_field_co_id_ver_lang_code: { fields: [contentobject_id, version, language_code] } + ibexa_content_field_field_definition_id: { fields: [content_type_field_definition_id] } + sort_key_string: { fields: [sort_key_string], options: { lengths: ['191'] } } + ibexa_content_field_language_code: { fields: [language_code] } + sort_key_int: { fields: [sort_key_int] } + ibexa_content_field_co_id_ver: { fields: [contentobject_id, version] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + version: { type: integer, nullable: false, options: { default: '0' } } + fields: + attribute_original_id: { type: integer, nullable: true, options: { default: '0' } } + content_type_field_definition_id: { type: integer, nullable: false, options: { default: '0' } } + contentobject_id: { type: integer, nullable: false, options: { default: '0' } } + data_float: { type: float, nullable: true, length: 0 } + data_int: { type: integer, nullable: true } + data_text: { type: text, nullable: true, length: 0 } + data_type_string: { type: string, nullable: true, length: 50, options: { default: '' } } + language_code: { type: string, nullable: false, length: 20, options: { default: '' } } + language_id: { type: bigint, nullable: false, options: { default: '0' } } + sort_key_int: { type: integer, nullable: false, options: { default: '0' } } + sort_key_string: { type: string, nullable: false, length: 255, options: { default: '' } } + ibexa_content_relation: + indexes: + ibexa_content_relation_to_co_id: { fields: [to_contentobject_id] } + ibexa_content_relation_from: { fields: [from_contentobject_id, from_contentobject_version, content_type_field_definition_id] } + ibexa_content_relation_ccfd_id: { fields: [ content_type_field_definition_id ] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + content_type_field_definition_id: { type: integer, nullable: false, options: { default: '0' } } + from_contentobject_id: { type: integer, nullable: false, options: { default: '0' } } + from_contentobject_version: { type: integer, nullable: false, options: { default: '0' } } + relation_type: { type: integer, nullable: false, options: { default: '1' } } + to_contentobject_id: { type: integer, nullable: false, options: { default: '0' } } + ibexa_content_name: + indexes: + ibexa_content_name_lang_id: { fields: [language_id] } + ibexa_content_name_cov_id: { fields: [content_version] } + ibexa_content_name_name: { fields: [name], options: { lengths: ['191'] } } + id: + contentobject_id: { type: integer, nullable: false, options: { default: '0' } } + content_version: { type: integer, nullable: false, options: { default: '0' } } + content_translation: { type: string, nullable: false, length: 20, options: { default: '' } } + fields: + language_id: { type: bigint, nullable: false, options: { default: '0' } } + name: { type: string, nullable: true, length: 255 } + real_translation: { type: string, nullable: true, length: 20 } + ibexa_content_trash: + indexes: + ibexa_content_trash_depth: { fields: [depth] } + ibexa_content_trash_p_node_id: { fields: [parent_node_id] } + ibexa_content_trash_path_ident: { fields: [path_identification_string], options: { lengths: ['50'] } } + ibexa_content_trash_co_id: { fields: [contentobject_id] } + ibexa_content_trash_modified_subnode: { fields: [modified_subnode] } + ibexa_content_trash_path: { fields: [path_string], options: { lengths: ['191'] } } + id: + node_id: { type: integer, nullable: false, options: { default: '0' } } + fields: + contentobject_id: { type: integer, nullable: true } + contentobject_version: { type: integer, nullable: true } + depth: { type: integer, nullable: false, options: { default: '0' } } + is_hidden: { type: integer, nullable: false, options: { default: '0' } } + is_invisible: { type: integer, nullable: false, options: { default: '0' } } + main_node_id: { type: integer, nullable: true } + modified_subnode: { type: integer, nullable: true, options: { default: '0' } } + parent_node_id: { type: integer, nullable: false, options: { default: '0' } } + path_identification_string: { type: text, nullable: true, length: 0 } + path_string: { type: string, nullable: false, length: 255, options: { default: '' } } + priority: { type: integer, nullable: false, options: { default: '0' } } + remote_id: { type: string, nullable: false, length: 100, options: { default: '' } } + sort_field: { type: integer, nullable: true, options: { default: '1' } } + sort_order: { type: integer, nullable: true, options: { default: '1' } } + trashed: { type: integer, nullable: false, options: { default: '0' } } + ibexa_content_version: + indexes: + ibexa_content_version_status: { fields: [status] } + ibexa_content_version_idx_ver: { fields: [contentobject_id, version] } + ibexa_content_version_idx_status: { fields: [contentobject_id, status] } + ibexa_content_version_creator_id: { fields: [creator_id] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + contentobject_id: { type: integer, nullable: true } + created: { type: integer, nullable: false, options: { default: '0' } } + creator_id: { type: integer, nullable: false, options: { default: '0' } } + initial_language_id: { type: bigint, nullable: false, options: { default: '0' } } + language_mask: { type: bigint, nullable: false, options: { default: '0' } } + modified: { type: integer, nullable: false, options: { default: '0' } } + status: { type: integer, nullable: false, options: { default: '0' } } + user_id: { type: integer, nullable: false, options: { default: '0' } } + version: { type: integer, nullable: false, options: { default: '0' } } + workflow_event_pos: { type: integer, nullable: true, options: { default: '0' } } + ibexa_dfs_file: + indexes: + ibexa_dfs_file_name_trunk: { fields: [name_trunk], options: { lengths: ['191'] } } + ibexa_dfs_file_expired_name: { fields: [expired, name], options: { lengths: [null, '191'] } } + ibexa_dfs_file_name: { fields: [name], options: { lengths: ['191'] } } + ibexa_dfs_file_mtime: { fields: [mtime] } + id: + name_hash: { type: string, nullable: false, length: 34, options: { default: '' } } + fields: + name: { type: text, nullable: false, length: 65535 } + name_trunk: { type: text, nullable: false, length: 65535 } + datatype: { type: string, nullable: false, length: 255, options: { default: application/octet-stream } } + scope: { type: string, nullable: false, length: 25, options: { default: '' } } + size: { type: bigint, nullable: false, options: { default: '0', unsigned: true } } + mtime: { type: integer, nullable: false, options: { default: '0' } } + expired: { type: boolean, nullable: false, options: { default: '0' } } + status: { type: boolean, nullable: false, options: { default: '0' } } + ibexa_map_location: + indexes: + ibexa_map_location_latitude_longitude_key: { fields: [latitude, longitude] } + id: + contentobject_attribute_id: { type: integer, nullable: false, options: { default: '0' } } + contentobject_version: { type: integer, nullable: false, options: { default: '0' } } + fields: + latitude: { type: float, nullable: false, length: 0, options: { default: '0' } } + longitude: { type: float, nullable: false, length: 0, options: { default: '0' } } + address: { type: string, nullable: true, length: 150 } + ibexa_image_file: + indexes: + ibexa_image_file_file: { fields: [filepath], options: { lengths: ['191'] } } + ibexa_image_file_coid: { fields: [contentobject_attribute_id] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + contentobject_attribute_id: { type: integer, nullable: false, options: { default: '0' } } + filepath: { type: text, nullable: false, length: 0 } + ibexa_keyword: + indexes: + ibexa_keyword_keyword: { fields: [keyword], options: { lengths: ['191'] } } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + class_id: { type: integer, nullable: false, options: { default: '0' } } + keyword: { type: string, nullable: true, length: 255 } + ibexa_keyword_field_link: + indexes: + ibexa_keyword_field_link_oaid: { fields: [objectattribute_id] } + ibexa_keyword_field_link_kid_oaid: { fields: [keyword_id, objectattribute_id] } + ibexa_keyword_field_link_oaid_ver: { fields: [objectattribute_id, version] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + keyword_id: { type: integer, nullable: false, options: { default: '0' } } + objectattribute_id: { type: integer, nullable: false, options: { default: '0' } } + version: { type: integer, nullable: false, options: { default: '0' } } + ibexa_media: + id: + contentobject_attribute_id: { type: integer, nullable: false, options: { default: '0' } } + version: { type: integer, nullable: false, options: { default: '0' } } + fields: + controls: { type: string, nullable: true, length: 50 } + filename: { type: string, nullable: false, length: 255, options: { default: '' } } + has_controller: { type: integer, nullable: true, options: { default: '0' } } + height: { type: integer, nullable: true } + is_autoplay: { type: integer, nullable: true, options: { default: '0' } } + is_loop: { type: integer, nullable: true, options: { default: '0' } } + mime_type: { type: string, nullable: false, length: 50, options: { default: '' } } + original_filename: { type: string, nullable: false, length: 255, options: { default: '' } } + pluginspage: { type: string, nullable: true, length: 255 } + quality: { type: string, nullable: true, length: 50 } + width: { type: integer, nullable: true } + ibexa_node_assignment: + indexes: + ibexa_node_assignment_is_main: { fields: [is_main] } + ibexa_node_assignment_coid_cov: { fields: [contentobject_id, contentobject_version] } + ibexa_node_assignment_parent_node: { fields: [parent_node] } + ibexa_node_assignment_co_version: { fields: [contentobject_version] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + contentobject_id: { type: integer, nullable: true } + contentobject_version: { type: integer, nullable: true } + from_node_id: { type: integer, nullable: true, options: { default: '0' } } + is_main: { type: integer, nullable: false, options: { default: '0' } } + op_code: { type: integer, nullable: false, options: { default: '0' } } + parent_node: { type: integer, nullable: true } + parent_remote_id: { type: string, nullable: false, length: 100, options: { default: '' } } + remote_id: { type: string, nullable: false, length: 100, options: { default: '0' } } + sort_field: { type: integer, nullable: true, options: { default: '1' } } + sort_order: { type: integer, nullable: true, options: { default: '1' } } + priority: { type: integer, nullable: false, options: { default: '0' } } + is_hidden: { type: integer, nullable: false, options: { default: '0' } } + ibexa_notification: + indexes: + ibexa_notification_owner_is_pending: { fields: [owner_id, is_pending] } + ibexa_notification_owner: { fields: [owner_id] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + owner_id: { type: integer, nullable: false, options: { default: '0' } } + is_pending: { type: boolean, nullable: false, options: { default: '1' } } + type: { type: string, nullable: false, length: 128, options: { default: '' } } + created: { type: integer, nullable: false, options: { default: '0' } } + data: { type: text, nullable: true } + ibexa_package: + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + install_date: { type: integer, nullable: false, options: { default: '0' } } + name: { type: string, nullable: false, length: 100, options: { default: '' } } + version: { type: string, nullable: false, length: 30, options: { default: '0' } } + ibexa_policy: + indexes: + ibexa_policy_role_id: { fields: [role_id] } + ibexa_policy_original_id: { fields: [original_id] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + function_name: { type: string, nullable: true, length: 255 } + module_name: { type: string, nullable: true, length: 255 } + original_id: { type: integer, nullable: false, options: { default: '0' } } + role_id: { type: integer, nullable: true } + ibexa_policy_limitation: + indexes: + ibexa_policy_id: { fields: [policy_id] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + identifier: { type: string, nullable: false, length: 255, options: { default: '' } } + policy_id: { type: integer, nullable: true } + ibexa_policy_limitation_value: + indexes: + ibexa_policy_limit_value_limit_id: { fields: [limitation_id] } + ibexa_policy_limitation_value_val: { fields: [value], options: { lengths: ['191'] } } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + limitation_id: { type: integer, nullable: true } + value: { type: string, nullable: true, length: 255 } + ibexa_user_preference: + indexes: + ibexa_user_preference_user_id_idx: { fields: [user_id, name] } + ibexa_user_preference_name: { fields: [name] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + name: { type: string, nullable: true, length: 100 } + user_id: { type: integer, nullable: false, options: { default: '0' } } + value: { type: text, nullable: true, length: 0 } + ibexa_role: + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + is_new: { type: integer, nullable: false, options: { default: '0' } } + name: { type: string, nullable: false, length: 255, options: { default: '' } } + value: { type: string, nullable: true, length: 1, options: { fixed: true } } + version: { type: integer, nullable: true, options: { default: '0' } } + ibexa_search_object_word_link: + indexes: + ibexa_search_object_word_link_object: { fields: [contentobject_id] } + ibexa_search_object_word_link_identifier: { fields: [identifier], options: { lengths: ['191'] } } + ibexa_search_object_word_link_integer_value: { fields: [integer_value] } + ibexa_search_object_word_link_word: { fields: [word_id] } + ibexa_search_object_word_link_frequency: { fields: [frequency] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + content_type_field_definition_id: { type: integer, nullable: false, options: { default: '0' } } + content_type_id: { type: integer, nullable: false, options: { default: '0' } } + contentobject_id: { type: integer, nullable: false, options: { default: '0' } } + frequency: { type: float, nullable: false, length: 0, options: { default: '0' } } + identifier: { type: string, nullable: false, length: 255, options: { default: '' } } + integer_value: { type: integer, nullable: false, options: { default: '0' } } + next_word_id: { type: integer, nullable: false, options: { default: '0' } } + placement: { type: integer, nullable: false, options: { default: '0' } } + prev_word_id: { type: integer, nullable: false, options: { default: '0' } } + published: { type: integer, nullable: false, options: { default: '0' } } + section_id: { type: integer, nullable: false, options: { default: '0' } } + word_id: { type: integer, nullable: false, options: { default: '0' } } + language_mask: { type: bigint, nullable: false, options: { default: '0' } } + ibexa_search_word: + indexes: + ibexa_search_word_word_i: { fields: [word] } + ibexa_search_word_obj_count: { fields: [object_count] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + object_count: { type: integer, nullable: false, options: { default: '0' } } + word: { type: string, nullable: true, length: 150 } + ibexa_section: + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + identifier: { type: string, nullable: true, length: 255 } + locale: { type: string, nullable: true, length: 255 } + name: { type: string, nullable: true, length: 255 } + navigation_part_identifier: { type: string, nullable: true, length: 100, options: { default: ezcontentnavigationpart } } + ibexa_site_data: + id: + name: { type: string, nullable: false, length: 60, options: { default: '' } } + fields: + value: { type: text, nullable: false, length: 0 } + ibexa_url: + indexes: + ibexa_url_url: { fields: [url], options: { lengths: ['191'] } } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + created: { type: integer, nullable: false, options: { default: '0' } } + is_valid: { type: integer, nullable: false, options: { default: '1' } } + last_checked: { type: integer, nullable: false, options: { default: '0' } } + modified: { type: integer, nullable: false, options: { default: '0' } } + original_url_md5: { type: string, nullable: false, length: 32, options: { default: '' } } + url: { type: text, nullable: true, length: 0 } + ibexa_url_content_link: + indexes: + ibexa_url_ol_coa_id: { fields: [contentobject_attribute_id] } + ibexa_url_ol_url_id: { fields: [url_id] } + ibexa_url_ol_coa_version: { fields: [contentobject_attribute_version] } + ibexa_url_ol_coa_id_cav: { fields: [contentobject_attribute_id, contentobject_attribute_version] } + fields: + contentobject_attribute_id: { type: integer, nullable: false, options: { default: '0' } } + contentobject_attribute_version: { type: integer, nullable: false, options: { default: '0' } } + url_id: { type: integer, nullable: false, options: { default: '0' } } + ibexa_url_alias: + indexes: + ibexa_url_alias_source_md5: { fields: [source_md5] } + ibexa_url_alias_wcard_fwd: { fields: [is_wildcard, forward_to_id] } + ibexa_url_alias_forward_to_id: { fields: [forward_to_id] } + ibexa_url_alias_imp_wcard_fwd: { fields: [is_imported, is_wildcard, forward_to_id] } + ibexa_url_alias_source_url: { fields: [source_url], options: { lengths: ['191'] } } + ibexa_url_alias_desturl: { fields: [destination_url], options: { lengths: ['191'] } } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + destination_url: { type: text, nullable: false, length: 0 } + forward_to_id: { type: integer, nullable: false, options: { default: '0' } } + is_imported: { type: integer, nullable: false, options: { default: '0' } } + is_internal: { type: integer, nullable: false, options: { default: '1' } } + is_wildcard: { type: integer, nullable: false, options: { default: '0' } } + source_md5: { type: string, nullable: true, length: 32 } + source_url: { type: text, nullable: false, length: 0 } + ibexa_url_alias_ml: + indexes: + ibexa_url_alias_ml_actt_org_al: { fields: [action_type, is_original, is_alias] } + ibexa_url_alias_ml_text_lang: { fields: [text, lang_mask, parent], options: { lengths: ['32', null, null] } } + ibexa_url_alias_ml_par_act_id_lnk: { fields: [action, id, link, parent], options: { lengths: ['32', null, null, null] } } + ibexa_url_alias_ml_par_lnk_txt: { fields: [parent, text, link], options: { lengths: [null, '32', null] } } + ibexa_url_alias_ml_act_org: { fields: [action, is_original], options: { lengths: ['32', null] } } + ibexa_url_alias_ml_text: { fields: [text, id, link], options: { lengths: ['32', null, null] } } + ibexa_url_alias_ml_link: { fields: [link] } + ibexa_url_alias_ml_id: { fields: [id] } + id: + parent: { type: integer, nullable: false, options: { default: '0' } } + text_md5: { type: string, nullable: false, length: 32, options: { default: '' } } + fields: + action: { type: text, nullable: false, length: 0 } + action_type: { type: string, nullable: false, length: 32, options: { default: '' } } + alias_redirects: { type: integer, nullable: false, options: { default: '1' } } + id: { type: integer, nullable: false, options: { default: '0' } } + is_alias: { type: integer, nullable: false, options: { default: '0' } } + is_original: { type: integer, nullable: false, options: { default: '0' } } + lang_mask: { type: bigint, nullable: false, options: { default: '0' } } + link: { type: integer, nullable: false, options: { default: '0' } } + text: { type: text, nullable: false, length: 0 } + ibexa_url_alias_ml_incr: + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + ibexa_url_wildcard: + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + destination_url: { type: text, nullable: false, length: 0 } + source_url: { type: text, nullable: false, length: 0 } + type: { type: integer, nullable: false, options: { default: '0' } } + ibexa_user_accountkey: + indexes: + hash_key: { fields: [hash_key] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + hash_key: { type: string, nullable: false, length: 32, options: { default: '' } } + time: { type: integer, nullable: false, options: { default: '0' } } + user_id: { type: integer, nullable: false, options: { default: '0' } } + ibexa_user_role: + indexes: + ibexa_user_role_role_id: { fields: [role_id] } + ibexa_user_role_contentobject_id: { fields: [contentobject_id] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + contentobject_id: { type: integer, nullable: true } + limit_identifier: { type: string, nullable: true, length: 255, options: { default: '' } } + limit_value: { type: string, nullable: true, length: 255, options: { default: '' } } + role_id: { type: integer, nullable: true } + ibexa_user_setting: + id: + user_id: { type: integer, nullable: false, options: { default: '0' } } + fields: + is_enabled: { type: integer, nullable: false, options: { default: '0' } } + max_login: { type: integer, nullable: true } + ibexa_setting: + indexes: + ibexa_setting_id: { fields: [id] } + uniqueConstraints: + ibexa_setting_group_identifier: { fields: [group, identifier] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + group: { type: string, nullable: false, length: 128 } + identifier: { type: string, nullable: false, length: 128 } + value: { type: json, nullable: false, length: 0 } + ibexa_token_type: + uniqueConstraints: + ibexa_token_type_unique: { fields: [identifier] } + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + identifier: { type: string, nullable: false, length: 64 } + ibexa_token: + uniqueConstraints: + ibexa_token_unique: { fields: [token, identifier, type_id] } + foreignKeys: + ibexa_token_type_id_fk: + fields: [type_id] + foreignTable: ibexa_token_type + foreignFields: [id] + options: + onDelete: CASCADE + id: + id: { type: integer, nullable: false, options: { autoincrement: true } } + fields: + type_id: { type: integer, nullable: false } + token: { type: string, nullable: false, length: 255 } + identifier: { type: string, nullable: true, length: 128 } + created: { type: integer, nullable: false, options: { default: '0' } } + expires: { type: integer, nullable: false, options: { default: '0' } } + revoked: { type: boolean, nullable: false, options: { default: '0' } } diff --git a/tests/integration/Core/BinaryBase/BinaryBaseStorage/BinaryBaseStorageGatewayTest.php b/tests/integration/Core/BinaryBase/BinaryBaseStorage/BinaryBaseStorageGatewayTest.php index a21405b46e..12e2433a7b 100644 --- a/tests/integration/Core/BinaryBase/BinaryBaseStorage/BinaryBaseStorageGatewayTest.php +++ b/tests/integration/Core/BinaryBase/BinaryBaseStorage/BinaryBaseStorageGatewayTest.php @@ -16,6 +16,7 @@ use Ibexa\Contracts\Core\Test\Persistence\Fixture\YamlFixture; use Ibexa\Core\FieldType\BinaryBase\BinaryBaseStorage\Gateway as BinaryBaseStorageGateway; use Ibexa\Core\FieldType\BinaryFile\BinaryFileStorage\Gateway\DoctrineStorage; +use Ibexa\DoctrineSchema\Filter\SchemaAssetsFilterBypass; use Ibexa\Tests\Integration\Core\BaseCoreFieldTypeIntegrationTestCase; /** @@ -27,7 +28,7 @@ protected function setUp(): void { parent::setUp(); - $importer = new FixtureImporter($this->getDatabaseConnection()); + $importer = new FixtureImporter($this->getDatabaseConnection(), new SchemaAssetsFilterBypass()); $importer->import(new YamlFixture(__DIR__ . '/_fixtures/ibexa_binary_file.yaml')); } diff --git a/tests/integration/Core/Repository/ContentService/MaxLanguagesContentServiceTest.php b/tests/integration/Core/Repository/ContentService/MaxLanguagesContentServiceTest.php index 3fb4b8d4aa..f1efdf3899 100644 --- a/tests/integration/Core/Repository/ContentService/MaxLanguagesContentServiceTest.php +++ b/tests/integration/Core/Repository/ContentService/MaxLanguagesContentServiceTest.php @@ -37,6 +37,13 @@ protected function setUp(): void $this->prepareMaxLanguages(); } + /** + * Number of languages to create beyond the fixture's 62 (the old bitmask ceiling, + * 8 * PHP_INT_SIZE - 2 on 64-bit PHP) - proves content creation works with translations in + * languages allocated past where the old power-of-two id scheme would have thrown. + */ + private const EXTRA_LANGUAGES_BEYOND_OLD_LIMIT = 5; + /** * @throws \Ibexa\Contracts\Core\Repository\Exceptions\Exception */ @@ -52,6 +59,11 @@ public function testCreateContent(): void ], self::$languagesRawList )); + + for ($i = 1; $i <= self::EXTRA_LANGUAGES_BEYOND_OLD_LIMIT; ++$i) { + $names["xtr-{$i}"] = "Beyond old limit {$i} name"; + } + $this->createFolder($names); } @@ -69,5 +81,12 @@ private function prepareMaxLanguages(): void $languageCreateStruct->name = $languageData['name']; $languageService->createLanguage($languageCreateStruct); } + + for ($i = 1; $i <= self::EXTRA_LANGUAGES_BEYOND_OLD_LIMIT; ++$i) { + $languageCreateStruct = $languageService->newLanguageCreateStruct(); + $languageCreateStruct->languageCode = "xtr-{$i}"; + $languageCreateStruct->name = "Beyond old limit {$i}"; + $languageService->createLanguage($languageCreateStruct); + } } } diff --git a/tests/integration/Core/Repository/LanguageServiceMaximumSupportedLanguagesTest.php b/tests/integration/Core/Repository/LanguageServiceMaximumSupportedLanguagesTest.php index d4fd5ad626..f7b2baabcc 100644 --- a/tests/integration/Core/Repository/LanguageServiceMaximumSupportedLanguagesTest.php +++ b/tests/integration/Core/Repository/LanguageServiceMaximumSupportedLanguagesTest.php @@ -7,10 +7,9 @@ namespace Ibexa\Tests\Integration\Core\Repository; -use Ibexa\Contracts\Core\Test\Repository\SetupFactory\Legacy as LegacySetupFactory; - /** - * Test case for maximum number of languages supported in the LanguageService. + * Test case proving the language bitmask's old ~62-language ceiling (8 * PHP_INT_SIZE - 2, from + * language ids being powers of two) is gone now that language ids are plain sequential integers. * * @see \Ibexa\Contracts\Core\Repository\LanguageService * @@ -25,41 +24,11 @@ class LanguageServiceMaximumSupportedLanguagesTest extends BaseTestCase /** @var array */ private $createdLanguages = []; - /** - * Creates as much languages as possible. - */ protected function setUp(): void { parent::setUp(); $this->languageService = $this->getRepository()->getContentLanguageService(); - - $languageCreate = $this->languageService->newLanguageCreateStruct(); - $languageCreate->enabled = true; - - // SKIP If using sqlite, PHP 5.3 and 64bit, tests will fail as int column seems to be limited to 32bit on 64bit - if (\PHP_VERSION_ID < 50400 && PHP_INT_SIZE === 8) { - $setupFactory = $this->getSetupFactory(); - if ($setupFactory instanceof LegacySetupFactory && $setupFactory->getDB() === 'sqlite') { - self::markTestSkipped('Skip on Sqlite, PHP 5.3 and 64bit, as int column is limited to 32bit on 64bit'); - } - } - - // Create as much languages as possible - for ($i = count($this->languageService->loadLanguages()) + 1; $i <= 8 * PHP_INT_SIZE - 2; ++$i) { - $languageCreate->name = "Language $i"; - $languageCreate->languageCode = sprintf('lan-%02d', $i); - - try { - $this->createdLanguages[] = $this->languageService->createLanguage($languageCreate); - } catch (\Exception $e) { - if (PHP_INT_SIZE === 8 && $i === 32) { - throw new \Exception('PHP/HHVM is 64bit, but seems INT column in db only supports 32bit', 0, $e); - } - - throw new \Exception("Unknown issue on iteration $i, PHP_INT_SIZE: " . PHP_INT_SIZE, 0, $e); - } - } } protected function tearDown(): void @@ -72,23 +41,29 @@ protected function tearDown(): void } /** - * Test for the number of maximum language that can be created. + * Creates more languages than the old bitmask ceiling (8 * PHP_INT_SIZE - 2, i.e. 62 on 64-bit + * PHP) ever allowed, proving the limit no longer exists. * * @covers \Ibexa\Contracts\Core\Repository\LanguageService::createLanguage() - * - * @depends Ibexa\Tests\Integration\Core\Repository\LanguageServiceTest::testNewLanguageCreateStruct */ - public function testCreateMaximumLanguageLimit() + public function testCreateMoreLanguagesThanOldBitmaskLimit(): void { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Maximum number of languages reached.'); + $existingLanguageCount = count($this->languageService->loadLanguages()); + $countToCreate = (8 * PHP_INT_SIZE - 2) - $existingLanguageCount + 10; $languageCreate = $this->languageService->newLanguageCreateStruct(); $languageCreate->enabled = true; - $languageCreate->name = 'Bad Language'; - $languageCreate->languageCode = 'lan-ER'; + for ($i = 1; $i <= $countToCreate; ++$i) { + $languageCreate->name = "Language $i"; + $languageCreate->languageCode = sprintf('lan-%03d', $i); + + $this->createdLanguages[] = $this->languageService->createLanguage($languageCreate); + } - $this->languageService->createLanguage($languageCreate); + self::assertCount( + $existingLanguageCount + $countToCreate, + $this->languageService->loadLanguages() + ); } } diff --git a/tests/integration/Core/Repository/URLAliasServiceTest.php b/tests/integration/Core/Repository/URLAliasServiceTest.php index 5ef2d1806a..ff6f076785 100644 --- a/tests/integration/Core/Repository/URLAliasServiceTest.php +++ b/tests/integration/Core/Repository/URLAliasServiceTest.php @@ -1762,7 +1762,6 @@ private function insertBrokenUrlAliasTableFixtures(Connection $connection): int 'id' => 9997, 'is_alias' => 0, 'is_original' => 1, - 'lang_mask' => 3, 'link' => 9997, 'parent' => 0, 'text' => 'my-location', @@ -1776,7 +1775,6 @@ private function insertBrokenUrlAliasTableFixtures(Connection $connection): int 'id' => 9998, 'is_alias' => 1, 'is_original' => 1, - 'lang_mask' => 2, 'link' => 9995, 'parent' => 0, 'text' => 'my-alias1', @@ -1790,7 +1788,6 @@ private function insertBrokenUrlAliasTableFixtures(Connection $connection): int 'id' => 9999, 'is_alias' => 0, 'is_original' => 1, - 'lang_mask' => 3, 'link' => 9999, 'parent' => 9995, 'text' => 'my-alias2', diff --git a/tests/integration/Core/Repository/_fixtures/Legacy/data/test_data.yaml b/tests/integration/Core/Repository/_fixtures/Legacy/data/test_data.yaml index 2c80bac8a4..f071fccb1a 100644 --- a/tests/integration/Core/Repository/_fixtures/Legacy/data/test_data.yaml +++ b/tests/integration/Core/Repository/_fixtures/Legacy/data/test_data.yaml @@ -28,7 +28,7 @@ ibexa_object_state_link: - { contentobject_id: 57, contentobject_state_id: 1 } - { contentobject_id: 58, contentobject_state_id: 1 } - { contentobject_id: 59, contentobject_state_id: 1 } -ibexa_content_language: +ibexa_language: - { disabled: 0, id: 2, locale: eng-US, name: 'English (American)' } - { disabled: 0, id: 4, locale: ger-DE, name: German } - { disabled: 0, id: 8, locale: eng-GB, name: 'English (United Kingdom)' } @@ -214,26 +214,26 @@ ibexa_content_type_name: - { content_type_id: 13, content_type_status: 0, language_id: 3, language_locale: eng-US, name: Comment } - { content_type_id: 14, content_type_status: 0, language_id: 3, language_locale: eng-US, name: 'Common ini settings' } - { content_type_id: 15, content_type_status: 0, language_id: 3, language_locale: eng-US, name: 'Template look' } - - { content_type_id: 16, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Article } - - { content_type_id: 17, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Blog } - - { content_type_id: 18, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: 'Blog post' } - - { content_type_id: 19, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Product } - - { content_type_id: 20, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: 'Feedback form' } - - { content_type_id: 21, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: 'Landing Page' } - - { content_type_id: 22, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: 'Wiki Page' } - - { content_type_id: 23, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Poll } - - { content_type_id: 24, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: File } - - { content_type_id: 25, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Image } - - { content_type_id: 26, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Link } - - { content_type_id: 27, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Gallery } - - { content_type_id: 28, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Forum } - - { content_type_id: 29, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: 'Forum topic' } - - { content_type_id: 30, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: 'Forum reply' } - - { content_type_id: 31, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Event } - - { content_type_id: 32, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: 'Event calendar' } - - { content_type_id: 33, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Banner } - - { content_type_id: 34, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Forums } - - { content_type_id: 35, content_type_status: 0, language_id: 9, language_locale: eng-GB, name: Video } + - { content_type_id: 16, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Article } + - { content_type_id: 17, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Blog } + - { content_type_id: 18, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: 'Blog post' } + - { content_type_id: 19, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Product } + - { content_type_id: 20, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: 'Feedback form' } + - { content_type_id: 21, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: 'Landing Page' } + - { content_type_id: 22, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: 'Wiki Page' } + - { content_type_id: 23, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Poll } + - { content_type_id: 24, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: File } + - { content_type_id: 25, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Image } + - { content_type_id: 26, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Link } + - { content_type_id: 27, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Gallery } + - { content_type_id: 28, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Forum } + - { content_type_id: 29, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: 'Forum topic' } + - { content_type_id: 30, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: 'Forum reply' } + - { content_type_id: 31, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Event } + - { content_type_id: 32, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: 'Event calendar' } + - { content_type_id: 33, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Banner } + - { content_type_id: 34, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Forums } + - { content_type_id: 35, content_type_status: 0, language_id: 8, language_locale: eng-GB, name: Video } ibexa_content_type_group: - { created: 1031216928, creator_id: 14, id: 1, modified: 1033922106, modifier_id: 14, name: Content, is_system: 0 } - { created: 1031216941, creator_id: 14, id: 2, modified: 1033922113, modifier_id: 14, name: Users, is_system: 0 } @@ -324,7 +324,7 @@ ibexa_content_field: - { attribute_original_id: 0, content_type_field_definition_id: 4, contentobject_id: 56, data_float: 0, data_int: null, data_text: Design, data_type_string: ibexa_string, id: 181, language_code: eng-US, language_id: 3, sort_key_int: 0, sort_key_string: design, version: 1 } - { attribute_original_id: 0, content_type_field_definition_id: 155, contentobject_id: 56, data_float: 0, data_int: null, data_text: '', data_type_string: ibexa_string, id: 182, language_code: eng-US, language_id: 3, sort_key_int: 0, sort_key_string: '', version: 1 } - { attribute_original_id: 0, content_type_field_definition_id: 158, contentobject_id: 56, data_float: 0, data_int: 1, data_text: '', data_type_string: ibexa_boolean, id: 185, language_code: eng-US, language_id: 3, sort_key_int: 1, sort_key_string: '', version: 1 } - - { attribute_original_id: 0, content_type_field_definition_id: 219, contentobject_id: 57, data_float: 0, data_int: null, data_text: Home, data_type_string: ibexa_string, id: 186, language_code: eng-GB, language_id: 9, sort_key_int: 0, sort_key_string: home, version: 1 } + - { attribute_original_id: 0, content_type_field_definition_id: 219, contentobject_id: 57, data_float: 0, data_int: null, data_text: Home, data_type_string: ibexa_string, id: 186, language_code: eng-GB, language_id: 8, sort_key_int: 0, sort_key_string: home, version: 1 } - { attribute_original_id: 0, content_type_field_definition_id: 212, contentobject_id: 58, data_float: 0, data_int: null, data_text: 'Contact Us', data_type_string: ibexa_string, id: 188, language_code: eng-GB, language_id: 8, sort_key_int: 0, sort_key_string: 'contact us', version: 1 } - { attribute_original_id: 0, content_type_field_definition_id: 214, contentobject_id: 58, data_float: 0, data_int: null, data_text: 'Firstname Lastname', data_type_string: ibexa_string, id: 190, language_code: eng-GB, language_id: 8, sort_key_int: 0, sort_key_string: 'firstname lastname', version: 1 } - { attribute_original_id: 0, content_type_field_definition_id: 215, contentobject_id: 58, data_float: 0, data_int: null, data_text: Subject, data_type_string: ibexa_string, id: 191, language_code: eng-GB, language_id: 8, sort_key_int: 0, sort_key_string: subject, version: 1 } @@ -370,7 +370,7 @@ ibexa_content_name: - { content_translation: eng-US, content_version: 1, contentobject_id: 52, language_id: 2, name: 'Common INI settings', real_translation: eng-US } - { content_translation: eng-US, content_version: 2, contentobject_id: 54, language_id: 2, name: 'Ibexa Demo Design (without demo content)', real_translation: eng-US } - { content_translation: eng-US, content_version: 1, contentobject_id: 56, language_id: 3, name: Design, real_translation: eng-US } - - { content_translation: eng-GB, content_version: 1, contentobject_id: 57, language_id: 9, name: Home, real_translation: eng-GB } + - { content_translation: eng-GB, content_version: 1, contentobject_id: 57, language_id: 8, name: Home, real_translation: eng-GB } - { content_translation: eng-GB, content_version: 1, contentobject_id: 58, language_id: 8, name: 'Contact Us', real_translation: eng-GB } - { content_translation: eng-US, content_version: 1, contentobject_id: 59, language_id: 3, name: Partners, real_translation: eng-US } ibexa_content_trash: { } diff --git a/tests/integration/Core/User/UserStorage/UserStorageGatewayTestCase.php b/tests/integration/Core/User/UserStorage/UserStorageGatewayTestCase.php index e2a47c5186..fb9b7e672d 100644 --- a/tests/integration/Core/User/UserStorage/UserStorageGatewayTestCase.php +++ b/tests/integration/Core/User/UserStorage/UserStorageGatewayTestCase.php @@ -11,6 +11,7 @@ use Ibexa\Contracts\Core\Test\Persistence\Fixture\YamlFixture; use Ibexa\Core\FieldType\User\UserStorage\Gateway; use Ibexa\Core\Repository\Values\User\User; +use Ibexa\DoctrineSchema\Filter\SchemaAssetsFilterBypass; use Ibexa\Tests\Integration\Core\BaseCoreFieldTypeIntegrationTestCase; /** @@ -72,7 +73,7 @@ public function testCountUsersWithUnsupportedHashType( ?string $fixtureFilePath ): void { if (null !== $fixtureFilePath) { - $importer = new FixtureImporter($this->getDatabaseConnection()); + $importer = new FixtureImporter($this->getDatabaseConnection(), new SchemaAssetsFilterBypass()); $importer->import(new YamlFixture($fixtureFilePath)); } diff --git a/tests/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabaseTest.php b/tests/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabaseTest.php index dbf60215c9..80d5311c14 100644 --- a/tests/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabaseTest.php +++ b/tests/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabaseTest.php @@ -35,6 +35,29 @@ class DoctrineDatabaseTest extends LanguageAwareTestCase */ protected $databaseGateway; + /** + * None of this file's fixtures populate "ibexa_language", but insertContentObject()/ + * insertVersion()/updateVersion() now also write to "ibexa_content_translation"/ + * "ibexa_content_version_translation", which FK-reference it - seed the same 3 languages + * LanguageHandlerMock already pretends exist, so those inserts don't violate the constraint. + */ + protected function setUp(): void + { + parent::setUp(); + + foreach ($this->getLanguageHandler()->loadAll() as $language) { + $this->getDatabaseConnection()->insert( + 'ibexa_language', + [ + 'id' => $language->id, + 'locale' => $language->languageCode, + 'name' => $language->name, + 'disabled' => 0, + ] + ); + } + } + /** * @todo Fix not available fields */ @@ -55,7 +78,7 @@ public function testInsertContentObject() 'current_version' => '1', 'initial_language_id' => '2', 'remote_id' => 'some_remote_id', - 'language_mask' => '3', + 'always_available' => '1', 'modified' => '0', 'published' => '0', 'status' => ContentInfo::STATUS_DRAFT, @@ -71,7 +94,7 @@ public function testInsertContentObject() 'current_version', 'initial_language_id', 'remote_id', - 'language_mask', + 'always_available', 'modified', 'published', 'status' @@ -179,7 +202,7 @@ public function testInsertVersion() 'status' => '0', 'workflow_event_pos' => '0', 'version' => '1', - 'language_mask' => '5', + 'always_available' => '1', 'initial_language_id' => '4', // Not needed, according to field mapping document // 'user_id', @@ -195,7 +218,7 @@ public function testInsertVersion() 'status', 'workflow_event_pos', 'version', - 'language_mask', + 'always_available', 'initial_language_id' )->from(Gateway::CONTENT_VERSION_TABLE) ); @@ -454,7 +477,7 @@ public function testInsertNewAlwaysAvailableField() 'data_text' => 'Test text', 'data_type_string' => 'ibexa_string', 'language_code' => self::ENG_GB, - 'language_id' => '5', + 'language_id' => '4', 'sort_key_int' => '23', 'sort_key_string' => 'Test', 'version' => '1', @@ -1427,56 +1450,12 @@ public function testUpdateAlwaysAvailableFlagRemove(): void $gateway->updateAlwaysAvailableFlag(103, false); $connection = $this->getDatabaseConnection(); - $query = $connection->createQueryBuilder(); $this->assertQueryResult( - [['id' => 2]], - $query - ->select('language_mask') + [['always_available' => 0]], + $connection->createQueryBuilder() + ->select('always_available') ->from(Gateway::CONTENT_ITEM_TABLE) - ->where( - $query->expr()->eq( - 'id', - $query->createPositionalParameter(103, ParameterType::INTEGER) - ) - ) - ); - - $query = $connection->createQueryBuilder(); - $this->assertQueryResult( - [['language_id' => 2]], - $query - ->select( - 'language_id' - )->from( - Gateway::CONTENT_NAME_TABLE - )->where( - $query->expr()->and( - $query->expr()->eq( - 'contentobject_id', - $query->createPositionalParameter(103, ParameterType::INTEGER) - ), - $query->expr()->eq( - 'content_version', - $query->createPositionalParameter(1, ParameterType::INTEGER) - ) - ) - ) - ); - - $query = $connection->createQueryBuilder(); - $this->assertQueryResult( - [ - ['language_id' => 2], - ], - $query - ->select('DISTINCT language_id') - ->from(Gateway::CONTENT_FIELD_TABLE) - ->where( - $query->expr()->and( - $query->expr()->eq('contentobject_id', 103), - $query->expr()->eq('version', 1) - ) - ) + ->where('id = 103') ); } @@ -1494,59 +1473,13 @@ public function testUpdateAlwaysAvailableFlagAdd(): void $gateway->updateAlwaysAvailableFlag($contentId, true); $connection = $this->getDatabaseConnection(); - $expectedLanguageId = 3; $this->assertQueryResult( - [['id' => $expectedLanguageId]], + [['always_available' => 1]], $connection->createQueryBuilder() - ->select('language_mask') + ->select('always_available') ->from(Gateway::CONTENT_ITEM_TABLE) ->where('id = 102') ); - - $versionNo = 1; - $query = $this->getDatabaseConnection()->createQueryBuilder(); - $this->assertQueryResult( - [ - ['language_id' => $expectedLanguageId], - ], - $query - ->select('language_id') - ->from(Gateway::CONTENT_NAME_TABLE) - ->where( - $query->expr()->and( - $query->expr()->eq( - 'contentobject_id', - $query->createPositionalParameter($contentId, ParameterType::INTEGER) - ), - $query->expr()->eq( - 'content_version', - $query->createPositionalParameter($versionNo, ParameterType::INTEGER) - ) - ) - ) - ); - - $query = $this->getDatabaseConnection()->createQueryBuilder(); - $this->assertQueryResult( - [ - ['language_id' => $expectedLanguageId], - ], - $query - ->select('DISTINCT language_id') - ->from(Gateway::CONTENT_FIELD_TABLE) - ->where( - $query->expr()->and( - $query->expr()->eq( - 'contentobject_id', - $query->createPositionalParameter($contentId, ParameterType::INTEGER) - ), - $query->expr()->eq( - 'version', - $query->createPositionalParameter($versionNo, ParameterType::INTEGER) - ) - ) - ) - ); } /** @@ -1569,9 +1502,9 @@ public function testUpdateContentAddAlwaysAvailableFlagMultilingual(): void $gateway->updateContent(4, $contentMetadataUpdateStruct); $this->assertQueryResult( - [['id' => 7]], + [['always_available' => 1]], $this->getDatabaseConnection()->createQueryBuilder()->select( - 'language_mask' + 'always_available' )->from( Gateway::CONTENT_ITEM_TABLE )->where( @@ -1579,12 +1512,14 @@ public function testUpdateContentAddAlwaysAvailableFlagMultilingual(): void ) ); + // ibexa_content_field.language_id is no longer touched by an always-available cascade - + // it retains its original (fixture) values, unchanged, for both versions. $this->assertContentVersionAttributesLanguages( 4, 2, [ - ['id' => '7', 'language_id' => 2], - ['id' => '8', 'language_id' => 5], + ['id' => '7', 'language_id' => 3], + ['id' => '8', 'language_id' => 4], ] ); @@ -1618,9 +1553,9 @@ public function testUpdateContentRemoveAlwaysAvailableFlagMultilingual(): void $gateway->updateContent(4, $contentMetadataUpdateStruct); $this->assertQueryResult( - [['id' => 6]], + [['always_available' => 0]], $this->getDatabaseConnection()->createQueryBuilder()->select( - 'language_mask' + 'always_available' )->from( Gateway::CONTENT_ITEM_TABLE )->where( @@ -1628,11 +1563,13 @@ public function testUpdateContentRemoveAlwaysAvailableFlagMultilingual(): void ) ); + // ibexa_content_field.language_id is no longer touched by an always-available cascade - + // it retains its original (fixture) values, unchanged, for both versions. $this->assertContentVersionAttributesLanguages( 4, 2, [ - ['id' => '7', 'language_id' => 2], + ['id' => '7', 'language_id' => 3], ['id' => '8', 'language_id' => 4], ] ); @@ -1885,8 +1822,7 @@ protected function getDatabaseGateway(): DoctrineDatabase $connection, $this->getSharedGateway(), new DoctrineDatabase\QueryBuilder($connection), - $this->getLanguageHandler(), - $this->getLanguageMaskGenerator() + $this->getLanguageHandler() ); } diff --git a/tests/lib/Persistence/Legacy/Content/Language/Gateway/DoctrineDatabaseTest.php b/tests/lib/Persistence/Legacy/Content/Language/Gateway/DoctrineDatabaseTest.php index 6e1a1dc9ff..8af9f07f41 100644 --- a/tests/lib/Persistence/Legacy/Content/Language/Gateway/DoctrineDatabaseTest.php +++ b/tests/lib/Persistence/Legacy/Content/Language/Gateway/DoctrineDatabaseTest.php @@ -45,7 +45,7 @@ public function testInsertLanguage() $this->assertQueryResult( [ [ - 'id' => '8', + 'id' => '5', 'locale' => 'de-DE', 'name' => 'Deutsch (Deutschland)', 'disabled' => '0', @@ -54,7 +54,7 @@ public function testInsertLanguage() $this->getDatabaseConnection()->createQueryBuilder() ->select('id', 'locale', 'name', 'disabled') ->from(Gateway::CONTENT_LANGUAGE_TABLE) - ->where('id=8') + ->where('id=5') ); } @@ -173,6 +173,50 @@ public function testDeleteLanguage() ); } + public function testCanDeleteUnusedLanguageAdjacentToAnotherInUseLanguage(): void + { + $gateway = $this->getDatabaseGateway(); + $connection = $this->getDatabaseConnection(); + + // Both real, independently-allocated languages (post-migration sequential ids commonly end + // up adjacent like this) - 65 is in use, 64 is not, and must stay deletable regardless. + $connection->insert( + Gateway::CONTENT_LANGUAGE_TABLE, + ['id' => 64, 'locale' => 'fr-FR', 'name' => 'Francais (France)', 'disabled' => 0] + ); + $connection->insert( + Gateway::CONTENT_LANGUAGE_TABLE, + ['id' => 65, 'locale' => 'it-IT', 'name' => 'Italiano (Italia)', 'disabled' => 0] + ); + $connection->insert( + 'ibexa_object_state_language', + ['contentobject_state_id' => 1, 'language_id' => 65, 'description' => '', 'name' => 'x'] + ); + + self::assertTrue($gateway->canDeleteLanguage(64)); + self::assertFalse($gateway->canDeleteLanguage(65)); + } + + public function testCanDeleteLanguageDetectsLegacyAlwaysAvailableTaintedValue(): void + { + $gateway = $this->getDatabaseGateway(); + $connection = $this->getDatabaseConnection(); + + // Only language 64 is real; no language 65 exists. A row carrying value 65 in an indicator + // column can only be legacy data where 64's always-available bit was folded in (64|1 = 65), + // so it must still count as "language 64 is in use". + $connection->insert( + Gateway::CONTENT_LANGUAGE_TABLE, + ['id' => 64, 'locale' => 'fr-FR', 'name' => 'Francais (France)', 'disabled' => 0] + ); + $connection->insert( + 'ibexa_object_state_language', + ['contentobject_state_id' => 1, 'language_id' => 65, 'description' => '', 'name' => 'x'] + ); + + self::assertFalse($gateway->canDeleteLanguage(64)); + } + /** * Return a ready to test DoctrineDatabase gateway. */ diff --git a/tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php b/tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php deleted file mode 100644 index 14c39c2035..0000000000 --- a/tests/lib/Persistence/Legacy/Content/Language/MaskGeneratorTest.php +++ /dev/null @@ -1,298 +0,0 @@ - $languages - * - * @dataProvider getLanguageMaskData - */ - public function testGenerateLanguageMaskFromLanguagesCodes(array $languages, bool $isAlwaysAvailable, int $expectedMask): void - { - $generator = $this->getMaskGenerator(); - - self::assertSame( - $expectedMask, - $generator->generateLanguageMaskFromLanguageCodes(array_keys($languages), $isAlwaysAvailable) - ); - } - - /** - * Returns test data {@link testGenerateLanguageMaskFromLanguagesCodes()}. - * - * @return array, bool, int}> - */ - public static function getLanguageMaskData(): array - { - return [ - 'error' => [ - [], - false, - 0, - ], - 'single_lang' => [ - ['eng-GB' => true], - false, - 4, - ], - 'multi_lang' => [ - ['eng-US' => true, 'eng-GB' => true], - false, - 6, - ], - 'always_available' => [ - ['eng-US' => true], - true, - 3, - ], - 'full' => [ - ['eng-US' => true, 'eng-GB' => true], - true, - 7, - ], - ]; - } - - /** - * @param string $languageCode - * @param bool $alwaysAvailable - * @param int $expectedIndicator - * - * @dataProvider getLanguageIndicatorData - */ - public function testGenerateLanguageIndicator( - $languageCode, - $alwaysAvailable, - $expectedIndicator - ) { - $generator = $this->getMaskGenerator(); - - self::assertSame( - $expectedIndicator, - $generator->generateLanguageIndicator($languageCode, $alwaysAvailable) - ); - } - - /** - * Returns test data for {@link testGenerateLanguageIndicator()}. - * - * @return array - */ - public static function getLanguageIndicatorData() - { - return [ - 'not_available' => [ - 'eng-GB', - false, - 4, - ], - 'always_available' => [ - 'eng-US', - true, - 3, - ], - ]; - } - - public function testIsLanguageAlwaysAvailable() - { - $generator = $this->getMaskGenerator(); - - self::assertTrue( - $generator->isLanguageAlwaysAvailable( - 'eng-GB', - [ - 'always-available' => 'eng-GB', - 'eng-GB' => 'lala', - ] - ) - ); - } - - public function testIsLanguageAlwaysAvailableOtherLanguage() - { - $generator = $this->getMaskGenerator(); - - self::assertFalse( - $generator->isLanguageAlwaysAvailable( - 'eng-GB', - [ - 'always-available' => 'eng-US', - 'eng-GB' => 'lala', - ] - ) - ); - } - - public function testIsLanguageAlwaysAvailableNoDefault() - { - $generator = $this->getMaskGenerator(); - - self::assertFalse( - $generator->isLanguageAlwaysAvailable( - 'eng-GB', - [ - 'eng-GB' => 'lala', - ] - ) - ); - } - - /** - * @param int $langMask - * @param bool $expectedResult - * - * @dataProvider isAlwaysAvailableProvider - */ - public function testIsAlwaysAvailable($langMask, $expectedResult) - { - $generator = $this->getMaskGenerator(); - self::assertSame($expectedResult, $generator->isAlwaysAvailable($langMask)); - } - - /** - * Returns test data for {@link testIsAlwaysAvailable()}. - * - * @return array - */ - public function isAlwaysAvailableProvider() - { - return [ - [2, false], - [3, true], - [62, false], - [14, false], - [15, true], - ]; - } - - /** - * @dataProvider removeAlwaysAvailableFlagProvider - */ - public function testRemoveAlwaysAvailableFlag($langMask, $expectedResult) - { - $generator = $this->getMaskGenerator(); - self::assertSame($expectedResult, $generator->removeAlwaysAvailableFlag($langMask)); - } - - /** - * Returns test data for {@link testRemoveAlwaysAvailableFlag}. - * - * @return array - */ - public function removeAlwaysAvailableFlagProvider() - { - return [ - [3, 2], - [7, 6], - [14, 14], - [62, 62], - ]; - } - - /** - * @param int $langMask - * @param array $expectedResult - * - * @dataProvider languageIdsFromMaskProvider - */ - public function testExtractLanguageIdsFromMask($langMask, array $expectedResult) - { - $generator = $this->getMaskGenerator(); - self::assertSame($expectedResult, $generator->extractLanguageIdsFromMask($langMask)); - } - - /** - * Returns test data for {@link testExtractLanguageIdsFromMask}. - * - * @return array - */ - public function languageIdsFromMaskProvider() - { - return [ - [ - 2, - [2], - ], - [ - 15, - [2, 4, 8], - ], - [ - 62, - [2, 4, 8, 16, 32], - ], - ]; - } - - /** - * Returns the mask generator to test. - * - * @return \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator - */ - protected function getMaskGenerator() - { - return new MaskGenerator($this->getLanguageHandler()); - } - - /** - * Returns a language handler mock. - * - * @return \Ibexa\Core\Persistence\Legacy\Content\Language\Handler - */ - protected function getLanguageHandler() - { - if (!isset($this->languageHandler)) { - $this->languageHandler = $this->createMock(LanguageHandler::class); - $this->languageHandler->expects(self::any()) - ->method(self::anything())// loadByLanguageCode && loadListByLanguageCodes - ->will( - self::returnCallback( - static function ($languageCodes) { - if (is_string($languageCodes)) { - $language = $languageCodes; - $languageCodes = [$language]; - } - - $languages = []; - if (in_array('eng-US', $languageCodes, true)) { - $languages['eng-US'] = new Language( - [ - 'id' => 2, - 'languageCode' => 'eng-US', - 'name' => 'US english', - ] - ); - } - - if (in_array('eng-GB', $languageCodes, true)) { - $languages['eng-GB'] = new Language( - [ - 'id' => 4, - 'languageCode' => 'eng-GB', - 'name' => 'British english', - ] - ); - } - - return isset($language) ? $languages[$language] : $languages; - } - ) - ); - } - - return $this->languageHandler; - } -} diff --git a/tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php b/tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php index 0c0c1b792f..2c57fad6ba 100644 --- a/tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php +++ b/tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php @@ -8,7 +8,6 @@ namespace Ibexa\Tests\Core\Persistence\Legacy\Content; use Ibexa\Core\Persistence; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\CriterionVisitor\CriterionVisitor; use Ibexa\Core\Search\Common\FieldNameGenerator; use Ibexa\Core\Search\Common\FieldRegistry; @@ -29,13 +28,6 @@ abstract class LanguageAwareTestCase extends TestCase */ protected $languageHandler; - /** - * Language mask generator. - * - * @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator - */ - protected $languageMaskGenerator; - protected CriterionVisitor $criterionVisitor; /** @@ -52,22 +44,6 @@ protected function getLanguageHandler() return $this->languageHandler; } - /** - * Returns a language mask generator. - * - * @return \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator - */ - protected function getLanguageMaskGenerator() - { - if (!isset($this->languageMaskGenerator)) { - $this->languageMaskGenerator = new LanguageMaskGenerator( - $this->getLanguageHandler() - ); - } - - return $this->languageMaskGenerator; - } - protected function getCriterionVisitor(): CriterionVisitor { if (!isset($this->criterionVisitor)) { diff --git a/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTest.php b/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTest.php index 7864b1a976..930d7ffa0f 100644 --- a/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTest.php +++ b/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTest.php @@ -28,7 +28,7 @@ protected function getLocationGateway() { return new DoctrineDatabase( $this->getDatabaseConnection(), - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $this->getTrashCriteriaConverterDependency(), $this->getTrashSortClauseConverterDependency(), $this->getLimitedCountQueryBuilderDependency() @@ -106,6 +106,42 @@ public function testLoadInvalidLocation() $gateway->getBasicNodeData(1337); } + public function testLoadLocationFiltersByTranslationTable(): void + { + // LanguageHandlerMock resolves "eng-GB" to id 4. + $this->insertDatabaseFixture(__DIR__ . '/_fixtures/full_example_tree.php'); + $connection = $this->getDatabaseConnection(); + $connection->insert('ibexa_language', [ + 'id' => 4, + 'locale' => 'eng-GB', + 'name' => 'British english', + 'disabled' => 0, + ]); + $connection->insert('ibexa_content_translation', ['content_id' => 75, 'language_id' => 4]); + + $gateway = $this->getLocationGateway(); + + $data = $gateway->getBasicNodeData(77, ['eng-GB'], false); + self::assertLoadLocationProperties($data); + } + + public function testLoadLocationFiltersOutContentMissingFromTranslationTable(): void + { + $this->insertDatabaseFixture(__DIR__ . '/_fixtures/full_example_tree.php'); + $connection = $this->getDatabaseConnection(); + $connection->insert('ibexa_language', [ + 'id' => 4, + 'locale' => 'eng-GB', + 'name' => 'British english', + 'disabled' => 0, + ]); + // No matching row in ibexa_content_translation, and content 75 is not always-available. + + $this->expectException(NotFoundException::class); + + $this->getLocationGateway()->getBasicNodeData(77, ['eng-GB'], false); + } + public function testLoadLocationDataByContent() { $this->insertDatabaseFixture(__DIR__ . '/_fixtures/full_example_tree.php'); diff --git a/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTrashTest.php b/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTrashTest.php index e73158b309..168e97cb09 100644 --- a/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTrashTest.php +++ b/tests/lib/Persistence/Legacy/Content/Location/Gateway/DoctrineDatabaseTrashTest.php @@ -24,7 +24,7 @@ protected function getLocationGateway() { return new DoctrineDatabase( $this->getDatabaseConnection(), - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $this->getTrashCriteriaConverterDependency(), $this->getTrashSortClauseConverterDependency(), $this->getLimitedCountQueryBuilderDependency(), diff --git a/tests/lib/Persistence/Legacy/Content/MapperTest.php b/tests/lib/Persistence/Legacy/Content/MapperTest.php index 076136a45b..59c70da2d7 100644 --- a/tests/lib/Persistence/Legacy/Content/MapperTest.php +++ b/tests/lib/Persistence/Legacy/Content/MapperTest.php @@ -25,6 +25,7 @@ use Ibexa\Core\Persistence\Legacy\Content\FieldValue\Converter; use Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry as Registry; use Ibexa\Core\Persistence\Legacy\Content\Gateway; +use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway as LanguageGateway; use Ibexa\Core\Persistence\Legacy\Content\Mapper; use Ibexa\Core\Persistence\Legacy\Content\Mapper\ResolveVirtualFieldSubscriber; use Ibexa\Core\Persistence\Legacy\Content\StorageFieldValue; @@ -155,6 +156,7 @@ public function testConvertToStorageValue() $this->getContentTypeHandler(), $this->getEventDispatcher(), $this->getFieldTypeAliasResolver(), + $this->getLanguageGatewayStub(), ); $res = $mapper->convertToStorageValue($field); @@ -181,7 +183,7 @@ public function testExtractContentFromRows() 'ibexa_image', 'ibexa_datetime', 'ibexa_keyword', - ], count($rowsFixture) - 1); + ], null); $mapper = new Mapper( $reg, @@ -189,6 +191,7 @@ public function testExtractContentFromRows() $contentTypeHandlerMock, $this->getEventDispatcher(), $this->getFieldTypeAliasResolver(), + $this->getLanguageGatewayStub($rowsFixture), ); $result = $mapper->extractContentFromRows($rowsFixture, $nameRowsFixture); @@ -221,7 +224,7 @@ public function testExtractContentFromRowsWithNewFieldDefinitions(): void 'ibexa_datetime', 'ibexa_keyword', 'eznumber', - ], count($rowsFixture) - 1); + ], null); $mapper = new Mapper( $reg, @@ -229,13 +232,25 @@ public function testExtractContentFromRowsWithNewFieldDefinitions(): void $contentTypeHandlerMock, $this->getEventDispatcher(), $this->getFieldTypeAliasResolver(), + $this->getLanguageGatewayStub($rowsFixture), ); $result = $mapper->extractContentFromRows($rowsFixture, $nameRowsFixture); $expectedContent = $this->getContentExtractReference(); + // Virtual "eznumber" fields (no data rows for this field definition at all) are + // synthesized once per recognized language - eng-US right after the real field rows, + // eng-GB at the very end after the eng-GB virtual fields for pre-existing definitions. + array_splice($expectedContent->fields, 9, 0, [ + new Field([ + 'type' => 'eznumber', + 'languageCode' => 'eng-US', + 'value' => new FieldValue(), + 'versionNo' => 2, + ]), + ]); $expectedContent->fields[] = new Field([ 'type' => 'eznumber', - 'languageCode' => 'eng-US', + 'languageCode' => 'eng-GB', 'value' => new FieldValue(), 'versionNo' => 2, ]); @@ -271,7 +286,7 @@ static function (Content\Type\FieldDefinition $fieldDefinition): bool { 'ibexa_image', 'ibexa_datetime', 'ibexa_keyword', - ], count($rowsFixture) - 2); + ], null); $mapper = new Mapper( $reg, @@ -279,6 +294,7 @@ static function (Content\Type\FieldDefinition $fieldDefinition): bool { $contentTypeHandlerMock, $this->getEventDispatcher(), $this->getFieldTypeAliasResolver(), + $this->getLanguageGatewayStub($rowsFixture), ); $result = $mapper->extractContentFromRows($rowsFixture, $nameRowsFixture); @@ -325,6 +341,7 @@ public function testExtractContentFromRowsMultipleVersions() $contentTypeHandlerMock, $this->getEventDispatcher(), $this->getFieldTypeAliasResolver(), + $this->getLanguageGatewayStub($rowsFixture), ); $result = $mapper->extractContentFromRows($rowsFixture, $nameRowsFixture); @@ -498,6 +515,7 @@ public function testExtractContentInfoFromRow(array $fixtures, $prefix) $this->getContentTypeHandler(), $this->getEventDispatcher(), $this->getFieldTypeAliasResolver(), + $this->getLanguageGatewayStub(), ); self::assertEquals($contentInfoReference, $mapper->extractContentInfoFromRow($fixtures, $prefix)); } @@ -658,9 +676,43 @@ protected function getMapper($valueConverter = null) $this->getContentTypeHandler(), $this->getEventDispatcher(), $this->getFieldTypeAliasResolver(), + $this->getLanguageGatewayStub(), ); } + /** + * Builds a Language Gateway stub whose loadVersionTranslations() collects the distinct set of + * real (non-always-available) language ids each version's fields are written in from $rows - + * the relational replacement for decoding "content_version_language_mask". + * + * These fixtures predate "content_field_language_id" always being a pure id - some rows still + * carry the legacy "indicator" encoding (id with the always-available bit folded in), so mask + * it off the same way decoding "content_version_language_mask" used to (implicitly, by never + * testing bit 0). + * + * @param array> $rows + */ + protected function getLanguageGatewayStub(array $rows = [], string $prefix = 'content_'): LanguageGateway + { + $versionLanguageIds = []; + foreach ($rows as $row) { + $versionId = (int)$row["{$prefix}version_id"]; + $languageId = (int)$row["{$prefix}field_language_id"] & ~1; + if (!isset($versionLanguageIds[$versionId])) { + $versionLanguageIds[$versionId] = []; + } + if (!in_array($languageId, $versionLanguageIds[$versionId], true)) { + $versionLanguageIds[$versionId][] = $languageId; + } + } + + $gateway = $this->createMock(LanguageGateway::class); + $gateway->method('loadVersionTranslations')->willReturn($versionLanguageIds); + $gateway->method('loadContentTranslations')->willReturn([]); + + return $gateway; + } + /** * Returns a FieldValue converter registry mock. * diff --git a/tests/lib/Persistence/Legacy/Content/ObjectState/Gateway/DoctrineDatabaseTest.php b/tests/lib/Persistence/Legacy/Content/ObjectState/Gateway/DoctrineDatabaseTest.php index 92ec9e8d05..ca987b8eef 100644 --- a/tests/lib/Persistence/Legacy/Content/ObjectState/Gateway/DoctrineDatabaseTest.php +++ b/tests/lib/Persistence/Legacy/Content/ObjectState/Gateway/DoctrineDatabaseTest.php @@ -25,13 +25,6 @@ class DoctrineDatabaseTest extends LanguageAwareTestCase */ protected $databaseGateway; - /** - * Language mask generator. - * - * @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator - */ - protected $languageMaskGenerator; - /** * Inserts DB fixture. */ @@ -61,7 +54,6 @@ public function testLoadObjectStateData() 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_id' => 1, 'ibexa_object_state_identifier' => 'not_locked', - 'ibexa_object_state_language_mask' => 3, 'ibexa_object_state_priority' => 0, 'ibexa_object_state_language_description' => '', 'ibexa_object_state_language_language_id' => 3, @@ -85,7 +77,6 @@ public function testLoadObjectStateDataByIdentifier() 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_id' => 1, 'ibexa_object_state_identifier' => 'not_locked', - 'ibexa_object_state_language_mask' => 3, 'ibexa_object_state_priority' => 0, 'ibexa_object_state_language_description' => '', 'ibexa_object_state_language_language_id' => 3, @@ -110,7 +101,6 @@ public function testLoadObjectStateListData() 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_id' => 1, 'ibexa_object_state_identifier' => 'not_locked', - 'ibexa_object_state_language_mask' => 3, 'ibexa_object_state_priority' => 0, 'ibexa_object_state_language_description' => '', 'ibexa_object_state_language_language_id' => 3, @@ -123,7 +113,6 @@ public function testLoadObjectStateListData() 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_id' => 2, 'ibexa_object_state_identifier' => 'locked', - 'ibexa_object_state_language_mask' => 3, 'ibexa_object_state_priority' => 1, 'ibexa_object_state_language_description' => '', 'ibexa_object_state_language_language_id' => 3, @@ -147,7 +136,6 @@ public function testLoadObjectStateGroupData() 'ibexa_object_state_group_default_language_id' => 2, 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_group_identifier' => 'ibexa_lock', - 'ibexa_object_state_group_language_mask' => 3, 'ibexa_object_state_group_language_description' => '', 'ibexa_object_state_group_language_language_id' => 3, 'ibexa_object_state_group_language_real_language_id' => 2, @@ -170,7 +158,6 @@ public function testLoadObjectStateGroupDataByIdentifier() 'ibexa_object_state_group_default_language_id' => 2, 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_group_identifier' => 'ibexa_lock', - 'ibexa_object_state_group_language_mask' => 3, 'ibexa_object_state_group_language_description' => '', 'ibexa_object_state_group_language_language_id' => 3, 'ibexa_object_state_group_language_real_language_id' => 2, @@ -194,7 +181,6 @@ public function testLoadObjectStateGroupListData() 'ibexa_object_state_group_default_language_id' => 2, 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_group_identifier' => 'ibexa_lock', - 'ibexa_object_state_group_language_mask' => 3, 'ibexa_object_state_group_language_description' => '', 'ibexa_object_state_group_language_language_id' => 3, 'ibexa_object_state_group_language_real_language_id' => 2, @@ -220,11 +206,10 @@ public function testInsertObjectState() // The new state should be added with state ID = 3 'ibexa_object_state_id' => 3, 'ibexa_object_state_identifier' => 'test_state', - 'ibexa_object_state_language_mask' => 5, // The new state should have priority = 2 'ibexa_object_state_priority' => 2, 'ibexa_object_state_language_description' => 'Test state description', - 'ibexa_object_state_language_language_id' => 5, + 'ibexa_object_state_language_language_id' => 4, 'ibexa_object_state_language_name' => 'Test state', ], ], @@ -249,11 +234,10 @@ public function testInsertObjectStateInEmptyGroup() // The new state should be added with state ID = 3 'ibexa_object_state_id' => 3, 'ibexa_object_state_identifier' => 'test_state', - 'ibexa_object_state_language_mask' => 5, // The new state should have priority = 0 'ibexa_object_state_priority' => 0, 'ibexa_object_state_language_description' => 'Test state description', - 'ibexa_object_state_language_language_id' => 5, + 'ibexa_object_state_language_language_id' => 4, 'ibexa_object_state_language_name' => 'Test state', ], ], @@ -284,10 +268,9 @@ public function testUpdateObjectState() 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_id' => 1, 'ibexa_object_state_identifier' => 'test_state', - 'ibexa_object_state_language_mask' => 5, 'ibexa_object_state_priority' => 0, 'ibexa_object_state_language_description' => 'Test state description', - 'ibexa_object_state_language_language_id' => 5, + 'ibexa_object_state_language_language_id' => 4, 'ibexa_object_state_language_name' => 'Test state', ], ], @@ -339,9 +322,8 @@ public function testInsertObjectStateGroup() // The new state group should be added with state group ID = 3 'ibexa_object_state_group_id' => 3, 'ibexa_object_state_group_identifier' => 'test_group', - 'ibexa_object_state_group_language_mask' => 5, 'ibexa_object_state_group_language_description' => 'Test group description', - 'ibexa_object_state_group_language_language_id' => 5, + 'ibexa_object_state_group_language_language_id' => 4, 'ibexa_object_state_group_language_real_language_id' => 4, 'ibexa_object_state_group_language_name' => 'Test group', ], @@ -366,9 +348,8 @@ public function testUpdateObjectStateGroup() 'ibexa_object_state_group_default_language_id' => 4, 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_group_identifier' => 'test_group', - 'ibexa_object_state_group_language_mask' => 5, 'ibexa_object_state_group_language_description' => 'Test group description', - 'ibexa_object_state_group_language_language_id' => 5, + 'ibexa_object_state_group_language_language_id' => 4, 'ibexa_object_state_group_language_real_language_id' => 4, 'ibexa_object_state_group_language_name' => 'Test group', ], @@ -422,7 +403,6 @@ public function testLoadObjectStateDataForContent() 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_id' => 1, 'ibexa_object_state_identifier' => 'not_locked', - 'ibexa_object_state_language_mask' => 3, 'ibexa_object_state_priority' => 0, 'ibexa_object_state_language_description' => '', 'ibexa_object_state_language_language_id' => 3, @@ -458,7 +438,6 @@ public function testUpdateObjectStatePriority() 'ibexa_object_state_group_id' => 2, 'ibexa_object_state_id' => 1, 'ibexa_object_state_identifier' => 'not_locked', - 'ibexa_object_state_language_mask' => 3, 'ibexa_object_state_priority' => 10, 'ibexa_object_state_language_description' => '', 'ibexa_object_state_language_language_id' => 3, @@ -511,7 +490,7 @@ protected function getDatabaseGateway(): DoctrineDatabase if (!isset($this->databaseGateway)) { $this->databaseGateway = new DoctrineDatabase( $this->getDatabaseConnection(), - $this->getLanguageMaskGenerator() + $this->getLanguageHandler() ); } diff --git a/tests/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabaseTest.php b/tests/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabaseTest.php index af898a6bcd..9844c567e7 100644 --- a/tests/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabaseTest.php +++ b/tests/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabaseTest.php @@ -357,7 +357,7 @@ public function testLoadTypeData() $rows ); self::assertCount( - 50, + 49, $rows[0] ); @@ -385,7 +385,7 @@ public function testLoadTypeDataByIdentifier() $rows ); self::assertCount( - 50, + 49, $rows[0] ); } @@ -404,7 +404,7 @@ public function testLoadTypeDataByRemoteId() $rows ); self::assertCount( - 50, + 49, $rows[0] ); } @@ -424,7 +424,6 @@ public static function getTypeCreationExpectations() ['identifier', 'folder'], ['initial_language_id', '2'], ['is_container', '1'], - ['language_mask', 7], ['modified', '1082454875'], ['modifier_id', '14'], ['remote_id', 'a3d405b81be900468eb153d774f4f0d2'], @@ -465,7 +464,7 @@ public static function getTypeCreationContentClassNameExpectations() { return [ ['content_type_status', [0, 0]], - ['language_id', [3, 4]], + ['language_id', [2, 4]], ['language_locale', ['eng-US', 'eng-GB']], ['name', ['Folder', 'Folder (GB)']], ]; @@ -858,7 +857,7 @@ public function testUpdateTypeName() [ 'content_type_id' => 1, 'content_type_status' => 0, - 'language_id' => 3, + 'language_id' => 2, 'language_locale' => 'eng-US', 'name' => 'New Folder', ], @@ -1153,7 +1152,7 @@ protected function getGateway(): DoctrineDatabase $this->gateway = new DoctrineDatabase( $this->getDatabaseConnection(), $this->getSharedGateway(), - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $this->getCriterionVisitor() ); } diff --git a/tests/lib/Persistence/Legacy/Content/Type/MapperTest.php b/tests/lib/Persistence/Legacy/Content/Type/MapperTest.php index 489113a6d1..7c904b6945 100644 --- a/tests/lib/Persistence/Legacy/Content/Type/MapperTest.php +++ b/tests/lib/Persistence/Legacy/Content/Type/MapperTest.php @@ -7,6 +7,8 @@ namespace Ibexa\Tests\Core\Persistence\Legacy\Content\Type; +use Ibexa\Contracts\Core\Persistence\Content\Language as LanguageValueObject; +use Ibexa\Contracts\Core\Persistence\Content\Language\Handler as LanguageHandler; use Ibexa\Contracts\Core\Persistence\Content\Location; use Ibexa\Contracts\Core\Persistence\Content\Type; use Ibexa\Contracts\Core\Persistence\Content\Type\CreateStruct; @@ -20,8 +22,8 @@ use Ibexa\Core\FieldType\FieldTypeAliasResolverInterface; use Ibexa\Core\Persistence\Legacy\Content\FieldValue\Converter; use Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\StorageFieldDefinition; +use Ibexa\Core\Persistence\Legacy\Content\Type\Gateway; use Ibexa\Core\Persistence\Legacy\Content\Type\Mapper; use Ibexa\Core\Persistence\Legacy\Content\Type\StorageDispatcherInterface; use Ibexa\Tests\Core\Persistence\Legacy\TestCase; @@ -37,7 +39,8 @@ public function testCreateGroupFromCreateStruct() $mapper = new Mapper( $this->getConverterRegistryMock(), - $this->getMaskGeneratorMock(), + $this->getContentTypeGatewayMock(), + $this->getLanguageHandlerMock(), $this->getStorageDispatcherMock(), $this->getFieldTypeAliasResolver(), ); @@ -93,7 +96,8 @@ public function testTypeFromCreateStruct() $mapper = new Mapper( $this->getConverterRegistryMock(), - $this->getMaskGeneratorMock(), + $this->getContentTypeGatewayMock(), + $this->getLanguageHandlerMock(), $this->getStorageDispatcherMock(), $this->getFieldTypeAliasResolver(), ); @@ -114,7 +118,8 @@ public function testTypeFromUpdateStruct() $mapper = new Mapper( $this->getConverterRegistryMock(), - $this->getMaskGeneratorMock(), + $this->getContentTypeGatewayMock(), + $this->getLanguageHandlerMock(), $this->getStorageDispatcherMock(), $this->getFieldTypeAliasResolver(), ); @@ -204,7 +209,8 @@ public function testCreateStructFromType() $mapper = new Mapper( $this->getConverterRegistryMock(), - $this->getMaskGeneratorMock(), + $this->getContentTypeGatewayMock(), + $this->getLanguageHandlerMock(), $this->getStorageDispatcherMock(), $this->getFieldTypeAliasResolver(), ); @@ -272,7 +278,8 @@ public function testExtractGroupsFromRows() $mapper = new Mapper( $this->getConverterRegistryMock(), - $this->getMaskGeneratorMock(), + $this->getContentTypeGatewayMock(), + $this->getLanguageHandlerMock(), $this->getStorageDispatcherMock(), $this->getFieldTypeAliasResolver(), ); @@ -401,7 +408,8 @@ public function testToStorageFieldDefinition() $mapper = new Mapper( $converterRegistry, - $this->getMaskGeneratorMock(), + $this->getContentTypeGatewayMock(), + $this->getLanguageHandlerMock(), $this->getStorageDispatcherMock(), $this->getFieldTypeAliasResolver(), ); @@ -438,7 +446,8 @@ public function testToFieldDefinition() $mapper = new Mapper( $converterRegistry, - $this->getMaskGeneratorMock(), + $this->getContentTypeGatewayMock(), + $this->getLanguageHandlerMock(), $storageDispatcher, $this->getFieldTypeAliasResolver(), ); @@ -457,7 +466,8 @@ protected function getNonConvertingMapper() ->setMethods(['toFieldDefinition']) ->setConstructorArgs([ $this->getConverterRegistryMock(), - $this->getMaskGeneratorMock(), + $this->getContentTypeGatewayMock(), + $this->getLanguageHandlerMock(), $this->getStorageDispatcherMock(), $this->getFieldTypeAliasResolver(), ]) @@ -511,9 +521,22 @@ protected function getLoadGroupFixture() return require __DIR__ . '/_fixtures/map_load_group.php'; } - protected function getMaskGeneratorMock() + protected function getContentTypeGatewayMock(): Gateway { - return $this->createMock(MaskGenerator::class); + return $this->createMock(Gateway::class); + } + + protected function getLanguageHandlerMock(): LanguageHandler + { + $language = new LanguageValueObject(); + $language->id = 2; + $language->languageCode = 'eng-US'; + + $languageHandler = $this->createMock(LanguageHandler::class); + $languageHandler->method('load')->willReturn($language); + $languageHandler->method('loadByLanguageCode')->willReturn($language); + + return $languageHandler; } /** diff --git a/tests/lib/Persistence/Legacy/Content/Type/_fixtures/map_load_type.php b/tests/lib/Persistence/Legacy/Content/Type/_fixtures/map_load_type.php index 5713f0bfa7..e9633b7324 100644 --- a/tests/lib/Persistence/Legacy/Content/Type/_fixtures/map_load_type.php +++ b/tests/lib/Persistence/Legacy/Content/Type/_fixtures/map_load_type.php @@ -16,7 +16,6 @@ 'content_type_identifier' => 'folder', 'content_type_initial_language_id' => '2', 'content_type_is_container' => '1', - 'content_type_language_mask' => '3', 'content_type_remote_id' => 'a3d405b81be900468eb153d774f4f0d2', 'content_type_serialized_description_list' => 'a:2:{i:0;s:0:"";s:16:"always-available";b:0;}', 'content_type_serialized_name_list' => 'a:2:{s:16:"always-available";s:6:"eng-US";s:6:"eng-US";s:6:"Folder";}', @@ -63,7 +62,6 @@ 'content_type_identifier' => 'folder', 'content_type_initial_language_id' => '2', 'content_type_is_container' => '1', - 'content_type_language_mask' => '3', 'content_type_remote_id' => 'a3d405b81be900468eb153d774f4f0d2', 'content_type_serialized_description_list' => 'a:2:{i:0;s:0:"";s:16:"always-available";b:0;}', 'content_type_serialized_name_list' => 'a:2:{s:16:"always-available";s:6:"eng-US";s:6:"eng-US";s:6:"Folder";}', @@ -110,7 +108,6 @@ 'content_type_identifier' => 'folder', 'content_type_initial_language_id' => '2', 'content_type_is_container' => '1', - 'content_type_language_mask' => '3', 'content_type_remote_id' => 'a3d405b81be900468eb153d774f4f0d2', 'content_type_serialized_description_list' => 'a:2:{i:0;s:0:"";s:16:"always-available";b:0;}', 'content_type_serialized_name_list' => 'a:2:{s:16:"always-available";s:6:"eng-US";s:6:"eng-US";s:6:"Folder";}', @@ -157,7 +154,6 @@ 'content_type_identifier' => 'folder', 'content_type_initial_language_id' => '2', 'content_type_is_container' => '1', - 'content_type_language_mask' => '3', 'content_type_remote_id' => 'a3d405b81be900468eb153d774f4f0d2', 'content_type_serialized_description_list' => '', 'content_type_serialized_name_list' => '', diff --git a/tests/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabaseTest.php b/tests/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabaseTest.php index f059eda39a..0eaae6711b 100644 --- a/tests/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabaseTest.php +++ b/tests/lib/Persistence/Legacy/Content/UrlAlias/Gateway/DoctrineDatabaseTest.php @@ -10,7 +10,6 @@ use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway\DoctrineDatabase as LanguageGateway; use Ibexa\Core\Persistence\Legacy\Content\Language\Handler as LanguageHandler; use Ibexa\Core\Persistence\Legacy\Content\Language\Mapper as LanguageMapper; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway\DoctrineDatabase; use Ibexa\Tests\Core\Persistence\Legacy\TestCase; @@ -28,6 +27,30 @@ class DoctrineDatabaseTest extends TestCase */ protected $gateway; + /** + * "ibexa_language" is never seeded by these fixtures (this test suite predates the + * gateway needing real Language rows at all) - FixtureImporter's language-mask backfill (see + * Ibexa\Contracts\Core\Test\Persistence\Fixture\FixtureImporter) needs at least one row per + * language id/bit actually used across the fixtures' "lang_mask" values (1, 2, 4, 8 cover every + * fixture in this directory) or it silently backfills nothing - seed it before loading the + * fixture (not part of the fixture's own table list, so it survives import()'s truncation). + */ + protected function insertDatabaseFixture(string $file): void + { + $connection = $this->getDatabaseConnection(); + // Some tests call insertDatabaseFixture() more than once (e.g. to layer a second fixture) - + // reset first so re-seeding these fixed ids doesn't violate the primary key. + $connection->executeStatement('DELETE FROM ibexa_language'); + foreach ([2, 4, 8, 16] as $languageId) { + $connection->executeStatement( + 'INSERT INTO ibexa_language (id, locale, name, disabled) VALUES (:id, :locale, :name, 0)', + ['id' => $languageId, 'locale' => "lang-{$languageId}", 'name' => "Language {$languageId}"] + ); + } + + parent::insertDatabaseFixture($file); + } + /** * Test for the loadUrlAliasData() method. */ @@ -60,7 +83,6 @@ public function testLoadUrlaliasData() 'ibexa_url_alias_ml0_is_original' => '1', 'ibexa_url_alias_ml0_action' => 'eznode:314', 'ibexa_url_alias_ml0_action_type' => 'eznode', - 'ibexa_url_alias_ml0_lang_mask' => '2', 'ibexa_url_alias_ml0_text' => 'jedan', 'ibexa_url_alias_ml0_parent' => '0', 'ibexa_url_alias_ml0_text_md5' => '6896260129051a949051c3847c34466f', @@ -71,10 +93,11 @@ public function testLoadUrlaliasData() 'is_original' => '1', 'action' => 'eznode:315', 'action_type' => 'eznode', - 'lang_mask' => '3', 'text' => 'dva', 'parent' => '2', 'text_md5' => 'c67ed9a09ab136fae610b6a087d82e21', + 'ibexa_url_alias_ml0_is_always_available' => '0', + 'is_always_available' => '1', ], $row ); @@ -101,7 +124,6 @@ public function testLoadUrlaliasDataMultipleLanguages() 'ibexa_url_alias_ml0_is_original' => '1', 'ibexa_url_alias_ml0_action' => 'eznode:314', 'ibexa_url_alias_ml0_action_type' => 'eznode', - 'ibexa_url_alias_ml0_lang_mask' => '3', 'ibexa_url_alias_ml0_text' => 'jedan', 'ibexa_url_alias_ml0_parent' => '0', 'ibexa_url_alias_ml0_text_md5' => '6896260129051a949051c3847c34466f', @@ -112,10 +134,11 @@ public function testLoadUrlaliasDataMultipleLanguages() 'is_original' => '1', 'action' => 'eznode:315', 'action_type' => 'eznode', - 'lang_mask' => '6', 'text' => 'dva', 'parent' => '2', 'text_md5' => 'c67ed9a09ab136fae610b6a087d82e21', + 'ibexa_url_alias_ml0_is_always_available' => '1', + 'is_always_available' => '0', ], $row ); @@ -131,7 +154,7 @@ public function providerForTestLoadPathData() 2, [ [ - ['parent' => '0', 'lang_mask' => '3', 'text' => 'jedan'], + ['parent' => '0', 'text_md5' => '6896260129051a949051c3847c34466f', 'is_always_available' => true, 'text' => 'jedan'], ], ], ], @@ -139,11 +162,11 @@ public function providerForTestLoadPathData() 3, [ [ - ['parent' => '0', 'lang_mask' => '3', 'text' => 'jedan'], + ['parent' => '0', 'text_md5' => '6896260129051a949051c3847c34466f', 'is_always_available' => true, 'text' => 'jedan'], ], [ - ['parent' => '2', 'lang_mask' => '5', 'text' => 'two'], - ['parent' => '2', 'lang_mask' => '3', 'text' => 'dva'], + ['parent' => '2', 'text_md5' => 'b8a9f715dbb64fd5c56e7783c6820a61', 'is_always_available' => true, 'text' => 'two'], + ['parent' => '2', 'text_md5' => 'c67ed9a09ab136fae610b6a087d82e21', 'is_always_available' => true, 'text' => 'dva'], ], ], ], @@ -151,16 +174,16 @@ public function providerForTestLoadPathData() 4, [ [ - ['parent' => '0', 'lang_mask' => '3', 'text' => 'jedan'], + ['parent' => '0', 'text_md5' => '6896260129051a949051c3847c34466f', 'is_always_available' => true, 'text' => 'jedan'], ], [ - ['parent' => '2', 'lang_mask' => '5', 'text' => 'two'], - ['parent' => '2', 'lang_mask' => '3', 'text' => 'dva'], + ['parent' => '2', 'text_md5' => 'b8a9f715dbb64fd5c56e7783c6820a61', 'is_always_available' => true, 'text' => 'two'], + ['parent' => '2', 'text_md5' => 'c67ed9a09ab136fae610b6a087d82e21', 'is_always_available' => true, 'text' => 'dva'], ], [ - ['parent' => '3', 'lang_mask' => '9', 'text' => 'drei'], - ['parent' => '3', 'lang_mask' => '5', 'text' => 'three'], - ['parent' => '3', 'lang_mask' => '3', 'text' => 'tri'], + ['parent' => '3', 'text_md5' => '1d8d2fd0a99802b89eb356a86e029d25', 'is_always_available' => true, 'text' => 'drei'], + ['parent' => '3', 'text_md5' => '35d6d33467aae9a2e3dccb4b6b027878', 'is_always_available' => true, 'text' => 'three'], + ['parent' => '3', 'text_md5' => 'd2cfe69af2d64330670e08efb2c86df7', 'is_always_available' => true, 'text' => 'tri'], ], ], ], @@ -196,7 +219,7 @@ public function providerForTestLoadPathDataMultipleLanguages() 2, [ [ - ['parent' => '0', 'lang_mask' => '3', 'text' => 'jedan'], + ['parent' => '0', 'text_md5' => '6896260129051a949051c3847c34466f', 'is_always_available' => true, 'text' => 'jedan'], ], ], ], @@ -204,10 +227,10 @@ public function providerForTestLoadPathDataMultipleLanguages() 3, [ [ - ['parent' => '0', 'lang_mask' => '3', 'text' => 'jedan'], + ['parent' => '0', 'text_md5' => '6896260129051a949051c3847c34466f', 'is_always_available' => true, 'text' => 'jedan'], ], [ - ['parent' => '2', 'lang_mask' => '6', 'text' => 'dva'], + ['parent' => '2', 'text_md5' => 'c67ed9a09ab136fae610b6a087d82e21', 'is_always_available' => false, 'text' => 'dva'], ], ], ], @@ -215,14 +238,14 @@ public function providerForTestLoadPathDataMultipleLanguages() 4, [ [ - ['parent' => '0', 'lang_mask' => '3', 'text' => 'jedan'], + ['parent' => '0', 'text_md5' => '6896260129051a949051c3847c34466f', 'is_always_available' => true, 'text' => 'jedan'], ], [ - ['parent' => '2', 'lang_mask' => '6', 'text' => 'dva'], + ['parent' => '2', 'text_md5' => 'c67ed9a09ab136fae610b6a087d82e21', 'is_always_available' => false, 'text' => 'dva'], ], [ - ['parent' => '3', 'lang_mask' => '4', 'text' => 'three'], - ['parent' => '3', 'lang_mask' => '2', 'text' => 'tri'], + ['parent' => '3', 'text_md5' => '35d6d33467aae9a2e3dccb4b6b027878', 'is_always_available' => false, 'text' => 'three'], + ['parent' => '3', 'text_md5' => 'd2cfe69af2d64330670e08efb2c86df7', 'is_always_available' => false, 'text' => 'tri'], ], ], ], @@ -345,13 +368,18 @@ public function testCleanupAfterPublishRemovesLanguage($action, $languageId, $pa $gateway = $this->getGateway(); $loadedRow = $gateway->loadRow($parentId, $textMD5); + $languageIdsBefore = $gateway->loadTranslationLanguageIds($parentId, $textMD5); $gateway->cleanupAfterPublish($action, $languageId, 42, $parentId, 'jabberwocky'); $reloadedRow = $gateway->loadRow($parentId, $textMD5); - $loadedRow['lang_mask'] = $loadedRow['lang_mask'] & ~$languageId; + $languageIdsAfter = $gateway->loadTranslationLanguageIds($parentId, $textMD5); - self::assertEquals($reloadedRow, $loadedRow); + self::assertEquals($loadedRow, $reloadedRow); + self::assertEquals( + array_values(array_diff($languageIdsBefore, [$languageId])), + array_values($languageIdsAfter) + ); } /** @@ -374,11 +402,11 @@ public function testReparent() 'id' => '3', 'is_alias' => '0', 'is_original' => '1', - 'lang_mask' => '3', 'link' => '3', 'parent' => '42', 'text' => 'dva', 'text_md5' => 'c67ed9a09ab136fae610b6a087d82e21', + 'is_always_available' => '1', ], $gateway->loadRow(42, 'c67ed9a09ab136fae610b6a087d82e21') ); @@ -481,14 +509,10 @@ public function testArchiveUrlAliasesForDeletedTranslations($locationId, array $ } // check results - $languageMask = 0; - foreach ($removedLanguageIds as $languageId) { - $languageMask |= $languageId; - } foreach ($gateway->loadLocationEntries($locationId) as $row) { - self::assertNotEquals(0, (int) $row['lang_mask']); - self::assertNotEquals(1, (int) $row['lang_mask']); - self::assertEquals(0, (int) $row['lang_mask'] & $languageMask); + $languageIds = $gateway->loadTranslationLanguageIds((int) $row['parent'], $row['text_md5']); + self::assertNotEmpty($languageIds); + self::assertEmpty(array_intersect($languageIds, $removedLanguageIds)); } } @@ -504,7 +528,7 @@ protected function getGateway(): DoctrineDatabase ); $this->gateway = new DoctrineDatabase( $this->getDatabaseConnection(), - new LanguageMaskGenerator($languageHandler) + $languageHandler ); } diff --git a/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php b/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php index 216cf15741..7f16813a7e 100644 --- a/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php +++ b/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasHandlerTest.php @@ -16,7 +16,6 @@ use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway\DoctrineDatabase as LanguageGateway; use Ibexa\Core\Persistence\Legacy\Content\Language\Handler as LanguageHandler; use Ibexa\Core\Persistence\Legacy\Content\Language\Mapper as LanguageMapper; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\Location\Gateway as LocationGateway; use Ibexa\Core\Persistence\Legacy\Content\Location\Gateway\DoctrineDatabase as DoctrineDatabaseLocation; use Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway as UrlAliasGateway; @@ -5346,9 +5345,6 @@ protected function countRows(): int /** @var \Ibexa\Core\Persistence\Legacy\Content\Language\Handler */ protected $languageHandler; - /** @var \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator */ - protected $languageMaskGenerator; - /** * @param array $methods * @@ -5365,8 +5361,8 @@ protected function getPartlyMockedHandler(array $methods) $this->createMock(LanguageHandler::class), $this->createMock(SlugConverter::class), $this->createMock(Gateway::class), - $this->createMock(LanguageMaskGenerator::class), $this->createMock(TransactionHandler::class), + $this->createMock(\Ibexa\Core\Persistence\Legacy\Content\Language\Gateway::class), ] ) ->setMethods($methods) @@ -5381,20 +5377,18 @@ protected function getPartlyMockedHandler(array $methods) protected function getHandler(): Handler { $languageHandler = $this->getLanguageHandler(); - $languageMaskGenerator = $this->getLanguageMaskGenerator(); $gateway = new DoctrineDatabase( $this->getDatabaseConnection(), - $languageMaskGenerator + $languageHandler ); - $mapper = new Mapper($languageMaskGenerator); + $mapper = new Mapper($gateway, $languageHandler); $slugConverter = new SlugConverter($this->getProcessor()); $connection = $this->getDatabaseConnection(); $contentGateway = new ContentGateway( $connection, $this->getSharedGateway(), new ContentGateway\QueryBuilder($connection), - $languageHandler, - $languageMaskGenerator + $languageHandler ); return new Handler( @@ -5404,8 +5398,8 @@ protected function getHandler(): Handler $languageHandler, $slugConverter, $contentGateway, - $languageMaskGenerator, - $this->createMock(TransactionHandler::class) + $this->createMock(TransactionHandler::class), + new LanguageGateway($this->getDatabaseConnection()) ); } @@ -5423,20 +5417,6 @@ protected function getLanguageHandler(): LanguageHandler return $this->languageHandler; } - /** - * @return \Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator - */ - protected function getLanguageMaskGenerator() - { - if (!isset($this->languageMaskGenerator)) { - $this->languageMaskGenerator = new LanguageMaskGenerator( - $this->getLanguageHandler() - ); - } - - return $this->languageMaskGenerator; - } - /** * @return \Ibexa\Core\Persistence\Legacy\Content\Location\Gateway */ @@ -5445,7 +5425,7 @@ protected function getLocationGateway() if (!isset($this->locationGateway)) { $this->locationGateway = new DoctrineDatabaseLocation( $this->getDatabaseConnection(), - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $this->getTrashCriteriaConverterDependency(), $this->getTrashSortClauseConverterDependency(), $this->getLimitedCountQueryBuilderDependency() diff --git a/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasMapperTest.php b/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasMapperTest.php index 1831d801aa..cd912eaab3 100644 --- a/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasMapperTest.php +++ b/tests/lib/Persistence/Legacy/Content/UrlAlias/UrlAliasMapperTest.php @@ -8,7 +8,7 @@ namespace Ibexa\Tests\Core\Persistence\Legacy\Content\UrlAlias; use Ibexa\Contracts\Core\Persistence\Content\UrlAlias; -use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator; +use Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Gateway; use Ibexa\Core\Persistence\Legacy\Content\UrlAlias\Mapper; use Ibexa\Tests\Core\Persistence\Legacy\Content\LanguageAwareTestCase; @@ -25,22 +25,32 @@ class UrlAliasMapperTest extends LanguageAwareTestCase 'raw_path_data' => [ 0 => [ [ + 'parent' => '100', + 'text_md5' => 'path-root_us', 'lang_mask' => 2, + 'is_always_available' => false, 'text' => 'root_us', ], [ + 'parent' => '100', + 'text_md5' => 'path-root_gb', 'lang_mask' => 4, + 'is_always_available' => false, 'text' => 'root_gb', ], ], 1 => [ [ + 'parent' => '101', + 'text_md5' => 'path-one', 'lang_mask' => 4, + 'is_always_available' => false, 'text' => 'one', ], ], ], 'lang_mask' => 5, + 'is_always_available' => true, 'is_original' => '1', 'is_alias' => '1', 'alias_redirects' => '0', @@ -52,12 +62,16 @@ class UrlAliasMapperTest extends LanguageAwareTestCase 'raw_path_data' => [ 0 => [ [ + 'parent' => '102', + 'text_md5' => 'path-two', 'lang_mask' => 3, + 'is_always_available' => true, 'text' => 'two', ], ], ], 'lang_mask' => 3, + 'is_always_available' => true, 'is_original' => '0', 'is_alias' => '0', 'alias_redirects' => '1', @@ -69,12 +83,16 @@ class UrlAliasMapperTest extends LanguageAwareTestCase 'raw_path_data' => [ 0 => [ [ + 'parent' => '103', + 'text_md5' => 'path-three', 'lang_mask' => 6, + 'is_always_available' => false, 'text' => 'three', ], ], ], 'lang_mask' => 6, + 'is_always_available' => false, 'is_original' => '1', 'is_alias' => '1', 'alias_redirects' => '1', @@ -86,12 +104,16 @@ class UrlAliasMapperTest extends LanguageAwareTestCase 'raw_path_data' => [ 0 => [ [ + 'parent' => '104', + 'text_md5' => 'path-four', 'lang_mask' => 1, + 'is_always_available' => true, 'text' => 'four', ], ], ], 'lang_mask' => 1, + 'is_always_available' => true, 'is_original' => '0', 'is_alias' => '0', 'alias_redirects' => '1', @@ -103,12 +125,16 @@ class UrlAliasMapperTest extends LanguageAwareTestCase 'raw_path_data' => [ 0 => [ [ + 'parent' => '105', + 'text_md5' => 'path-drei', 'lang_mask' => 8, + 'is_always_available' => false, 'text' => 'drei', ], ], ], 'lang_mask' => 8, + 'is_always_available' => false, 'is_original' => '0', 'is_alias' => '0', 'alias_redirects' => '1', @@ -286,8 +312,44 @@ public function testExtractLanguageCodesFromData() protected function getMapper() { $languageHandler = $this->getLanguageHandler(); - $languageMaskGenerator = new LanguageMaskGenerator($languageHandler); - return new Mapper($languageMaskGenerator); + $languageIdsByRow = []; + foreach ($this->fixture as $row) { + $languageIdsByRow[$row['parent'] . ':' . $row['text_md5']] + = $this->extractLanguageIdsFromMask($row['lang_mask']); + foreach ($row['raw_path_data'] as $pathLevel) { + foreach ($pathLevel as $pathRow) { + $languageIdsByRow[$pathRow['parent'] . ':' . $pathRow['text_md5']] + = $this->extractLanguageIdsFromMask($pathRow['lang_mask']); + } + } + } + + $gateway = $this->createMock(Gateway::class); + $gateway->method('loadTranslationLanguageIds')->willReturnCallback( + static function (int $parent, string $textMd5) use ($languageIdsByRow): array { + return $languageIdsByRow["{$parent}:{$textMd5}"] ?? []; + } + ); + + return new Mapper($gateway, $languageHandler); + } + + /** + * Decodes the fixture's legacy bitmask values into real (non-always-available) language ids, + * for driving the {@see Gateway::loadTranslationLanguageIds()} mock. + * + * @return int[] + */ + private function extractLanguageIdsFromMask(int $mask): array + { + $languageIds = []; + for ($languageId = 2; $languageId <= $mask; $languageId *= 2) { + if (($mask & $languageId) === $languageId) { + $languageIds[] = $languageId; + } + } + + return $languageIds; } } diff --git a/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows.php b/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows.php index ade04f1293..e22497e85a 100644 --- a/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows.php +++ b/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows.php @@ -4,315 +4,315 @@ * @copyright Copyright (C) Ibexa AS. All rights reserved. * @license For full copyright and license information view LICENSE file distributed with this source code. */ -return [ - 0 => [ - 'content_id' => 226, - 'content_content_type_id' => 16, - 'content_section_id' => 1, - 'content_owner_id' => 14, - 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1313061404, - 'content_published' => 1313047907, - 'content_status' => 1, - 'content_name' => 'Something', - 'content_language_mask' => 2, - 'content_is_hidden' => 0, - 'content_version_id' => 676, - 'content_version_version' => 2, - 'content_version_modified' => 1313061404, - 'content_version_creator_id' => 14, - 'content_version_created' => 1313061317, - 'content_version_status' => 1, - 'content_version_language_mask' => 3, - 'content_version_initial_language_id' => 2, - 'content_field_id' => 1332, - 'content_field_content_type_field_definition_id' => 183, - 'content_field_data_type_string' => 'ibexa_string', - 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => 2, - 'content_field_data_float' => 0.0, - 'content_field_data_int' => null, - 'content_field_data_text' => 'New test article (2)', - 'content_field_sort_key_int' => 0, - 'content_field_sort_key_string' => 'new test article (2)', - 'content_tree_main_node_id' => 228, - ], - 1 => [ - 'content_id' => 226, - 'content_content_type_id' => 16, - 'content_section_id' => 1, - 'content_owner_id' => 14, - 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1313061404, - 'content_published' => 1313047907, - 'content_status' => 1, - 'content_name' => 'Something', - 'content_language_mask' => 2, - 'content_version_id' => 676, - 'content_version_version' => 2, - 'content_version_modified' => 1313061404, - 'content_version_creator_id' => 14, - 'content_version_created' => 1313061317, - 'content_version_status' => 1, - 'content_version_language_mask' => 3, - 'content_version_initial_language_id' => 2, - 'content_field_id' => 1333, - 'content_field_content_type_field_definition_id' => 184, - 'content_field_data_type_string' => 'ibexa_string', - 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => 2, - 'content_field_data_float' => 0.0, - 'content_field_data_int' => null, - 'content_field_data_text' => 'Something', - 'content_field_sort_key_int' => 0, - 'content_field_sort_key_string' => 'something', - 'content_tree_main_node_id' => 228, - 'content_is_hidden' => 0, - ], - 2 => [ - 'content_id' => 226, - 'content_content_type_id' => 16, - 'content_section_id' => 1, - 'content_owner_id' => 14, - 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1313061404, - 'content_published' => 1313047907, - 'content_status' => 1, - 'content_name' => 'Something', - 'content_language_mask' => 2, - 'content_version_id' => 676, - 'content_version_version' => 2, - 'content_version_modified' => 1313061404, - 'content_version_creator_id' => 14, - 'content_version_created' => 1313061317, - 'content_version_status' => 1, - 'content_version_language_mask' => 3, - 'content_version_initial_language_id' => 2, - 'content_field_id' => 1334, - 'content_field_content_type_field_definition_id' => 185, - 'content_field_data_type_string' => 'ibexa_author', - 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => 2, - 'content_field_data_float' => 0.0, - 'content_field_data_int' => null, - 'content_field_data_text' => ' +return [ + 0 => [ + 'content_id' => 226, + 'content_content_type_id' => 16, + 'content_section_id' => 1, + 'content_owner_id' => 14, + 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1313061404, + 'content_published' => 1313047907, + 'content_status' => 1, + 'content_name' => 'Something', + 'content_always_available' => 0, + 'content_is_hidden' => 0, + 'content_version_id' => 676, + 'content_version_version' => 2, + 'content_version_modified' => 1313061404, + 'content_version_creator_id' => 14, + 'content_version_created' => 1313061317, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 1332, + 'content_field_content_type_field_definition_id' => 183, + 'content_field_data_type_string' => 'ibexa_string', + 'content_field_language_code' => 'eng-US', + 'content_field_language_id' => 2, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => null, + 'content_field_data_text' => 'New test article (2)', + 'content_field_sort_key_int' => 0, + 'content_field_sort_key_string' => 'new test article (2)', + 'content_tree_main_node_id' => 228, + ], + 1 => [ + 'content_id' => 226, + 'content_content_type_id' => 16, + 'content_section_id' => 1, + 'content_owner_id' => 14, + 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1313061404, + 'content_published' => 1313047907, + 'content_status' => 1, + 'content_name' => 'Something', + 'content_always_available' => 0, + 'content_is_hidden' => 0, + 'content_version_id' => 676, + 'content_version_version' => 2, + 'content_version_modified' => 1313061404, + 'content_version_creator_id' => 14, + 'content_version_created' => 1313061317, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 1333, + 'content_field_content_type_field_definition_id' => 184, + 'content_field_data_type_string' => 'ibexa_string', + 'content_field_language_code' => 'eng-US', + 'content_field_language_id' => 2, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => null, + 'content_field_data_text' => 'Something', + 'content_field_sort_key_int' => 0, + 'content_field_sort_key_string' => 'something', + 'content_tree_main_node_id' => 228, + ], + 2 => [ + 'content_id' => 226, + 'content_content_type_id' => 16, + 'content_section_id' => 1, + 'content_owner_id' => 14, + 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1313061404, + 'content_published' => 1313047907, + 'content_status' => 1, + 'content_name' => 'Something', + 'content_always_available' => 0, + 'content_is_hidden' => 0, + 'content_version_id' => 676, + 'content_version_version' => 2, + 'content_version_modified' => 1313061404, + 'content_version_creator_id' => 14, + 'content_version_created' => 1313061317, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 1334, + 'content_field_content_type_field_definition_id' => 185, + 'content_field_data_type_string' => 'ibexa_author', + 'content_field_language_code' => 'eng-US', + 'content_field_language_id' => 2, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => null, + 'content_field_data_text' => ' ', - 'content_field_sort_key_int' => 0, - 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => 228, - 'content_is_hidden' => 0, - ], - 3 => [ - 'content_id' => 226, - 'content_content_type_id' => 16, - 'content_section_id' => 1, - 'content_owner_id' => 14, - 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1313061404, - 'content_published' => 1313047907, - 'content_status' => 1, - 'content_name' => 'Something', - 'content_language_mask' => 2, - 'content_version_id' => 676, - 'content_version_version' => 2, - 'content_version_modified' => 1313061404, - 'content_version_creator_id' => 14, - 'content_version_created' => 1313061317, - 'content_version_status' => 1, - 'content_version_language_mask' => 3, - 'content_version_initial_language_id' => 2, - 'content_field_id' => 1337, - 'content_field_content_type_field_definition_id' => 188, - 'content_field_data_type_string' => 'ibexa_boolean', - 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => 2, - 'content_field_data_float' => 0.0, - 'content_field_data_int' => 1, - 'content_field_data_text' => '', - 'content_field_sort_key_int' => 1, - 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => 228, - 'content_is_hidden' => 0, - ], - 4 => [ - 'content_id' => 226, - 'content_content_type_id' => 16, - 'content_section_id' => 1, - 'content_owner_id' => 14, - 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1313061404, - 'content_published' => 1313047907, - 'content_status' => 1, - 'content_name' => 'Something', - 'content_language_mask' => 2, - 'content_version_id' => 676, - 'content_version_version' => 2, - 'content_version_modified' => 1313061404, - 'content_version_creator_id' => 14, - 'content_version_created' => 1313061317, - 'content_version_status' => 1, - 'content_version_language_mask' => 3, - 'content_version_initial_language_id' => 2, - 'content_field_id' => 1338, - 'content_field_content_type_field_definition_id' => 189, - 'content_field_data_type_string' => 'ibexa_image', - 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => 2, - 'content_field_data_float' => 0.0, - 'content_field_data_int' => null, - 'content_field_data_text' => ' + 'content_field_sort_key_int' => 0, + 'content_field_sort_key_string' => '', + 'content_tree_main_node_id' => 228, + ], + 3 => [ + 'content_id' => 226, + 'content_content_type_id' => 16, + 'content_section_id' => 1, + 'content_owner_id' => 14, + 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1313061404, + 'content_published' => 1313047907, + 'content_status' => 1, + 'content_name' => 'Something', + 'content_always_available' => 0, + 'content_is_hidden' => 0, + 'content_version_id' => 676, + 'content_version_version' => 2, + 'content_version_modified' => 1313061404, + 'content_version_creator_id' => 14, + 'content_version_created' => 1313061317, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 1337, + 'content_field_content_type_field_definition_id' => 188, + 'content_field_data_type_string' => 'ibexa_boolean', + 'content_field_language_code' => 'eng-US', + 'content_field_language_id' => 2, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => 1, + 'content_field_data_text' => '', + 'content_field_sort_key_int' => 1, + 'content_field_sort_key_string' => '', + 'content_tree_main_node_id' => 228, + ], + 4 => [ + 'content_id' => 226, + 'content_content_type_id' => 16, + 'content_section_id' => 1, + 'content_owner_id' => 14, + 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1313061404, + 'content_published' => 1313047907, + 'content_status' => 1, + 'content_name' => 'Something', + 'content_always_available' => 0, + 'content_is_hidden' => 0, + 'content_version_id' => 676, + 'content_version_version' => 2, + 'content_version_modified' => 1313061404, + 'content_version_creator_id' => 14, + 'content_version_created' => 1313061317, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 1338, + 'content_field_content_type_field_definition_id' => 189, + 'content_field_data_type_string' => 'ibexa_image', + 'content_field_language_code' => 'eng-US', + 'content_field_language_id' => 2, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => null, + 'content_field_data_text' => ' ', - 'content_field_sort_key_int' => 0, - 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => 228, - 'content_is_hidden' => 0, - ], - 5 => [ - 'content_id' => 226, - 'content_content_type_id' => 16, - 'content_section_id' => 1, - 'content_owner_id' => 14, - 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1313061404, - 'content_published' => 1313047907, - 'content_status' => 1, - 'content_name' => 'Something', - 'content_language_mask' => 2, - 'content_version_id' => 676, - 'content_version_version' => 2, - 'content_version_modified' => 1313061404, - 'content_version_creator_id' => 14, - 'content_version_created' => 1313061317, - 'content_version_status' => 1, - 'content_version_language_mask' => 3, - 'content_version_initial_language_id' => 2, - 'content_field_id' => 1340, - 'content_field_content_type_field_definition_id' => 191, - 'content_field_data_type_string' => 'ibexa_datetime', - 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => 2, - 'content_field_data_float' => 0.0, - 'content_field_data_int' => 0, - 'content_field_data_text' => '', - 'content_field_sort_key_int' => 0, - 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => 228, - 'content_is_hidden' => 0, - ], - 6 => [ - 'content_id' => 226, - 'content_content_type_id' => 16, - 'content_section_id' => 1, - 'content_owner_id' => 14, - 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1313061404, - 'content_published' => 1313047907, - 'content_status' => 1, - 'content_name' => 'Something', - 'content_language_mask' => 2, - 'content_version_id' => 676, - 'content_version_version' => 2, - 'content_version_modified' => 1313061404, - 'content_version_creator_id' => 14, - 'content_version_created' => 1313061317, - 'content_version_status' => 1, - 'content_version_language_mask' => 3, - 'content_version_initial_language_id' => 2, - 'content_field_id' => 1341, - 'content_field_content_type_field_definition_id' => 192, - 'content_field_data_type_string' => 'ibexa_datetime', - 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => 2, - 'content_field_data_float' => 0.0, - 'content_field_data_int' => 0, - 'content_field_data_text' => '', - 'content_field_sort_key_int' => 0, - 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => 228, - 'content_is_hidden' => 0, - ], - 7 => [ - 'content_id' => 226, - 'content_content_type_id' => 16, - 'content_section_id' => 1, - 'content_owner_id' => 14, - 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1313061404, - 'content_published' => 1313047907, - 'content_status' => 1, - 'content_name' => 'Something', - 'content_language_mask' => 2, - 'content_version_id' => 676, - 'content_version_version' => 2, - 'content_version_modified' => 1313061404, - 'content_version_creator_id' => 14, - 'content_version_created' => 1313061317, - 'content_version_status' => 1, - 'content_version_language_mask' => 3, - 'content_version_initial_language_id' => 2, - 'content_field_id' => 1342, - 'content_field_content_type_field_definition_id' => 193, - 'content_field_data_type_string' => 'ibexa_keyword', - 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => 2, - 'content_field_data_float' => 0.0, - 'content_field_data_int' => null, - 'content_field_data_text' => '', - 'content_field_sort_key_int' => 0, - 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => 228, - 'content_is_hidden' => 0, - ], - 8 => [ - 'content_id' => 226, - 'content_content_type_id' => 16, - 'content_section_id' => 1, - 'content_owner_id' => 14, - 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1313061404, - 'content_published' => 1313047907, - 'content_status' => 1, - 'content_name' => 'Something', - 'content_language_mask' => 2, - 'content_version_id' => 676, - 'content_version_version' => 2, - 'content_version_modified' => 1313061404, - 'content_version_creator_id' => 14, - 'content_version_created' => 1313061317, - 'content_version_status' => 1, - 'content_version_language_mask' => 3, - 'content_version_initial_language_id' => 2, - 'content_field_id' => 4000, - 'content_field_content_type_field_definition_id' => 193, - 'content_field_data_type_string' => 'ibexa_keyword', - 'content_field_language_code' => 'eng-GB', - 'content_field_language_id' => 4, - 'content_field_data_float' => 0.0, - 'content_field_data_int' => null, - 'content_field_data_text' => '', - 'content_field_sort_key_int' => 0, - 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => 228, - 'content_is_hidden' => 0, - ], + 'content_field_sort_key_int' => 0, + 'content_field_sort_key_string' => '', + 'content_tree_main_node_id' => 228, + ], + 5 => [ + 'content_id' => 226, + 'content_content_type_id' => 16, + 'content_section_id' => 1, + 'content_owner_id' => 14, + 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1313061404, + 'content_published' => 1313047907, + 'content_status' => 1, + 'content_name' => 'Something', + 'content_always_available' => 0, + 'content_is_hidden' => 0, + 'content_version_id' => 676, + 'content_version_version' => 2, + 'content_version_modified' => 1313061404, + 'content_version_creator_id' => 14, + 'content_version_created' => 1313061317, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 1340, + 'content_field_content_type_field_definition_id' => 191, + 'content_field_data_type_string' => 'ibexa_datetime', + 'content_field_language_code' => 'eng-US', + 'content_field_language_id' => 2, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => 0, + 'content_field_data_text' => '', + 'content_field_sort_key_int' => 0, + 'content_field_sort_key_string' => '', + 'content_tree_main_node_id' => 228, + ], + 6 => [ + 'content_id' => 226, + 'content_content_type_id' => 16, + 'content_section_id' => 1, + 'content_owner_id' => 14, + 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1313061404, + 'content_published' => 1313047907, + 'content_status' => 1, + 'content_name' => 'Something', + 'content_always_available' => 0, + 'content_is_hidden' => 0, + 'content_version_id' => 676, + 'content_version_version' => 2, + 'content_version_modified' => 1313061404, + 'content_version_creator_id' => 14, + 'content_version_created' => 1313061317, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 1341, + 'content_field_content_type_field_definition_id' => 192, + 'content_field_data_type_string' => 'ibexa_datetime', + 'content_field_language_code' => 'eng-US', + 'content_field_language_id' => 2, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => 0, + 'content_field_data_text' => '', + 'content_field_sort_key_int' => 0, + 'content_field_sort_key_string' => '', + 'content_tree_main_node_id' => 228, + ], + 7 => [ + 'content_id' => 226, + 'content_content_type_id' => 16, + 'content_section_id' => 1, + 'content_owner_id' => 14, + 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1313061404, + 'content_published' => 1313047907, + 'content_status' => 1, + 'content_name' => 'Something', + 'content_always_available' => 0, + 'content_is_hidden' => 0, + 'content_version_id' => 676, + 'content_version_version' => 2, + 'content_version_modified' => 1313061404, + 'content_version_creator_id' => 14, + 'content_version_created' => 1313061317, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 1342, + 'content_field_content_type_field_definition_id' => 193, + 'content_field_data_type_string' => 'ibexa_keyword', + 'content_field_language_code' => 'eng-US', + 'content_field_language_id' => 2, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => null, + 'content_field_data_text' => '', + 'content_field_sort_key_int' => 0, + 'content_field_sort_key_string' => '', + 'content_tree_main_node_id' => 228, + ], + 8 => [ + 'content_id' => 226, + 'content_content_type_id' => 16, + 'content_section_id' => 1, + 'content_owner_id' => 14, + 'content_remote_id' => '95a226fb62c1533f60c16c3769bc7c6c', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1313061404, + 'content_published' => 1313047907, + 'content_status' => 1, + 'content_name' => 'Something', + 'content_always_available' => 0, + 'content_is_hidden' => 0, + 'content_version_id' => 676, + 'content_version_version' => 2, + 'content_version_modified' => 1313061404, + 'content_version_creator_id' => 14, + 'content_version_created' => 1313061317, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 4000, + 'content_field_content_type_field_definition_id' => 193, + 'content_field_data_type_string' => 'ibexa_keyword', + 'content_field_language_code' => 'eng-GB', + 'content_field_language_id' => 4, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => null, + 'content_field_data_text' => '', + 'content_field_sort_key_int' => 0, + 'content_field_sort_key_string' => '', + 'content_tree_main_node_id' => 228, + ], ]; diff --git a/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows_multiple_versions.php b/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows_multiple_versions.php index b4b7aa583b..705cfa0b80 100644 --- a/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows_multiple_versions.php +++ b/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows_multiple_versions.php @@ -4,141 +4,141 @@ * @copyright Copyright (C) Ibexa AS. All rights reserved. * @license For full copyright and license information view LICENSE file distributed with this source code. */ -return [ +return [ 0 => [ - 'content_id' => '11', - 'content_content_type_id' => '3', - 'content_section_id' => '2', - 'content_owner_id' => '14', + 'content_id' => 11, + 'content_content_type_id' => 3, + 'content_section_id' => 2, + 'content_owner_id' => 14, 'content_remote_id' => '5f7f0bdb3381d6a461d8c29ff53d908f', - 'content_current_version' => '2', - 'content_initial_language_id' => '2', - 'content_modified' => '1311154215', - 'content_published' => '1033920746', - 'content_status' => '1', - 'content_version_id' => '439', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1311154215, + 'content_published' => 1033920746, + 'content_status' => 1, 'content_name' => 'Members', - 'content_language_mask' => '3', - 'content_version_version' => '1', - 'content_version_modified' => '1033920746', - 'content_version_creator_id' => '14', - 'content_version_created' => '1033920737', - 'content_version_status' => '3', - 'content_version_language_mask' => '3', - 'content_version_initial_language_id' => '2', - 'content_field_id' => '22', - 'content_field_content_type_field_definition_id' => '6', + 'content_always_available' => 1, + 'content_is_hidden' => 0, + 'content_version_id' => 439, + 'content_version_version' => 1, + 'content_version_modified' => 1033920746, + 'content_version_creator_id' => 14, + 'content_version_created' => 1033920737, + 'content_version_status' => 3, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 22, + 'content_field_content_type_field_definition_id' => 6, 'content_field_data_type_string' => 'ibexa_string', 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => '3', - 'content_field_data_float' => '0.0', - 'content_field_data_int' => '0', + 'content_field_language_id' => 3, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => 0, 'content_field_data_text' => 'Guest accounts', - 'content_field_sort_key_int' => '0', + 'content_field_sort_key_int' => 0, 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => '12', - 'content_is_hidden' => '0', + 'content_tree_main_node_id' => 12, ], 1 => [ - 'content_id' => '11', - 'content_content_type_id' => '3', - 'content_section_id' => '2', - 'content_owner_id' => '14', + 'content_id' => 11, + 'content_content_type_id' => 3, + 'content_section_id' => 2, + 'content_owner_id' => 14, 'content_remote_id' => '5f7f0bdb3381d6a461d8c29ff53d908f', - 'content_current_version' => '2', - 'content_initial_language_id' => '2', - 'content_modified' => '1311154215', - 'content_published' => '1033920746', - 'content_status' => '1', - 'content_version_id' => '439', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1311154215, + 'content_published' => 1033920746, + 'content_status' => 1, 'content_name' => 'Members', - 'content_language_mask' => '3', - 'content_version_version' => '1', - 'content_version_modified' => '1033920746', - 'content_version_creator_id' => '14', - 'content_version_created' => '1033920737', - 'content_version_status' => '3', - 'content_version_language_mask' => '3', - 'content_version_initial_language_id' => '2', - 'content_field_id' => '23', - 'content_field_content_type_field_definition_id' => '7', + 'content_always_available' => 1, + 'content_is_hidden' => 0, + 'content_version_id' => 439, + 'content_version_version' => 1, + 'content_version_modified' => 1033920746, + 'content_version_creator_id' => 14, + 'content_version_created' => 1033920737, + 'content_version_status' => 3, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 23, + 'content_field_content_type_field_definition_id' => 7, 'content_field_data_type_string' => 'ibexa_string', 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => '3', - 'content_field_data_float' => '0.0', - 'content_field_data_int' => '0', + 'content_field_language_id' => 3, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => 0, 'content_field_data_text' => '', - 'content_field_sort_key_int' => '0', + 'content_field_sort_key_int' => 0, 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => '12', - 'content_is_hidden' => '0', + 'content_tree_main_node_id' => 12, ], 2 => [ - 'content_id' => '11', - 'content_content_type_id' => '3', - 'content_section_id' => '2', - 'content_owner_id' => '14', + 'content_id' => 11, + 'content_content_type_id' => 3, + 'content_section_id' => 2, + 'content_owner_id' => 14, 'content_remote_id' => '5f7f0bdb3381d6a461d8c29ff53d908f', - 'content_current_version' => '2', - 'content_initial_language_id' => '2', - 'content_modified' => '1311154215', - 'content_published' => '1033920746', - 'content_status' => '1', - 'content_version_id' => '674', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1311154215, + 'content_published' => 1033920746, + 'content_status' => 1, 'content_name' => 'Members', - 'content_language_mask' => '3', - 'content_version_version' => '2', - 'content_version_modified' => '1311154215', - 'content_version_creator_id' => '14', - 'content_version_created' => '1311154215', - 'content_version_status' => '1', - 'content_version_language_mask' => '3', - 'content_version_initial_language_id' => '2', - 'content_field_id' => '22', - 'content_field_content_type_field_definition_id' => '6', + 'content_always_available' => 1, + 'content_is_hidden' => 0, + 'content_version_id' => 674, + 'content_version_version' => 2, + 'content_version_modified' => 1311154215, + 'content_version_creator_id' => 14, + 'content_version_created' => 1311154215, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 22, + 'content_field_content_type_field_definition_id' => 6, 'content_field_data_type_string' => 'ibexa_string', 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => '3', - 'content_field_data_float' => '0.0', - 'content_field_data_int' => '0', + 'content_field_language_id' => 3, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => 0, 'content_field_data_text' => 'Members', - 'content_field_sort_key_int' => '0', + 'content_field_sort_key_int' => 0, 'content_field_sort_key_string' => 'members', - 'content_tree_main_node_id' => '12', - 'content_is_hidden' => '0', + 'content_tree_main_node_id' => 12, ], 3 => [ - 'content_id' => '11', - 'content_content_type_id' => '3', - 'content_section_id' => '2', - 'content_owner_id' => '14', + 'content_id' => 11, + 'content_content_type_id' => 3, + 'content_section_id' => 2, + 'content_owner_id' => 14, 'content_remote_id' => '5f7f0bdb3381d6a461d8c29ff53d908f', - 'content_current_version' => '2', - 'content_initial_language_id' => '2', - 'content_modified' => '1311154215', - 'content_published' => '1033920746', - 'content_status' => '1', - 'content_version_id' => '674', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1311154215, + 'content_published' => 1033920746, + 'content_status' => 1, 'content_name' => 'Members', - 'content_language_mask' => '3', - 'content_version_version' => '2', - 'content_version_modified' => '1311154215', - 'content_version_creator_id' => '14', - 'content_version_created' => '1311154215', - 'content_version_status' => '1', - 'content_version_language_mask' => '3', - 'content_version_initial_language_id' => '2', - 'content_field_id' => '23', - 'content_field_content_type_field_definition_id' => '7', + 'content_always_available' => 1, + 'content_is_hidden' => 0, + 'content_version_id' => 674, + 'content_version_version' => 2, + 'content_version_modified' => 1311154215, + 'content_version_creator_id' => 14, + 'content_version_created' => 1311154215, + 'content_version_status' => 1, + 'content_version_always_available' => 1, + 'content_version_initial_language_id' => 2, + 'content_field_id' => 23, + 'content_field_content_type_field_definition_id' => 7, 'content_field_data_type_string' => 'ibexa_string', 'content_field_language_code' => 'eng-US', - 'content_field_language_id' => '3', - 'content_field_data_float' => '0.0', - 'content_field_data_int' => '0', + 'content_field_language_id' => 3, + 'content_field_data_float' => 0.0, + 'content_field_data_int' => 0, 'content_field_data_text' => '', - 'content_field_sort_key_int' => '0', + 'content_field_sort_key_int' => 0, 'content_field_sort_key_string' => '', - 'content_tree_main_node_id' => '12', - 'content_is_hidden' => '0', + 'content_tree_main_node_id' => 12, ], ]; diff --git a/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows_result.php b/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows_result.php index 727fd2b185..2d9b76ab2a 100644 --- a/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows_result.php +++ b/tests/lib/Persistence/Legacy/Content/_fixtures/extract_content_from_rows_result.php @@ -23,7 +23,7 @@ $versionInfo->creationDate = 1313061317; $versionInfo->status = 1; $versionInfo->initialLanguageCode = 'eng-US'; -$versionInfo->languageCodes = ['eng-US']; +$versionInfo->languageCodes = ['eng-US', 'eng-GB']; $versionInfo->contentInfo = new ContentInfo(); $versionInfo->contentInfo->id = 226; @@ -122,4 +122,84 @@ $content->fields[] = $field; +$field = new Field(); +$field->id = 4000; +$field->fieldDefinitionId = 193; +$field->type = 'ibexa_keyword'; +$field->value = new FieldValue(); +$field->languageCode = 'eng-GB'; +$field->versionNo = 2; + +$content->fields[] = $field; + +$field = new Field(); +$field->id = 0; +$field->fieldDefinitionId = 183; +$field->type = 'ibexa_string'; +$field->value = new FieldValue(); +$field->languageCode = 'eng-GB'; +$field->versionNo = 2; + +$content->fields[] = $field; + +$field = new Field(); +$field->id = 0; +$field->fieldDefinitionId = 184; +$field->type = 'ibexa_string'; +$field->value = new FieldValue(); +$field->languageCode = 'eng-GB'; +$field->versionNo = 2; + +$content->fields[] = $field; + +$field = new Field(); +$field->id = 0; +$field->fieldDefinitionId = 185; +$field->type = 'ibexa_author'; +$field->value = new FieldValue(); +$field->languageCode = 'eng-GB'; +$field->versionNo = 2; + +$content->fields[] = $field; + +$field = new Field(); +$field->id = 0; +$field->fieldDefinitionId = 188; +$field->type = 'ibexa_boolean'; +$field->value = new FieldValue(); +$field->languageCode = 'eng-GB'; +$field->versionNo = 2; + +$content->fields[] = $field; + +$field = new Field(); +$field->id = 0; +$field->fieldDefinitionId = 189; +$field->type = 'ibexa_image'; +$field->value = new FieldValue(); +$field->languageCode = 'eng-GB'; +$field->versionNo = 2; + +$content->fields[] = $field; + +$field = new Field(); +$field->id = 0; +$field->fieldDefinitionId = 191; +$field->type = 'ibexa_datetime'; +$field->value = new FieldValue(); +$field->languageCode = 'eng-GB'; +$field->versionNo = 2; + +$content->fields[] = $field; + +$field = new Field(); +$field->id = 0; +$field->fieldDefinitionId = 192; +$field->type = 'ibexa_datetime'; +$field->value = new FieldValue(); +$field->languageCode = 'eng-GB'; +$field->versionNo = 2; + +$content->fields[] = $field; + return $content; diff --git a/tests/lib/Persistence/Legacy/Content/_fixtures/extract_version_info_from_rows_multiple_versions.php b/tests/lib/Persistence/Legacy/Content/_fixtures/extract_version_info_from_rows_multiple_versions.php index d7562c9719..3c66a218d8 100644 --- a/tests/lib/Persistence/Legacy/Content/_fixtures/extract_version_info_from_rows_multiple_versions.php +++ b/tests/lib/Persistence/Legacy/Content/_fixtures/extract_version_info_from_rows_multiple_versions.php @@ -4,55 +4,55 @@ * @copyright Copyright (C) Ibexa AS. All rights reserved. * @license For full copyright and license information view LICENSE file distributed with this source code. */ -return [ - 0 => [ - 'content_version_id' => 439, - 'content_version_version' => 1, - 'content_version_modified' => 1033920746, - 'content_version_creator_id' => 14, - 'content_version_created' => 1033920737, - 'content_version_status' => 3, - 'content_version_initial_language_id' => 2, - 'content_version_language_mask' => 3, - 'content_tree_main_node_id' => 12, - 'content_id' => 11, - 'content_content_type_id' => 3, - 'content_section_id' => 2, - 'content_owner_id' => 14, - 'content_remote_id' => '5f7f0bdb3381d6a461d8c29ff53d908f', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1311154215, - 'content_published' => 1033920746, - 'content_status' => 1, - 'content_name' => 'Members', - 'content_language_mask' => 3, - 'content_is_hidden' => 0, - 'content_version_contentobject_id' => 11, - ], - 1 => [ - 'content_version_id' => 674, - 'content_version_version' => 2, - 'content_version_modified' => 1311154215, - 'content_version_creator_id' => 14, - 'content_version_created' => 1311154215, - 'content_version_status' => 1, - 'content_version_initial_language_id' => 2, - 'content_version_language_mask' => 3, - 'content_tree_main_node_id' => 12, - 'content_id' => 11, - 'content_content_type_id' => 3, - 'content_section_id' => 2, - 'content_owner_id' => 14, - 'content_remote_id' => '5f7f0bdb3381d6a461d8c29ff53d908f', - 'content_current_version' => 2, - 'content_initial_language_id' => 2, - 'content_modified' => 1311154215, - 'content_published' => 1033920746, - 'content_status' => 1, - 'content_name' => 'Members', - 'content_language_mask' => 3, - 'content_is_hidden' => 0, - 'content_version_contentobject_id' => 11, - ], +return [ + 0 => [ + 'content_version_id' => 439, + 'content_version_version' => 1, + 'content_version_modified' => 1033920746, + 'content_version_creator_id' => 14, + 'content_version_created' => 1033920737, + 'content_version_status' => 3, + 'content_version_contentobject_id' => 11, + 'content_version_initial_language_id' => 2, + 'content_version_always_available' => 1, + 'content_tree_main_node_id' => 12, + 'content_id' => 11, + 'content_content_type_id' => 3, + 'content_section_id' => 2, + 'content_owner_id' => 14, + 'content_remote_id' => '5f7f0bdb3381d6a461d8c29ff53d908f', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1311154215, + 'content_published' => 1033920746, + 'content_status' => 1, + 'content_name' => 'Members', + 'content_always_available' => 1, + 'content_is_hidden' => 0, + ], + 1 => [ + 'content_version_id' => 674, + 'content_version_version' => 2, + 'content_version_modified' => 1311154215, + 'content_version_creator_id' => 14, + 'content_version_created' => 1311154215, + 'content_version_status' => 1, + 'content_version_contentobject_id' => 11, + 'content_version_initial_language_id' => 2, + 'content_version_always_available' => 1, + 'content_tree_main_node_id' => 12, + 'content_id' => 11, + 'content_content_type_id' => 3, + 'content_section_id' => 2, + 'content_owner_id' => 14, + 'content_remote_id' => '5f7f0bdb3381d6a461d8c29ff53d908f', + 'content_current_version' => 2, + 'content_initial_language_id' => 2, + 'content_modified' => 1311154215, + 'content_published' => 1033920746, + 'content_status' => 1, + 'content_name' => 'Members', + 'content_always_available' => 1, + 'content_is_hidden' => 0, + ], ]; diff --git a/tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilderQueryBuilderTest.php b/tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilderQueryBuilderTest.php index 70f3f71754..c7b692cf49 100644 --- a/tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilderQueryBuilderTest.php +++ b/tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/Content/LanguageCodeQueryBuilderQueryBuilderTest.php @@ -21,8 +21,8 @@ public function getFilteringCriteriaQueryData(): iterable { yield 'Language Code IN (eng-GB, eng-US), match always available' => [ new Criterion\LanguageCode(['eng-GB', 'eng-US']), - '(language.locale IN (:dcValue1)) OR (version.language_mask & 1 = 1)', - ['dcValue1' => ['eng-GB', 'eng-US']], + '(language.locale IN (:dcValue1)) OR (version.always_available = :dcValue2)', + ['dcValue1' => ['eng-GB', 'eng-US'], 'dcValue2' => true], ]; yield 'Language Code=pol-PL, don\'t match always available' => [ diff --git a/tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalOperatorQueryBuilderQueryBuilderTest.php b/tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalOperatorQueryBuilderQueryBuilderTest.php index 8c46ee4156..feb76994cf 100644 --- a/tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalOperatorQueryBuilderQueryBuilderTest.php +++ b/tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/LogicalOperatorQueryBuilderQueryBuilderTest.php @@ -30,8 +30,8 @@ public function getFilteringCriteriaQueryData(): iterable new Criterion\LanguageCode('eng-GB'), ] ), - '(location.parent_node_id IN (:dcValue1)) AND ((language.locale IN (:dcValue2)) OR (version.language_mask & 1 = 1))', - ['dcValue1' => [1], 'dcValue2' => ['eng-GB']], + '(location.parent_node_id IN (:dcValue1)) AND ((language.locale IN (:dcValue2)) OR (version.always_available = :dcValue3))', + ['dcValue1' => [1], 'dcValue2' => ['eng-GB'], 'dcValue3' => true], ]; yield 'Language Code=eng-US OR Parent Location ID=2' => [ @@ -41,8 +41,8 @@ public function getFilteringCriteriaQueryData(): iterable new Criterion\ParentLocationId(2), ] ), - '((language.locale IN (:dcValue1)) OR (version.language_mask & 1 = 1)) OR (location.parent_node_id IN (:dcValue2))', - ['dcValue1' => ['eng-GB'], 'dcValue2' => [2]], + '((language.locale IN (:dcValue1)) OR (version.always_available = :dcValue2)) OR (location.parent_node_id IN (:dcValue3))', + ['dcValue1' => ['eng-GB'], 'dcValue2' => true, 'dcValue3' => [2]], ]; yield 'NOT(Content ID=1 OR (Parent Location ID=2 AND Content ID = 1)' => [ diff --git a/tests/lib/Persistence/Legacy/TestCase.php b/tests/lib/Persistence/Legacy/TestCase.php index 39a5592456..25767103a9 100644 --- a/tests/lib/Persistence/Legacy/TestCase.php +++ b/tests/lib/Persistence/Legacy/TestCase.php @@ -182,7 +182,7 @@ static function ($row): string { protected function insertDatabaseFixture(string $file): void { try { - $fixtureImporter = new FixtureImporter($this->getDatabaseConnection()); + $fixtureImporter = new FixtureImporter($this->getDatabaseConnection(), new SchemaAssetsFilterBypass()); $fixtureImporter->import((new FileFixtureFactory())->buildFixture($file)); } catch (DBALException $e) { self::fail('Database fixture import failed: ' . $e->getMessage()); @@ -197,7 +197,7 @@ protected function insertDatabaseFixture(string $file): void protected function insertSharedDatabaseFixture(): void { try { - $fixtureImporter = new FixtureImporter($this->getDatabaseConnection()); + $fixtureImporter = new FixtureImporter($this->getDatabaseConnection(), new SchemaAssetsFilterBypass()); $fixtureImporter->import( new YamlFixture( __DIR__ . '/../../../integration/Core/Repository/_fixtures/Legacy/data/test_data.yaml' diff --git a/tests/lib/Search/Legacy/Content/AbstractTestCase.php b/tests/lib/Search/Legacy/Content/AbstractTestCase.php index ca15b7023e..4768864002 100644 --- a/tests/lib/Search/Legacy/Content/AbstractTestCase.php +++ b/tests/lib/Search/Legacy/Content/AbstractTestCase.php @@ -88,16 +88,19 @@ static function ($hit) { protected function getContentTypeHandler(): SPIContentTypeHandler { if (!isset($this->contentTypeHandler)) { + $contentTypeGateway = new ContentTypeGateway( + $this->getDatabaseConnection(), + $this->getSharedGateway(), + $this->getLanguageHandler(), + $this->getCriterionVisitor() + ); + $this->contentTypeHandler = new ContentTypeHandler( - new ContentTypeGateway( - $this->getDatabaseConnection(), - $this->getSharedGateway(), - $this->getLanguageMaskGenerator(), - $this->getCriterionVisitor() - ), + $contentTypeGateway, new ContentTypeMapper( $this->getConverterRegistry(), - $this->getLanguageMaskGenerator(), + $contentTypeGateway, + $this->getLanguageHandler(), $this->createMock(StorageDispatcherInterface::class), $this->getFieldTypeAliasResolver(), ), @@ -110,6 +113,13 @@ protected function getContentTypeHandler(): SPIContentTypeHandler return $this->contentTypeHandler; } + protected function getLanguageGateway(): \Ibexa\Core\Persistence\Legacy\Content\Language\Gateway + { + return new \Ibexa\Core\Persistence\Legacy\Content\Language\Gateway\DoctrineDatabase( + $this->getDatabaseConnection() + ); + } + protected function getConverterRegistry() { if (!isset($this->converterRegistry)) { diff --git a/tests/lib/Search/Legacy/Content/HandlerContentSortTest.php b/tests/lib/Search/Legacy/Content/HandlerContentSortTest.php index bdea90b99a..3377446f71 100644 --- a/tests/lib/Search/Legacy/Content/HandlerContentSortTest.php +++ b/tests/lib/Search/Legacy/Content/HandlerContentSortTest.php @@ -14,6 +14,7 @@ use Ibexa\Core\Persistence\Doctrine\JoinedTablesTracker; use Ibexa\Core\Persistence\Legacy\Content\FieldHandler; use Ibexa\Core\Persistence\Legacy\Content\FieldValue\ConverterRegistry; +use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway as LanguageGateway; use Ibexa\Core\Persistence\Legacy\Content\Location\Mapper as LocationMapper; use Ibexa\Core\Persistence\Legacy\Content\Mapper as ContentMapper; use Ibexa\Core\Search\Legacy\Content; @@ -71,7 +72,11 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = new Content\Common\Gateway\SortClauseHandler\Field( $connection, $this->getLanguageHandler(), - $this->getContentTypeHandler() + $this->getContentTypeHandler(), + new Content\Common\Gateway\LanguagePriorityConditionBuilder( + $connection, + $this->getLanguageHandler() + ) ), ] ), @@ -83,13 +88,14 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = $this->getContentTypeHandler(), $this->getDefinitionBasedTransformationProcessor(), new Content\WordIndexer\Repository\SearchIndex($this->getDatabaseConnection()), - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $this->getFullTextSearchConfiguration() ), $this->getContentMapperMock(), $this->createMock(LocationMapper::class), $this->getLanguageHandler(), - $this->getFullTextMapper($this->getContentTypeHandler()) + $this->getFullTextMapper($this->getContentTypeHandler()), + $this->getLanguageGateway() ); } @@ -108,6 +114,7 @@ protected function getContentMapperMock() $this->getContentTypeHandler(), $this->getEventDispatcher(), $this->getFieldTypeAliasResolver(), + $this->createMock(LanguageGateway::class), ] ) ->setMethods(['extractContentInfoFromRows']) diff --git a/tests/lib/Search/Legacy/Content/HandlerContentTest.php b/tests/lib/Search/Legacy/Content/HandlerContentTest.php index 3eab281e10..b2c18addd5 100644 --- a/tests/lib/Search/Legacy/Content/HandlerContentTest.php +++ b/tests/lib/Search/Legacy/Content/HandlerContentTest.php @@ -16,6 +16,7 @@ use Ibexa\Contracts\Core\Repository\Values\Content\Query\SortClause; use Ibexa\Core\Persistence; use Ibexa\Core\Persistence\Legacy\Content\FieldHandler; +use Ibexa\Core\Persistence\Legacy\Content\Language\Gateway as LanguageGateway; use Ibexa\Core\Persistence\Legacy\Content\Location\Mapper as LocationMapper; use Ibexa\Core\Persistence\Legacy\Content\Mapper as ContentMapper; use Ibexa\Core\Search\Legacy\Content; @@ -134,7 +135,7 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = new Content\Common\Gateway\CriterionHandler\FullText( $connection, $transformationProcessor, - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $joinedTablesTracker, $fullTextSearchConfiguration ), @@ -161,7 +162,11 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = $compositeValueHandler ), $transformationProcessor, - $joinedTablesTracker + $joinedTablesTracker, + new Content\Common\Gateway\LanguagePriorityConditionBuilder( + $connection, + $this->getLanguageHandler() + ) ), new Content\Common\Gateway\CriterionHandler\ObjectStateId( $connection, @@ -169,7 +174,7 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = ), new Content\Common\Gateway\CriterionHandler\LanguageCode( $connection, - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $joinedTablesTracker ), new Content\Gateway\CriterionHandler\Visibility( @@ -188,7 +193,11 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = $connection, $this->getContentTypeHandler(), $this->getLanguageHandler(), - $joinedTablesTracker + $joinedTablesTracker, + new Content\Common\Gateway\LanguagePriorityConditionBuilder( + $connection, + $this->getLanguageHandler() + ) ), ] ), @@ -205,13 +214,14 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = $this->getContentTypeHandler(), $this->getDefinitionBasedTransformationProcessor(), new Content\WordIndexer\Repository\SearchIndex($this->getDatabaseConnection()), - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $this->getFullTextSearchConfiguration() ), $this->getContentMapperMock(), $this->createMock(LocationMapper::class), $this->getLanguageHandler(), - $this->getFullTextMapper($this->getContentTypeHandler()) + $this->getFullTextMapper($this->getContentTypeHandler()), + $this->getLanguageGateway() ); } @@ -230,6 +240,7 @@ protected function getContentMapperMock() $this->getContentTypeHandler(), $this->getEventDispatcher(), $this->getFieldTypeAliasResolver(), + $this->createMock(LanguageGateway::class), ] ) ->setMethods(['extractContentInfoFromRows']) diff --git a/tests/lib/Search/Legacy/Content/HandlerLocationSortTest.php b/tests/lib/Search/Legacy/Content/HandlerLocationSortTest.php index fe4d39381f..5eee51e4d0 100644 --- a/tests/lib/Search/Legacy/Content/HandlerLocationSortTest.php +++ b/tests/lib/Search/Legacy/Content/HandlerLocationSortTest.php @@ -87,7 +87,11 @@ protected function getContentSearchHandler() new CommonSortClauseHandler\Field( $connection, $this->getLanguageHandler(), - $this->getContentTypeHandler() + $this->getContentTypeHandler(), + new Content\Common\Gateway\LanguagePriorityConditionBuilder( + $connection, + $this->getLanguageHandler() + ) ), ] ), @@ -98,13 +102,14 @@ protected function getContentSearchHandler() $this->getContentTypeHandler(), $this->getDefinitionBasedTransformationProcessor(), new Content\WordIndexer\Repository\SearchIndex($this->getDatabaseConnection()), - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $this->getFullTextSearchConfiguration() ), $this->createMock(ContentMapper::class), $this->getLocationMapperMock(), $this->getLanguageHandler(), - $this->getFullTextMapper($this->getContentTypeHandler()) + $this->getFullTextMapper($this->getContentTypeHandler()), + $this->getLanguageGateway() ); } @@ -131,6 +136,7 @@ static function ($rows): array { if (!isset($locations[$locationId])) { $locations[$locationId] = new SPILocation(); $locations[$locationId]->id = $locationId; + $locations[$locationId]->contentId = (int)$row['contentobject_id']; } } diff --git a/tests/lib/Search/Legacy/Content/HandlerLocationTest.php b/tests/lib/Search/Legacy/Content/HandlerLocationTest.php index 92d36ab6ce..190345ddf0 100644 --- a/tests/lib/Search/Legacy/Content/HandlerLocationTest.php +++ b/tests/lib/Search/Legacy/Content/HandlerLocationTest.php @@ -117,18 +117,22 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = $compositeValueHandler ), $transformationProcessor, - $joinedTablesTracker + $joinedTablesTracker, + new Content\Common\Gateway\LanguagePriorityConditionBuilder( + $connection, + $this->getLanguageHandler() + ) ), new CommonCriterionHandler\FullText( $connection, $transformationProcessor, - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $joinedTablesTracker, $fullTextSearchConfiguration ), new CommonCriterionHandler\LanguageCode( $connection, - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $joinedTablesTracker ), new CommonCriterionHandler\LogicalAnd($connection, $joinedTablesTracker), @@ -138,7 +142,11 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = $connection, $this->getContentTypeHandler(), $this->getLanguageHandler(), - $joinedTablesTracker + $joinedTablesTracker, + new Content\Common\Gateway\LanguagePriorityConditionBuilder( + $connection, + $this->getLanguageHandler() + ) ), new CommonCriterionHandler\MatchAll($connection, $joinedTablesTracker), new CommonCriterionHandler\ObjectStateId($connection, $joinedTablesTracker), @@ -146,7 +154,11 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = $connection, $this->getContentTypeHandler(), $this->getLanguageHandler(), - $joinedTablesTracker + $joinedTablesTracker, + new Content\Common\Gateway\LanguagePriorityConditionBuilder( + $connection, + $this->getLanguageHandler() + ) ), new CommonCriterionHandler\RemoteId($connection, $joinedTablesTracker), new CommonCriterionHandler\SectionId($connection, $joinedTablesTracker), @@ -166,13 +178,14 @@ protected function getContentSearchHandler(array $fullTextSearchConfiguration = $this->getContentTypeHandler(), $transformationProcessor, new Content\WordIndexer\Repository\SearchIndex($this->getDatabaseConnection()), - $this->getLanguageMaskGenerator(), + $this->getLanguageHandler(), $this->getFullTextSearchConfiguration() ), $this->createMock(ContentMapper::class), $this->getLocationMapperMock(), $this->getLanguageHandler(), - $this->getFullTextMapper($this->getContentTypeHandler()) + $this->getFullTextMapper($this->getContentTypeHandler()), + $this->getLanguageGateway() ); } @@ -199,6 +212,7 @@ static function ($rows): array { if (!isset($locations[$locationId])) { $locations[$locationId] = new SPILocation(); $locations[$locationId]->id = $locationId; + $locations[$locationId]->contentId = (int)$row['contentobject_id']; } }