diff --git a/changelogs/unreleased/module-override.md b/changelogs/unreleased/module-override.md new file mode 100644 index 0000000000000000000000000000000000000000..16429752cde1dfdb8108a55d95fcd95e3e9c28d0 --- /dev/null +++ b/changelogs/unreleased/module-override.md @@ -0,0 +1,3 @@ +## feature/lua + +* Added ability to override a built-in module by an external one (gh-7774). diff --git a/src/lua/loaders.lua b/src/lua/loaders.lua index 2a0c7ddcb5061426fec73c491916c4771eedef98..27490301acb1746042878e48f72690c03eb59589 100644 --- a/src/lua/loaders.lua +++ b/src/lua/loaders.lua @@ -175,6 +175,30 @@ local function search(name) return nil end +-- Accept a loader and return a loader, which search by a prefixed +-- module name. +-- +-- The module receives the original (unprefixed) module name in +-- the argument (three dots). +local function prefix_loader(prefix, subloader) + return function(name) + local prefixed_name = prefix .. '.' .. name + -- On success the return value is a function, which + -- executes module's initialization code. require() calls + -- it with one argument: the module name (it can be + -- received in the module using three dots). Since + -- require() knows nothing about our prefixing it passes + -- the original name there. + -- + -- It is expected behavior in our case. The prefixed + -- loaders are added to enable extra search paths: like + -- we would add more package.{path,cpath} entries. It + -- shouldn't change the string passed to the module's + -- initialization code. + return subloader(prefixed_name) + end +end + -- Accept an array of loaders and return a loader, whose effect is -- equivalent to calling the loaders in a row. local function chain_loaders(subloaders) @@ -214,14 +238,26 @@ table.insert(package.loaders, 5, gen_loader_func(search_rocks_lib, load_lib)) -- package.cpath 7 -- croot 8 --- Add a loader for searching a built-in module (compiled into --- tarantool's executable). +-- Add two loaders: +-- +-- - Search for override.<module_name> module. It is necessary for +-- overriding built-in modules. +-- - Search for a built-in module (compiled into tarantool's +-- executable). -- --- The loader is mixed into the first loader to don't change --- ordinals of the loaders 2-8. It is possible that someone +-- Those two loaders are mixed into the first loader to don't +-- change ordinals of the loaders 2-8. It is possible that someone -- has a logic based on those loader positions. package.loaders[1] = chain_loaders({ package.loaders[1], + chain_loaders({ + prefix_loader('override', package.loaders[2]), + prefix_loader('override', package.loaders[3]), + prefix_loader('override', package.loaders[4]), + prefix_loader('override', package.loaders[5]), + prefix_loader('override', package.loaders[6]), + prefix_loader('override', package.loaders[7]), + }), builtin_loader, }) diff --git a/test/app-luatest/override_test.lua b/test/app-luatest/override_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..f5b694cde5c3880abae47a5b538b50731fbbda01 --- /dev/null +++ b/test/app-luatest/override_test.lua @@ -0,0 +1,146 @@ +local t = require('luatest') +local treegen = require('test.treegen') +local justrun = require('test.justrun') + +local g = t.group() + +-- Core idea: return something that differs from the corresponding +-- built-in module. +-- +-- Print `...` and `arg` to ensure that they have expected +-- value. +local OVERRIDE_SCRIPT_TEMPLATE = [[ +print(require('json').encode({ + ['script'] = '<script>', + ['...'] = {...}, + ['arg[-1]'] = arg[-1], + ['arg[0]'] = arg[0], + ['arg[]'] = setmetatable(arg, {__serialize = 'seq'}), +})) +return {whoami = 'override.<module_name>'} +]] + +-- Print a result of the require call. +local MAIN_SCRIPT_TEMPLATE = [[ +print(require('json').encode({ + ['script'] = '<script>', + ['<module_name>'] = require('<module_name>'), +})) +]] + +g.before_all(function(g) + treegen.init(g) + treegen.add_template(g, '^override/.*%.lua$', OVERRIDE_SCRIPT_TEMPLATE) + treegen.add_template(g, '^main%.lua$', MAIN_SCRIPT_TEMPLATE) +end) + +g.after_all(function(g) + treegen.clean(g) +end) + +-- Test oracle. +-- +-- Verifies that the override module is actually returned by the +-- require call in main.lua. +-- +-- Also holds `...` (without 'override.') and `arg` (the same as +-- in the main script). +local function expected_output(module_name) + local module_name_as_path = table.concat(module_name:split('.'), '/') + local override_filename = ('override/%s.lua'):format(module_name_as_path) + + local res = { + { + ['script'] = override_filename, + ['...'] = {module_name}, + ['arg[-1]'] = arg[-1], + ['arg[0]'] = 'main.lua', + ['arg[]'] = {}, + }, + { + ['script'] = 'main.lua', + [module_name] = { + whoami = ('override.%s'):format(module_name) + }, + }, + } + + return { + exit_code = 0, + stdout = res, + } +end + +-- A couple of test cases with overriding built-in modules. +-- +-- In a general case it is not a trivial task to correctly +-- override a built-in module. However, there are modules that +-- could be overridden with an arbitrary table and it will pass +-- tarantool's initialization successfully. +-- +-- We have no guarantee that any module could be overridden. It is +-- more like 'it is generally possible'. +-- +-- The list is collected from loaders.builtin and package.loaded +-- keys. Many modules are excluded deliberately: +-- +-- - json -- it is used in the test itself +-- - bit, coroutine, debug, ffi, io, jit, jit.*, math, os, +-- package, string, table -- LuaJIT modules +-- - misc -- tarantool's module implemented as part of LuaJIT. +-- - *.lib, internal.*, utils.* and so on -- tarantool internal +-- modules +-- - memprof.*, misc.*, sysprof.*, table.*, timezones -- unclear +-- whether they're public +-- - box, buffer, decimal, errno, fiber, fio, log, merger, +-- msgpackffi, strict, tarantool, yaml -- used during +-- tarantool's initialization in a way that doesn't allow to +-- replace them with an arbitrary table +local override_cases = { + 'clock', + 'console', + 'crypto', + 'csv', + 'datetime', + 'digest', + 'error', + 'fun', + 'help', + 'http.client', + 'iconv', + 'key_def', + 'luadebug', + 'memprof', + 'msgpack', + 'net.box', + 'pickle', + 'popen', + 'pwd', + 'socket', + 'swim', + 'sysprof', + 'tap', + 'title', + 'uri', + 'utf8', + 'uuid', + 'xlog', +} + +-- Generate a workdir (override/foo.lua and main.lua), run +-- tarantool, check output. +for _, module_name in ipairs(override_cases) do + local module_name_as_path = table.concat(module_name:split('.'), '/') + local override_filename = ('override/%s.lua'):format(module_name_as_path) + local module_name_as_snake = table.concat(module_name:split('.'), '_') + local case_name = ('test_override_%s'):format(module_name_as_snake) + + g[case_name] = function(g) + local scripts = {override_filename, 'main.lua'} + local replacements = {module_name = module_name} + local dir = treegen.prepare_directory(g, scripts, replacements) + local res = justrun.tarantool(dir, {}, {'main.lua'}) + local exp = expected_output(module_name) + t.assert_equals(res, exp) + end +end diff --git a/test/luajit-test-init.lua b/test/luajit-test-init.lua index a04d48b010f115eb10129962d9be3a46dd3260c9..25afcd3cae16c041a06b538006bd835c11c74967 100644 --- a/test/luajit-test-init.lua +++ b/test/luajit-test-init.lua @@ -100,6 +100,45 @@ rawset(_G, 'pairs', pairs_M.builtin_pairs) assert(pairs ~= nil) assert(type(pairs) == 'function') +-- Remove the override loader. +-- +-- If the override loader is enabled at least one test in the +-- LuaJIT submodule fails: PUC-Rio-Lua-5.1-tests/attrib.lua. +-- +-- A sketchy description of the test case of the question is the +-- following. +-- +-- There is a directory: +-- +-- | + libs/ +-- | +- foo.lua +-- | +- bar.lua +-- +-- And the Lua code: +-- +-- | package.path = 'libs/?.lua;libs/bar.lua' +-- | local foo = require('foo') +-- +-- The test case expects that `foo` contains a return value of +-- libs/foo.lua. +-- +-- However, when the override loader is enabled, the following +-- occurs. The override loader attempts to find `override.foo`: it +-- tries `libs/?.lua` first -- it means `libs/override/foo.lua` +-- and there is no such file. Then the loader tries `libs/bar.lua` +-- and succeeds. +-- +-- So `foo` contains a return value of `libs/bar.lua`. +-- +-- Unlikely there is a valid user scenario for a package.path +-- entry without the interrogation mark. We consider this override +-- loader behavior as valid. The loader is disabled to pass the +-- test suite, but likely it would be more correct to discard the +-- test case. +local varname, subloaders = debug.getupvalue(package.loaders[1], 1) +assert(varname == 'subloaders') +table.remove(subloaders, 2) + -- This is workaround introduced for flaky macosx tests reported by -- https://github.com/tarantool/tarantool/issues/7058 collectgarbage('collect') diff --git a/test/treegen.lua b/test/treegen.lua index 4c7b42cdacf43a82548be517f0f9d7460b7c57ba..1988ed9313ed3f86e1a9a261be46dc6beb924418 100644 --- a/test/treegen.lua +++ b/test/treegen.lua @@ -31,6 +31,7 @@ local fio = require('fio') local log = require('log') +local fun = require('fun') local treegen = {} @@ -45,17 +46,16 @@ end -- Generate a script that follows a template and write it at given -- script file path in given directory. -local function write_script(g, dir, script) +local function write_script(g, dir, script, replacements) local script_abspath = fio.pathjoin(dir, script) local flags = {'O_CREAT', 'O_WRONLY', 'O_TRUNC'} local mode = tonumber('644', 8) local fh = fio.open(script_abspath, flags, mode) local template = find_template(g, script) + local replacements = fun.chain({script = script}, replacements):tomap() log.info(('Writing a script: %s'):format(script_abspath)) - fh:write(template:gsub('<(.-)>', { - script = script, - })) + fh:write(template:gsub('<(.-)>', replacements)) fh:close() end @@ -108,8 +108,11 @@ end -- + baz.lua -- -- The return value is '/tmp/rfbWOJ' for this example. -function treegen.prepare_directory(g, scripts) +function treegen.prepare_directory(g, scripts, replacements) + local replacements = replacements or {} + assert(type(scripts) == 'table') + assert(type(replacements) == 'table') local dir = fio.tempdir() table.insert(g.tempdirs, dir) @@ -119,7 +122,7 @@ function treegen.prepare_directory(g, scripts) local scriptdir_abspath = fio.dirname(script_abspath) log.info(('Creating a directory: %s'):format(scriptdir_abspath)) fio.mktree(scriptdir_abspath) - write_script(g, dir, script) + write_script(g, dir, script, replacements) end return dir