├── .gitignore ├── CHANGELOG ├── CMakeLists.txt ├── LICENSE ├── README.md ├── cmake └── FindTarantool.cmake ├── rockspecs ├── spacer-2.0.1-1.rockspec ├── spacer-2.1.1-1.rockspec ├── spacer-2.1.2-1.rockspec ├── spacer-3.0.1-1.rockspec ├── spacer-3.1.0-1.rockspec ├── spacer-3.2.0-1.rockspec ├── spacer-scm-1.rockspec ├── spacer-scm-2.rockspec └── spacer-scm-3.rockspec ├── spacer-scm-1.rockspec ├── spacer ├── CMakeLists.txt ├── compat.lua ├── fileio.lua ├── init.lua ├── inspect.lua ├── migration.lua ├── ops.lua ├── stmt.lua ├── transformations.lua ├── util.lua └── version.lua └── tests ├── 01-basic.lua └── tnt └── init.lua /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | # Custom 43 | .idea 44 | *.xlog 45 | *.snap 46 | tests/migrations 47 | tests/temp 48 | .tmp 49 | 50 | # CMake 51 | 52 | CMakeCache.txt 53 | CMakeFiles 54 | CMakeScripts 55 | Testing 56 | Makefile 57 | cmake_install.cmake 58 | install_manifest.txt 59 | compile_commands.json 60 | CTestTestfile.cmake 61 | 62 | .rocks -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | v3.1.0 2 | * Removed automigrate option in spacer. Replaced with a separate method that should be called 3 | when all necessary `spacer:space()` calls are finished. (addresses #13) 4 | 5 | v3.0.0 6 | * Breaking change: changed Spacer api. Now it has 2 methods: new() and get(). `new` creates a new 7 | spacer instance as usual require 'spacer'({}) call. `get()` returns a spacer 8 | instance by its name or a default instance. 9 | * spacer instances can have names now. And they are fully independent in terms of 10 | migrations and allocated system spaces. 11 | 12 | v2.1.1 13 | * Changed behaviour of migrations' versions handling. 14 | * Removed version_name() method. Now version() is returning the full version (_). 15 | * check_alter option for makemigration() function. 16 | * New migrate_dummy() function to imitate migration applying. 17 | 18 | v2.0.1 19 | * New version 2 with migrations and stuff 20 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 2.8 FATAL_ERROR) 2 | set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake" ${CMAKE_MODULE_PATH}) 3 | 4 | project(spacer NONE) 5 | if(NOT CMAKE_BUILD_TYPE) 6 | set(CMAKE_BUILD_TYPE Debug) 7 | endif() 8 | 9 | # Build module 10 | add_subdirectory(spacer) 11 | 12 | # Tests 13 | set(TARANTOOL tarantool) 14 | set(LUA_PATH 15 | "${CMAKE_SOURCE_DIR}/?.lua\\;${CMAKE_SOURCE_DIR}/?/init.lua\\;\\;") 16 | 17 | set(LUA_CPATH "${CMAKE_SOURCE_DIR}/?.so\\;\\;") 18 | enable_testing() 19 | 20 | add_test(NAME basic 21 | COMMAND ${TARANTOOL} ${CMAKE_SOURCE_DIR}/tests/01-basic.lua 22 | WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/tests) 23 | 24 | set(TESTS basic) 25 | foreach(test IN LISTS TESTS) 26 | set_property(TEST ${test} PROPERTY ENVIRONMENT "LUA_PATH=${LUA_PATH}") 27 | set_property(TEST ${test} APPEND PROPERTY ENVIRONMENT 28 | "LUA_CPATH=${LUA_CPATH}") 29 | endforeach() 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spacer 2 | Tarantool Spacer. Automatic models migrations. 3 | 4 | # Changes detected by spacer 5 | 6 | * Space create and drop 7 | * Index create and drop 8 | * Index options alteration 9 | * `unique` 10 | * `type` 11 | * `sequence` 12 | * `dimension` and `distance` (for RTREE indexes) 13 | * `parts` 14 | * Format alterations 15 | * New fields (only to the end of format list). 16 | Setting values for existing tuples is handled by the [moonwalker](https://github.com/tarantool/moonwalker) library 17 | * Field's `is_nullable` and `collation` changes 18 | * **[IMPORTANT] `type` and `name` changes are prohibited** 19 | 20 | 21 | # Installation 22 | 23 | The latest `spacer` release is **`3.0.1`**. 24 | 25 | Use `luarocks` to install this package by one of the following rockspec from the `rockspecs` folders: 26 | * `rockspecs/spacer-scm-3.rockspec` - Installs current version 3 from `master` branch 27 | * `rockspecs/spacer-3.0.1-1.rockspec` - Installs tagged version 3 from `v3.0.1` tag 28 | 29 | There is also a `rockspecs/spacer-scm-1.rockspec` which installs an old **v1** version of spacer (from branch `v1`). This is left here for compatibility reasons for projects that still use spacer v1. Please do not use this version as it is not supported anymore. 30 | 31 | Spacer depends on 2 libraries: 32 | * `inspect` - available from rocks.moonscript.org 33 | * `moonwalker` - available from rocks.tarantool.org 34 | 35 | So you shold put the following to your `~/.luarocks/config.lua` file: 36 | ```lua 37 | rocks_servers = { 38 | [[http://rocks.tarantool.org]], 39 | [[https://rocks.moonscript.org]], 40 | } 41 | ``` 42 | 43 | And after that you can run 44 | 45 | ``` 46 | luarocks install https://raw.githubusercontent.com/igorcoding/tarantool-spacer/master/rockspecs/spacer-scm-3.rockspec 47 | ``` 48 | 49 | to install from master 50 | 51 | or 52 | 53 | ``` 54 | luarocks install https://raw.githubusercontent.com/igorcoding/tarantool-spacer/master/rockspecs/spacer-3.0.1-1.rockspec 55 | ``` 56 | 57 | to install from the tag. 58 | 59 | 60 | # Usage 61 | ## Initialize 62 | 63 | Initialized spacer somewhere in the beginning of your `init.lua`: 64 | ```lua 65 | require 'spacer'.new({ 66 | migrations = 'path/to/migrations/folder', 67 | }) 68 | ``` 69 | 70 | You can assign spacer to a some global variable for easy access: 71 | ```lua 72 | box.spacer = require 'spacer'.new({ 73 | migrations = 'path/to/migrations/folder', 74 | }) 75 | ``` 76 | 77 | ### Spacer options 78 | 79 | * `migrations` (required) - Path to migrations folder 80 | * `name` (default is `''`) - Spacer instance name. You can have multiple independent instances 81 | * `global_ft` (default is `true`) - Expose `F` and `T` variables to the global `_G` table (see [Fields](#Fields) and [Transformations](#Transformations) sections). 82 | * `keep_obsolete_spaces` (default is `false`) - do not track space removals 83 | * `keep_obsolete_indexes` (default is `false`) - do not track indexes removals 84 | * `down_migration_fail_on_impossible` (default is `true`) - Generate an `assert(false)` statement in a down migration when detecting new fields in format, so user can perform proper actions on a down migration. 85 | 86 | ## Define spaces 87 | 88 | You can easily define new spaces in a separate file (e.g. `models.lua`) and all 89 | you will need to do is to `require` it from `init.lua` right after spacer initialization: 90 | ```lua 91 | box.spacer = require 'spacer'.new({ 92 | migrations = 'path/to/migrations/folder', 93 | }) 94 | require 'models' 95 | ``` 96 | 97 | ### `models.lua` example: 98 | 99 | Note that spacer now has methods 100 | 101 | ```lua 102 | local spacer = require 'spacer'.get() 103 | 104 | spacer:space({ 105 | name = 'object', 106 | format = { 107 | { name = 'id', type = 'unsigned' }, 108 | { name = 'name', type = 'string', is_nullable = true }, 109 | }, 110 | indexes = { 111 | { name = 'primary', type = 'tree', unique = true, parts = {'id'}, sequence = true }, 112 | { name = 'name', type = 'tree', unique = false, parts = {'name', 'id'} }, 113 | } 114 | }) 115 | ``` 116 | 117 | `spacer:space` has 4 parameters: 118 | 1. `name` - space name (required) 119 | 2. `format` - space format (required) 120 | 3. `indexes` - space indexes array (required) 121 | 4. `opts` - any box.schema.create_space options (optional). Please refer to Tarantool documentation for details. 122 | 123 | Indexes parts must be defined using only field names. 124 | 125 | ## Creating migration 126 | 127 | You can autogenerate migration by just running the following snippet in Tarantool console: 128 | 129 | ```lua 130 | box.spacer:makemigration('init_object') 131 | ``` 132 | 133 | There are 2 arguments to the `makemigration` method: 134 | 1. Migration name (required) 135 | 2. Options 136 | * `autogenerate` (`true`/`false`) - Autogenerate migration (default is `true`). If `false` then empty migration file is generated 137 | * `check_alter` (`true`/`false`) - Default is `true` - so spacer will check spaces and indexes for changes and create alter migrations. If `false` then spacer will assume that spaces never existed. Useful when you want to add spacer to already existing project. 138 | * `allow_empty` (`true`, `false`) - Default is `true`. If `false` no migration files will be created if there are no schema changes. If `true` an empty migration file will be created instead. 139 | 140 | After executing this command a new migrations file will be generated under name `_.lua` inside your `migrations` folder: 141 | ```lua 142 | return { 143 | up = function() 144 | box.schema.create_space("object", nil) 145 | box.space.object:format({ { 146 | name = "id", 147 | type = "unsigned" 148 | }, { 149 | is_nullable = false, 150 | name = "name", 151 | type = "string" 152 | } }) 153 | box.space.object:create_index("primary", { 154 | parts = { { 1, "unsigned", 155 | is_nullable = false 156 | } }, 157 | sequence = true, 158 | type = "tree", 159 | unique = true 160 | }) 161 | box.space.object:create_index("name", { 162 | parts = { { 2, "string", 163 | is_nullable = false 164 | }, { 1, "unsigned", 165 | is_nullable = false 166 | } }, 167 | type = "tree", 168 | unique = false 169 | }) 170 | end, 171 | 172 | down = function() 173 | box.space.object:drop() 174 | end, 175 | } 176 | ``` 177 | 178 | Any migration file consists of 2 exported functions (`up` and `down`). 179 | You are free to edit this migration any way you want. 180 | 181 | ## Applying migrations 182 | 183 | You can apply not yet applied migrations by running: 184 | ```lua 185 | box.spacer:migrate_up(n) 186 | ``` 187 | 188 | It accepts `n` - number of migrations to apply (by default `n` is infinity, i.e. apply till the end) 189 | 190 | Current migration version number is stored in the `_schema` space under `_spacer_ver` key: 191 | ``` 192 | tarantool> box.space._schema:select{'_spacer_ver'} 193 | --- 194 | - - ['_spacer_ver', 1516561029, 'init'] 195 | ... 196 | ``` 197 | 198 | ## Rolling back migrations 199 | 200 | If you want to roll back migration you need to run: 201 | ```lua 202 | box.spacer:migrate_down(n) 203 | ``` 204 | 205 | It accepts `n` - number of migrations to rollback (by default `n` is 1, i.e. roll back obly the latest migration). 206 | To rollback all migration just pass any huge number. 207 | 208 | 209 | ## List migrations 210 | 211 | ```lua 212 | box.spacer:list() 213 | ``` 214 | 215 | Returns list of migrations. 216 | ``` 217 | tarantool> box.spacer:list() 218 | --- 219 | - - 1517144699_events.lua 220 | - 1517228368_events_sequence.lua 221 | ... 222 | 223 | tarantool> box.spacer:list(true) 224 | --- 225 | - - version: '1517144699_events' 226 | filename: 1517144699_events.lua 227 | path: ./migrations/1517144699_events.lua 228 | - version: '1517228368_events_sequence' 229 | filename: 1517228368_events_sequence.lua 230 | path: ./migrations/1517228368_events_sequence.lua 231 | ... 232 | 233 | ``` 234 | 235 | ### Options 236 | * `verbose` (default is false) - return verbose information about migrations. If false returns only a list of names. 237 | 238 | 239 | ## Get migration 240 | 241 | ```lua 242 | box.spacer:get(name) 243 | ``` 244 | 245 | Returns information about a migration in the following format: 246 | ``` 247 | tarantool> box.spacer:get('1517144699_events') 248 | --- 249 | - version: 1517144699_events 250 | path: ./migrations/1517144699_events.lua 251 | migration: 252 | up: 'function: 0x40de9090' 253 | down: 'function: 0x40de90b0' 254 | filename: 1517144699_events.lua 255 | ... 256 | ``` 257 | 258 | ### Options 259 | * `name` (optional) - Can be either a filename or migration version. If not specified - the latest migration is returned 260 | * `compile` (default is true) - Perform migration compilation. If false returns only the text of migration. 261 | 262 | ## Get current migration version 263 | 264 | ``` 265 | tarantool> box.spacer:version() 266 | --- 267 | - 1517144699_events 268 | ... 269 | ``` 270 | 271 | Returns current migration's version 272 | 273 | ## migrate_dummy 274 | 275 | You can force spacer to think that the specified migration is already migrated by setting the appropriate `_schema` space key and registering in `_spacer_models` space all models, registered by calling `spacer:space(...)` function. Convinient when you are migrating an already working project to using spacer. 276 | 277 | ```lua 278 | box.spacer:migrate_dummy(name) 279 | ``` 280 | 281 | ## automigrate 282 | 283 | Automatically applies migrations without creating an actual migration file. Useful when changing schema a lot in development. **Highly discouraged to be used in production**. 284 | Call this method after all spacer:space(...) calls like this: 285 | 286 | ```lua 287 | spacer:space({ 288 | name = "object1", 289 | format = { 290 | {name = "id", type = "unsigned"} 291 | }, 292 | indexes = { 293 | {name = "primary", type = "tree", unique = true, parts = {"id"}} 294 | } 295 | }) 296 | 297 | spacer:space({ 298 | name = "object2", 299 | format = { 300 | {name = "id", type = "unsigned"} 301 | }, 302 | indexes = { 303 | {name = "primary", type = "tree", unique = true, parts = {"id"}} 304 | } 305 | }) 306 | 307 | spacer:automigrate() 308 | ``` 309 | 310 | But when you decide that you need to grenerate migrations - either drop your db and create 311 | migrations from scratch or have a loot at [Migrating a project to using spacer](#Migrating-a-project-to-using-spacer). 312 | 313 | ### Options 314 | * `name` (required) - Can be either a filename or version number or full migration name. 315 | 316 | # Migrating a project to using spacer 317 | 318 | In order to use spacer in an already running project you will need to do the following: 319 | 320 | 1. Initialize spacer. 321 | 2. Define all your spaces you want to track by spacer by using `spacer:space()` function as usual. 322 | 3. Create a non-altering migration (meaning that it will assume that spaces does not exist yet) by calling 323 | ``` 324 | spacer:makemigration(, {check_alter = false}) 325 | ``` 326 | 4. Force-apply migration without actually calling it by using 327 | ``` 328 | spacer:migrate_dummy() 329 | ``` 330 | 5. That's all 331 | 332 | After that you can make any changes to the spaces delcarations and track those changes my calling `spacer:makemigration()` function normally and applying migrations with `spacer:migrate_up()` as usual from now on. 333 | 334 | # Fields 335 | 336 | Space fields can be accessed by the global variable `F`, which is set by spacer 337 | or in the spacer directly (`box.spacer.F`): 338 | 339 | ```lua 340 | box.space.space1:update( 341 | {1}, 342 | { 343 | {'=', F.object.name, 'John Watson'}, 344 | } 345 | ) 346 | ``` 347 | 348 | # Transformations 349 | 350 | You can easily transform a given tuple to a dictionary-like object and vice-a-versa. 351 | 352 | These are the functions: 353 | * `T.space_name.dict` or `T.space_name.hash` - transforms a tuple to a dictionary 354 | * `T.space_name.tuple` - transforms a dictionary back to a tuple 355 | 356 | T can be accessed through the global variable `T` or in the spacer directly (`box.spacer.T`) 357 | 358 | ```lua 359 | local john = box.space.object:get({1}) 360 | local john_dict = T.object.dict(john) -- or T.object.hash(john) 361 | --[[ 362 | john_dict = { 363 | id = 1, 364 | name = 'John Watson', 365 | ... 366 | } 367 | --]] 368 | 369 | ``` 370 | 371 | ... or vice-a-versa: 372 | 373 | ```lua 374 | local john_dict = { 375 | id = 1, 376 | name = 'John Watson', 377 | -- ... 378 | } 379 | 380 | local john = T.object.tuple(john_dict) 381 | --[[ 382 | john = [1, 'John Watson', ...] 383 | --]] 384 | 385 | ``` 386 | -------------------------------------------------------------------------------- /cmake/FindTarantool.cmake: -------------------------------------------------------------------------------- 1 | # Define GNU standard installation directories 2 | include(GNUInstallDirs) 3 | 4 | macro(extract_definition name output input) 5 | string(REGEX MATCH "#define[\t ]+${name}[\t ]+\"([^\"]*)\"" 6 | _t "${input}") 7 | string(REGEX REPLACE "#define[\t ]+${name}[\t ]+\"(.*)\"" "\\1" 8 | ${output} "${_t}") 9 | endmacro() 10 | 11 | find_path(TARANTOOL_INCLUDE_DIR tarantool/module.h 12 | HINTS ENV TARANTOOL_DIR /usr/local/include 13 | ) 14 | 15 | if(TARANTOOL_INCLUDE_DIR) 16 | set(_config "-") 17 | file(READ "${TARANTOOL_INCLUDE_DIR}/tarantool/module.h" _config0) 18 | string(REPLACE "\\" "\\\\" _config ${_config0}) 19 | unset(_config0) 20 | extract_definition(PACKAGE_VERSION TARANTOOL_VERSION ${_config}) 21 | extract_definition(INSTALL_PREFIX _install_prefix ${_config}) 22 | unset(_config) 23 | endif() 24 | 25 | include(FindPackageHandleStandardArgs) 26 | find_package_handle_standard_args(TARANTOOL 27 | REQUIRED_VARS TARANTOOL_INCLUDE_DIR VERSION_VAR TARANTOOL_VERSION) 28 | if(TARANTOOL_FOUND) 29 | set(TARANTOOL_INCLUDE_DIRS "${TARANTOOL_INCLUDE_DIR}" 30 | "${TARANTOOL_INCLUDE_DIR}/tarantool/" 31 | CACHE PATH "Include directories for Tarantool") 32 | set(TARANTOOL_INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/tarantool" 33 | CACHE PATH "Directory for storing Lua modules written in Lua") 34 | set(TARANTOOL_INSTALL_LUADIR "${CMAKE_INSTALL_DATADIR}/tarantool" 35 | CACHE PATH "Directory for storing Lua modules written in C") 36 | 37 | if (NOT TARANTOOL_FIND_QUIETLY AND NOT FIND_TARANTOOL_DETAILS) 38 | set(FIND_TARANTOOL_DETAILS ON CACHE INTERNAL "Details about TARANTOOL") 39 | message(STATUS "Tarantool LUADIR is ${TARANTOOL_INSTALL_LUADIR}") 40 | message(STATUS "Tarantool LIBDIR is ${TARANTOOL_INSTALL_LIBDIR}") 41 | endif () 42 | endif() 43 | mark_as_advanced(TARANTOOL_INCLUDE_DIRS TARANTOOL_INSTALL_LIBDIR 44 | TARANTOOL_INSTALL_LUADIR) 45 | -------------------------------------------------------------------------------- /rockspecs/spacer-2.0.1-1.rockspec: -------------------------------------------------------------------------------- 1 | package = 'spacer' 2 | version = '2.0.1-1' 3 | source = { 4 | url = 'git://github.com/igorcoding/tarantool-spacer.git', 5 | tag = 'v2.0.1', 6 | } 7 | description = { 8 | summary = "Tarantool Spacer. Automatic models migrations.", 9 | homepage = 'https://github.com/igorcoding/tarantool-spacer', 10 | license = 'MIT', 11 | } 12 | dependencies = { 13 | 'lua >= 5.1', 14 | 'inspect >= 3.1.0-1', 15 | 'moonwalker' 16 | } 17 | build = { 18 | type = 'cmake', 19 | variables = { 20 | CMAKE_BUILD_TYPE="RelWithDebInfo", 21 | TARANTOOL_INSTALL_LIBDIR="$(LIBDIR)", 22 | TARANTOOL_INSTALL_LUADIR="$(LUADIR)" 23 | } 24 | } 25 | 26 | -- vim: syntax=lua 27 | -------------------------------------------------------------------------------- /rockspecs/spacer-2.1.1-1.rockspec: -------------------------------------------------------------------------------- 1 | package = 'spacer' 2 | version = '2.1.1-1' 3 | source = { 4 | url = 'git://github.com/igorcoding/tarantool-spacer.git', 5 | tag = 'v2.1.1', 6 | } 7 | description = { 8 | summary = "Tarantool Spacer. Automatic models migrations.", 9 | homepage = 'https://github.com/igorcoding/tarantool-spacer', 10 | license = 'MIT', 11 | } 12 | dependencies = { 13 | 'lua >= 5.1', 14 | 'inspect >= 3.1.0-1', 15 | 'moonwalker' 16 | } 17 | build = { 18 | type = 'cmake', 19 | variables = { 20 | CMAKE_BUILD_TYPE="RelWithDebInfo", 21 | TARANTOOL_INSTALL_LIBDIR="$(LIBDIR)", 22 | TARANTOOL_INSTALL_LUADIR="$(LUADIR)" 23 | } 24 | } 25 | 26 | -- vim: syntax=lua 27 | -------------------------------------------------------------------------------- /rockspecs/spacer-2.1.2-1.rockspec: -------------------------------------------------------------------------------- 1 | package = 'spacer' 2 | version = '2.1.2-1' 3 | source = { 4 | url = 'git://github.com/igorcoding/tarantool-spacer.git', 5 | tag = 'v2.1.2', 6 | } 7 | description = { 8 | summary = "Tarantool Spacer. Automatic models migrations.", 9 | homepage = 'https://github.com/igorcoding/tarantool-spacer', 10 | license = 'MIT', 11 | } 12 | dependencies = { 13 | 'lua >= 5.1', 14 | 'inspect >= 3.1.0-1', 15 | 'moonwalker' 16 | } 17 | build = { 18 | type = 'cmake', 19 | variables = { 20 | CMAKE_BUILD_TYPE="RelWithDebInfo", 21 | TARANTOOL_INSTALL_LIBDIR="$(LIBDIR)", 22 | TARANTOOL_INSTALL_LUADIR="$(LUADIR)" 23 | } 24 | } 25 | 26 | -- vim: syntax=lua 27 | -------------------------------------------------------------------------------- /rockspecs/spacer-3.0.1-1.rockspec: -------------------------------------------------------------------------------- 1 | package = 'spacer' 2 | version = '3.0.1-1' 3 | source = { 4 | url = 'git://github.com/igorcoding/tarantool-spacer.git', 5 | tag = 'v3.0.1', 6 | } 7 | description = { 8 | summary = "Tarantool Spacer. Automatic models migrations.", 9 | homepage = 'https://github.com/igorcoding/tarantool-spacer', 10 | license = 'MIT', 11 | } 12 | dependencies = { 13 | 'lua >= 5.1', 14 | 'inspect >= 3.1.0-1', 15 | 'moonwalker' 16 | } 17 | build = { 18 | type = 'cmake', 19 | variables = { 20 | CMAKE_BUILD_TYPE="RelWithDebInfo", 21 | TARANTOOL_INSTALL_LIBDIR="$(LIBDIR)", 22 | TARANTOOL_INSTALL_LUADIR="$(LUADIR)" 23 | } 24 | } 25 | 26 | -- vim: syntax=lua 27 | -------------------------------------------------------------------------------- /rockspecs/spacer-3.1.0-1.rockspec: -------------------------------------------------------------------------------- 1 | package = 'spacer' 2 | version = '3.1.0-1' 3 | source = { 4 | url = 'git://github.com/igorcoding/tarantool-spacer.git', 5 | tag = 'v3.1.0', 6 | } 7 | description = { 8 | summary = "Tarantool Spacer. Automatic models migrations.", 9 | homepage = 'https://github.com/igorcoding/tarantool-spacer', 10 | license = 'MIT', 11 | } 12 | dependencies = { 13 | 'lua >= 5.1', 14 | 'inspect >= 3.1.0-1', 15 | 'moonwalker' 16 | } 17 | build = { 18 | type = 'cmake', 19 | variables = { 20 | CMAKE_BUILD_TYPE="RelWithDebInfo", 21 | TARANTOOL_INSTALL_LIBDIR="$(LIBDIR)", 22 | TARANTOOL_INSTALL_LUADIR="$(LUADIR)" 23 | } 24 | } 25 | 26 | -- vim: syntax=lua 27 | -------------------------------------------------------------------------------- /rockspecs/spacer-3.2.0-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "spacer" 2 | version = "3.2.0-1" 3 | source = { 4 | url = "git+https://github.com/igorcoding/tarantool-spacer.git", 5 | tag = "v3.2.0", 6 | } 7 | description = { 8 | summary = "Tarantool Spacer. Automatic models migrations.", 9 | homepage = "https://github.com/igorcoding/tarantool-spacer", 10 | license = "MIT" 11 | } 12 | dependencies = { 13 | "lua >= 5.1", 14 | "inspect >= 3.1.0-1", 15 | "moonwalker" 16 | } 17 | build = { 18 | type = "cmake", 19 | variables = { 20 | CMAKE_BUILD_TYPE = "RelWithDebInfo", 21 | TARANTOOL_INSTALL_LIBDIR = "$(LIBDIR)", 22 | TARANTOOL_INSTALL_LUADIR = "$(LUADIR)" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /rockspecs/spacer-scm-1.rockspec: -------------------------------------------------------------------------------- 1 | --[[ 2 | *** This is legacy rockspec of deprecated spacer version 3 | *** Please, for this version refer instead to 4 | *** rockspecs/spacer-scm-1.rockspec 5 | *** or update to spacer v2 6 | ]] 7 | 8 | package = 'spacer' 9 | version = 'scm-1' 10 | source = { 11 | url = 'git://github.com/igorcoding/tarantool-spacer.git', 12 | branch = 'v1', 13 | } 14 | description = { 15 | summary = "Spacer for Tarantool. For managing spaces easily.", 16 | homepage = 'https://github.com/igorcoding/tarantool-spacer', 17 | license = 'MIT', 18 | } 19 | dependencies = { 20 | 'lua >= 5.1' 21 | } 22 | build = { 23 | type = 'builtin', 24 | modules = { 25 | ['spacer'] = 'spacer.lua', 26 | } 27 | } 28 | 29 | -- vim: syntax=lua 30 | -------------------------------------------------------------------------------- /rockspecs/spacer-scm-2.rockspec: -------------------------------------------------------------------------------- 1 | package = 'spacer' 2 | version = 'scm-2' 3 | source = { 4 | url = 'git://github.com/igorcoding/tarantool-spacer.git', 5 | branch = 'v2', 6 | } 7 | description = { 8 | summary = "Tarantool Spacer. Automatic models migrations.", 9 | homepage = 'https://github.com/igorcoding/tarantool-spacer', 10 | license = 'MIT', 11 | } 12 | dependencies = { 13 | 'lua >= 5.1', 14 | 'inspect >= 3.1.0-1', 15 | 'moonwalker' 16 | } 17 | build = { 18 | type = 'cmake', 19 | variables = { 20 | CMAKE_BUILD_TYPE="RelWithDebInfo", 21 | TARANTOOL_INSTALL_LIBDIR="$(LIBDIR)", 22 | TARANTOOL_INSTALL_LUADIR="$(LUADIR)" 23 | } 24 | } 25 | 26 | -- vim: syntax=lua 27 | -------------------------------------------------------------------------------- /rockspecs/spacer-scm-3.rockspec: -------------------------------------------------------------------------------- 1 | package = 'spacer' 2 | version = 'scm-3' 3 | source = { 4 | url = 'git+https://github.com/igorcoding/tarantool-spacer.git', 5 | branch = 'master', 6 | } 7 | description = { 8 | summary = "Tarantool Spacer. Automatic models migrations.", 9 | homepage = 'https://github.com/igorcoding/tarantool-spacer', 10 | license = 'MIT', 11 | } 12 | dependencies = { 13 | 'lua >= 5.1', 14 | 'inspect >= 3.1.0-1', 15 | 'moonwalker' 16 | } 17 | build = { 18 | type = 'cmake', 19 | variables = { 20 | CMAKE_BUILD_TYPE="RelWithDebInfo", 21 | TARANTOOL_INSTALL_LIBDIR="$(LIBDIR)", 22 | TARANTOOL_INSTALL_LUADIR="$(LUADIR)" 23 | } 24 | } 25 | 26 | -- vim: syntax=lua 27 | -------------------------------------------------------------------------------- /spacer-scm-1.rockspec: -------------------------------------------------------------------------------- 1 | --[[ 2 | *** This is legacy rockspec of deprecated spacer version 3 | *** Please, for this version refer instead to 4 | *** rockspecs/spacer-scm-1.rockspec 5 | *** or update to spacer v2 6 | ]] 7 | 8 | package = 'spacer' 9 | version = 'scm-1' 10 | source = { 11 | url = 'git://github.com/igorcoding/tarantool-spacer.git', 12 | branch = 'v1', 13 | } 14 | description = { 15 | summary = "Spacer for Tarantool. For managing spaces easily.", 16 | homepage = 'https://github.com/igorcoding/tarantool-spacer', 17 | license = 'MIT', 18 | } 19 | dependencies = { 20 | 'lua >= 5.1' 21 | } 22 | build = { 23 | type = 'builtin', 24 | modules = { 25 | ['spacer'] = 'spacer.lua', 26 | } 27 | } 28 | 29 | -- vim: syntax=lua 30 | -------------------------------------------------------------------------------- /spacer/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | set(spacer_files 2 | init.lua 3 | compat.lua 4 | fileio.lua 5 | inspect.lua 6 | migration.lua 7 | ops.lua 8 | stmt.lua 9 | transformations.lua 10 | util.lua 11 | version.lua 12 | ) 13 | 14 | foreach(file IN LISTS spacer_files) 15 | install(FILES ${file} DESTINATION ${TARANTOOL_INSTALL_LUADIR}/spacer) 16 | endforeach() 17 | -------------------------------------------------------------------------------- /spacer/compat.lua: -------------------------------------------------------------------------------- 1 | local ver = _TARANTOOL 2 | 3 | local function check_version(expected, version) 4 | -- from tarantool/queue compat.lua 5 | local fun = require 'fun' 6 | local iter, op = fun.iter, fun.operator 7 | 8 | local function split(self, sep) 9 | local sep, fields = sep or ":", {} 10 | local pattern = string.format("([^%s]+)", sep) 11 | self:gsub(pattern, function(c) table.insert(fields, c) end) 12 | return fields 13 | end 14 | 15 | local function reducer(res, l, r) 16 | if res ~= nil then 17 | return res 18 | end 19 | if tonumber(l) == tonumber(r) then 20 | return nil 21 | end 22 | return tonumber(l) > tonumber(r) 23 | end 24 | 25 | local function split_version(version_string) 26 | local vtable = split(version_string, '.') 27 | local vtable2 = split(vtable[3], '-') 28 | vtable[3], vtable[4] = vtable2[1], vtable2[2] 29 | return vtable 30 | end 31 | 32 | local function check_version_internal(expected, version) 33 | version = version or _TARANTOOL 34 | if type(version) == 'string' then 35 | version = split_version(version) 36 | end 37 | local res = iter(version):zip(expected):reduce(reducer, nil) 38 | 39 | if res or res == nil then res = true end 40 | return res 41 | end 42 | 43 | return check_version_internal(expected, version) 44 | end 45 | 46 | local function compat_type(type) 47 | type = string.lower(type) 48 | if check_version({1, 7}, ver) then 49 | if type == 'string' or type == 'str' then 50 | return 'string' 51 | end 52 | 53 | if type == 'unsigned' or type =='uint' or type == 'num' then 54 | return 'unsigned' 55 | end 56 | 57 | if type == 'integer' or type == 'int' then 58 | return 'integer' 59 | end 60 | else 61 | if type == 'string' or type == 'str' then 62 | return 'str' 63 | end 64 | 65 | if type == 'unsigned' or type =='uint' or type == 'num' then 66 | return 'num' 67 | end 68 | 69 | if type == 'integer' or type == 'int' then 70 | return 'int' 71 | end 72 | end 73 | 74 | return type 75 | end 76 | 77 | local function index_parts_from_fields(space_name, fields, f_extra) 78 | if fields == nil then 79 | if check_version({1, 7}, ver) then 80 | return {{1, 'unsigned'}} 81 | else 82 | return {1, 'NUM'} 83 | end 84 | end 85 | 86 | if check_version({1, 7}, ver) then 87 | local parts = {} 88 | for _, p in ipairs(fields) do 89 | local part = {} 90 | local f_info = f_extra[p] 91 | if f_info ~= nil then 92 | table.insert(part, f_info.fieldno) 93 | table.insert(part, compat_type(f_info.type)) 94 | 95 | if check_version({1, 7, 6}, ver) then 96 | part.is_nullable = f_info.is_nullable 97 | part.collation = f_info.collation 98 | end 99 | 100 | table.insert(parts, part) 101 | else 102 | error(string.format("Field %s.%s not found", space_name, p)) 103 | end 104 | end 105 | return parts 106 | else 107 | local parts = {} 108 | for _, p in ipairs(fields) do 109 | local f_info = f_extra[p] 110 | if f_info ~= nil then 111 | table.insert(parts, f_info.fieldno) 112 | table.insert(parts, compat_type(f_info.type)) 113 | else 114 | error(string.format("Field %s.%s not found", space_name, p)) 115 | end 116 | end 117 | return parts 118 | end 119 | end 120 | 121 | local function normalize_index_tuple_format(format, is_raw_tuple) 122 | if is_raw_tuple == nil then 123 | is_raw_tuple = false 124 | end 125 | 126 | if format == nil then 127 | return nil 128 | end 129 | 130 | if #format == 0 then 131 | return {} 132 | end 133 | 134 | if type(format[1]) == 'table' then 135 | -- 1.7+ format 136 | 137 | local parts = {} 138 | for _, p in ipairs(format) do 139 | local part 140 | if #format[1] == 0 then 141 | -- 1.7.6+ format (with is_nullable or collation) like { {fieldno = 1, type = 'unsigned', is_nullable = true}, ... } 142 | local fieldno = p.fieldno 143 | if fieldno == nil then 144 | fieldno = p.field + 1 -- because fields are indexed from 0 in raw tuples 145 | end 146 | part = { 147 | fieldno = fieldno, 148 | ['type'] = compat_type(p.type), 149 | is_nullable = p.is_nullable, 150 | collation = p.collation 151 | } 152 | else 153 | -- <1.7.6 format { {1, 'unsigned'}, {2, 'string'}, ... } 154 | -- but it can contain is_nullable and collation in a 'map' of each field 155 | local fieldno = p[1] 156 | if is_raw_tuple then 157 | fieldno = fieldno + 1 158 | end 159 | part = { 160 | fieldno = fieldno, 161 | ['type'] = compat_type(p[2]), 162 | is_nullable = p.is_nullable, 163 | collation = p.collation 164 | } 165 | end 166 | 167 | table.insert(parts, part) 168 | end 169 | return parts 170 | else 171 | -- 1.6 format like {1, 'num', 2, 'string', ...} 172 | local parts = {} 173 | assert(#format % 2 == 0, 'format length must be even') 174 | for i = 1, #format, 2 do 175 | table.insert(parts, { 176 | fieldno = format[i], 177 | ['type'] = compat_type(format[i + 1]), 178 | is_nullable = false, 179 | collation = nil 180 | }) 181 | end 182 | return parts 183 | end 184 | end 185 | 186 | 187 | local function index_parts_from_normalized(normalized_parts) 188 | if check_version({1, 7}, ver) then 189 | local parts = {} 190 | for _, p in ipairs(normalized_parts) do 191 | local part = {p.fieldno, compat_type(p.type)} 192 | 193 | if check_version({1, 7, 6}, ver) then 194 | part.is_nullable = p.is_nullable 195 | part.collation = p.collation 196 | end 197 | 198 | table.insert(parts, part) 199 | end 200 | return parts 201 | else 202 | local parts = {} 203 | for _, p in ipairs(normalized_parts) do 204 | table.insert(parts, p.fieldno) 205 | table.insert(parts, compat_type(p.type)) 206 | end 207 | return parts 208 | end 209 | end 210 | 211 | 212 | local function get_default_for_type(type, field_name, indexes_decl) 213 | type = string.lower(type) 214 | if indexes_decl == nil then 215 | indexes_decl = {} 216 | end 217 | 218 | if type == 'unsigned' or type == 'uint' or type == 'num' then 219 | return 0 220 | end 221 | 222 | if type == 'integer' or type == 'int' then 223 | return 0 224 | end 225 | 226 | if type == 'number' then 227 | return 0 228 | end 229 | 230 | if type == 'string' or type == 'str' then 231 | return "" 232 | end 233 | 234 | if type == 'boolean' then 235 | return false 236 | end 237 | 238 | if type == 'array' then 239 | if field_name == nil then 240 | return {} 241 | end 242 | 243 | for _, ind in ipairs(indexes_decl) do 244 | if string.lower(ind.type) == 'rtree' 245 | and #ind.parts > 0 246 | and ind.parts[1] == field_name then 247 | local dim = ind.dimension 248 | if dim == nil then 249 | dim = 2 250 | end 251 | 252 | local t = {} 253 | for _ = 1,dim do 254 | table.insert(t, 0) 255 | end 256 | return t 257 | end 258 | end 259 | 260 | return {} 261 | end 262 | 263 | if type == 'map' then 264 | return setmetatable({}, {__serialize = 'map'}) 265 | end 266 | 267 | if type == 'scalar' then 268 | return 0 269 | end 270 | 271 | error(string.format('unknown type "%s"', type)) 272 | end 273 | 274 | return { 275 | check_version = check_version, 276 | index_parts_from_fields = index_parts_from_fields, 277 | normalize_index_tuple_format = normalize_index_tuple_format, 278 | index_parts_from_normalized = index_parts_from_normalized, 279 | get_default_for_type = get_default_for_type, 280 | } 281 | -------------------------------------------------------------------------------- /spacer/fileio.lua: -------------------------------------------------------------------------------- 1 | local errno = require 'errno' 2 | local fio = require 'fio' 3 | 4 | local compat = require 'spacer.compat' 5 | 6 | local fileio = {} 7 | 8 | local modes = fio.c.mode 9 | local perms = bit.bor(modes.S_IRUSR, modes.S_IWUSR, 10 | modes.S_IRGRP, modes.S_IWGRP, 11 | modes.S_IROTH, modes.S_IWOTH) 12 | local folder_perms = bit.bor(modes.S_IRUSR, modes.S_IWUSR, modes.S_IXUSR, 13 | modes.S_IRGRP, modes.S_IWGRP, modes.S_IXGRP, 14 | modes.S_IROTH, modes.S_IXOTH) 15 | 16 | 17 | function fileio.get_mode(file_path) 18 | local stat = fio.stat(file_path) 19 | if stat:is_dir() then 20 | return 'directory' 21 | end 22 | return 'file' 23 | end 24 | 25 | 26 | local function merge_tables(t, ...) 27 | for _, tt in ipairs({...}) do 28 | for _, v in ipairs(tt) do 29 | table.insert(t, v) 30 | end 31 | end 32 | return t 33 | end 34 | 35 | 36 | function fileio.listdir(path) 37 | local files = {} 38 | for _, postfix in ipairs({'/*', '/.*'}) do 39 | for _, file in ipairs(fio.glob(path .. postfix)) do 40 | if fio.basename(file) ~= "." and fio.basename(file) ~= ".." then 41 | local mode = fileio.get_mode(file) 42 | table.insert(files, { 43 | mode = mode, 44 | path = file 45 | }) 46 | if mode == "directory" then 47 | files = merge_tables(files, fileio.listdir(file)) 48 | end 49 | end 50 | end 51 | end 52 | return files 53 | end 54 | 55 | 56 | function fileio.read_file(filepath) 57 | local fh = fio.open(filepath, {'O_RDONLY'}) 58 | if not fh then 59 | error(string.format("Failed to open file %s: %s", filepath, errno.strerror())) 60 | end 61 | 62 | local data 63 | if compat.check_version({1, 9}) then 64 | data = fh:read() 65 | else 66 | data = fh:read(fh:stat().size) 67 | end 68 | 69 | fh:close() 70 | return data 71 | end 72 | 73 | 74 | function fileio.write_to_file(filepath, data) 75 | local local_perms = perms 76 | 77 | local fh = fio.open(filepath, {'O_WRONLY', 'O_CREAT'}, local_perms) 78 | if not fh then 79 | error(string.format("Failed to open file %s: %s", filepath, errno.strerror())) 80 | end 81 | 82 | fh:write(data) 83 | fh:close() 84 | end 85 | 86 | 87 | function fileio.mkdir(path) 88 | local ok = fio.mkdir(path, folder_perms) 89 | if not ok then 90 | error(string.format("Could not create folder %s: %s", path, errno.strerror())) 91 | end 92 | end 93 | 94 | 95 | function fileio.exists(path) 96 | return fio.stat(path) ~= nil 97 | end 98 | 99 | 100 | return fileio 101 | -------------------------------------------------------------------------------- /spacer/init.lua: -------------------------------------------------------------------------------- 1 | local clock = require "clock" 2 | local errno = require "errno" 3 | local fio = require "fio" 4 | local fun = require "fun" 5 | local log = require "log" 6 | 7 | local compat = require "spacer.compat" 8 | local fileio = require "spacer.fileio" 9 | local inspect = require "spacer.inspect" 10 | local space_migration = require "spacer.migration" 11 | local util = require "spacer.util" 12 | local transformations = require "spacer.transformations" 13 | local lversion = require "spacer.version" 14 | 15 | local NULL = require "msgpack".NULL 16 | 17 | local _init_models_space -- forward declaration 18 | 19 | local function _init_fields_and_transform(self, space_name, format) 20 | local f, f_extra = space_migration.generate_field_info(format) 21 | self.F[space_name] = f 22 | self.F_FULL[space_name] = f_extra 23 | self.T[space_name] = {} 24 | self.T[space_name].dict = transformations.tuple2hash(f) 25 | self.T[space_name].tuple = transformations.hash2tuple(f) 26 | self.T[space_name].hash = self.T[space_name].dict -- alias 27 | end 28 | 29 | local function automigrate(self) 30 | local m, have_changes = 31 | self:_makemigration( 32 | "automigration", 33 | { 34 | autogenerate = true, 35 | nofile = true 36 | } 37 | ) 38 | 39 | if not have_changes then 40 | log.info("spacer.automigrate detected no changes in schema") 41 | return 42 | end 43 | 44 | local compiled_code, err, ok 45 | compiled_code, err = loadstring(m) 46 | if compiled_code == nil then 47 | error(string.format("Cannot automigrate due to error: %s", err)) 48 | return 49 | end 50 | 51 | ok, err = self:_migrate_one_up(compiled_code) 52 | if not ok then 53 | error(string.format("Error while applying migration: %s", err)) 54 | end 55 | 56 | log.info("spacer performed automigration: %s", m) 57 | end 58 | 59 | local function _space(self, name, format, indexes, opts) 60 | assert(name ~= nil, "Space name cannot be null") 61 | assert(format ~= nil, "Space format cannot be null") 62 | assert(indexes ~= nil, "Space indexes cannot be null") 63 | self.__models__[name] = { 64 | type = "raw", 65 | space_name = name, 66 | space_format = format, 67 | space_indexes = indexes, 68 | space_opts = opts 69 | } 70 | _init_fields_and_transform(self, name, format) 71 | end 72 | 73 | local function space(self, space_decl) 74 | assert(space_decl ~= nil, "Space declaration is missing") 75 | return self:_space(space_decl.name, space_decl.format, space_decl.indexes, space_decl.opts) 76 | end 77 | 78 | local function space_drop(self, name) 79 | assert(name ~= nil, "Space name cannot be null") 80 | assert(self.__models__[name] ~= nil, "Space is not defined") 81 | 82 | self.__models__[name] = nil 83 | self.F[name] = nil 84 | self.F_FULL[name] = nil 85 | self.T[name] = nil 86 | end 87 | 88 | local function _schema_set_version(self, version) 89 | version = tostring(version) 90 | return box.space._schema:replace({self.schema_key, version}) 91 | end 92 | 93 | local function _schema_del_version(self) 94 | return box.space._schema:delete({self.schema_key}) 95 | end 96 | 97 | local function _schema_get_version_tuple(self) 98 | local t = box.space._schema:get({self.schema_key}) 99 | if t == nil then 100 | return nil 101 | end 102 | 103 | if #t > 2 then -- contains version and name separately 104 | local version = string.format("%s_%s", t[2], t[3]) 105 | return _schema_set_version(self, version) 106 | end 107 | 108 | return t 109 | end 110 | 111 | local function _schema_get_version(self) 112 | local t = _schema_get_version_tuple(self) 113 | if t == nil then 114 | return nil 115 | end 116 | return t[2] 117 | end 118 | 119 | --- 120 | --- _migrate_one_up function 121 | --- 122 | local function _migrate_one_up(self, migration) 123 | setfenv(migration, _G) 124 | local funcs = migration() 125 | assert(funcs ~= nil, "Migration file should return { up = function() ... end, down = function() ... end } table") 126 | assert(funcs.up ~= nil, "up function is required") 127 | local ok, err = pcall(funcs.up) 128 | if not ok then 129 | return false, err 130 | end 131 | return true, nil 132 | end 133 | 134 | --- 135 | --- migrate_up function 136 | --- 137 | local function migrate_up(self, _n) 138 | if _init_models_space(self) == nil then 139 | error('instance is not writable') 140 | end 141 | local n = tonumber(_n) 142 | if n == nil and _n ~= nil then 143 | error("n must be a number or nil") 144 | end 145 | 146 | local version = _schema_get_version(self) 147 | local migrations = util.read_migrations(self.migrations_path, "up", version, n) 148 | 149 | if #migrations == 0 then 150 | log.info("No migrations to apply. Last migration: %s", inspect(version)) 151 | return nil 152 | end 153 | 154 | for _, m in ipairs(migrations) do 155 | local ok, err = self:_migrate_one_up(m.migration) 156 | if not ok then 157 | log.error("Error running migration %s: %s", m.filename, err) 158 | return { 159 | version = m.version, 160 | migration = m.filename, 161 | error = err 162 | } 163 | end 164 | 165 | _schema_set_version(self, m.version) 166 | log.info('Applied migration "%s"', m.filename) 167 | end 168 | 169 | return nil 170 | end 171 | 172 | --- 173 | --- _migrate_one_down function 174 | --- 175 | local function _migrate_one_down(self, migration) 176 | setfenv(migration, _G) 177 | local funcs = migration() 178 | assert(funcs ~= nil, "Migration file should return { up = function() ... end, down = function() ... end } table") 179 | assert(funcs.down ~= nil, "down function is required") 180 | local ok, err = pcall(funcs.down) 181 | if not ok then 182 | return false, err 183 | end 184 | return true, nil 185 | end 186 | 187 | --- 188 | --- migrate_down function 189 | --- 190 | local function migrate_down(self, _n) 191 | if _init_models_space(self) == nil then 192 | error('instance is not writable') 193 | end 194 | 195 | local n = tonumber(_n) 196 | if n == nil and _n ~= nil then 197 | error("n must be a number or nil") 198 | end 199 | 200 | if n == nil then 201 | n = 1 202 | end 203 | 204 | local version = _schema_get_version(self) 205 | local migrations = util.read_migrations(self.migrations_path, "down", version, n + 1) 206 | 207 | if #migrations == 0 then 208 | log.info("No migrations to apply. Last migration: %s", inspect(version)) 209 | return nil 210 | end 211 | 212 | for i, m in ipairs(migrations) do 213 | local ok, err = self:_migrate_one_down(m.migration) 214 | if not ok then 215 | log.error("Error running migration %s: %s", m.filename, err) 216 | return { 217 | version = m.version, 218 | migration = m.filename, 219 | error = err 220 | } 221 | end 222 | 223 | local prev_migration = migrations[i + 1] 224 | if prev_migration == nil then 225 | _schema_del_version(self) 226 | else 227 | _schema_set_version(self, prev_migration.version) 228 | end 229 | log.info('Rolled back migration "%s"', m.filename) 230 | n = n - 1 231 | if n == 0 then 232 | break 233 | end 234 | end 235 | 236 | return nil 237 | end 238 | 239 | --- 240 | --- makemigration function 241 | --- 242 | local function _makemigration(self, name, opts) 243 | assert(name ~= nil, "Migration name is required") 244 | if opts == nil then 245 | opts = {} 246 | end 247 | 248 | if opts.autogenerate == nil then 249 | opts.autogenerate = true 250 | end 251 | 252 | if opts.check_alter == nil then 253 | opts.check_alter = true 254 | end 255 | 256 | if opts.allow_empty == nil then 257 | opts.allow_empty = true 258 | end 259 | 260 | local date = clock.time() 261 | util.check_version_exists(self.migrations_path, date, name) 262 | 263 | local requirements_body = "" 264 | local up_body = "" 265 | local down_body = "" 266 | local have_changes = false 267 | if opts.autogenerate then 268 | local migration = space_migration.spaces_migration(self, self.__models__, opts.check_alter) 269 | requirements_body = 270 | table.concat( 271 | fun.iter(migration.requirements):map( 272 | function(key, r) 273 | return string.format("local %s = require '%s'", r.name, key) 274 | end 275 | ):totable(), 276 | "\n" 277 | ) 278 | 279 | local tab = string.rep(" ", 8) 280 | 281 | if #migration.up > 0 or #migration.down > 0 then 282 | have_changes = true 283 | end 284 | 285 | up_body = util.tabulate_string(table.concat(migration.up, "\n"), tab) 286 | down_body = util.tabulate_string(table.concat(migration.down, "\n"), tab) 287 | end 288 | 289 | local migration_body = 290 | string.format( 291 | [[--- 292 | --- Migration "%s" 293 | --- Date: %d - %s 294 | --- 295 | %s 296 | 297 | return { 298 | up = function() 299 | %s 300 | end, 301 | 302 | down = function() 303 | %s 304 | end, 305 | } 306 | ]], 307 | lversion.new(date, name), 308 | date, 309 | os.date("%x %X", date), 310 | requirements_body, 311 | up_body, 312 | down_body 313 | ) 314 | 315 | if not opts.nofile and (have_changes or (not have_changes and opts.allow_empty)) then 316 | local path = fio.pathjoin(self.migrations_path, lversion.new(date, name):str(".lua")) 317 | fileio.write_to_file(path, migration_body) 318 | end 319 | return migration_body, have_changes 320 | end 321 | local function makemigration(self, ...) 322 | self:_makemigration(...) 323 | end 324 | 325 | --- 326 | --- models_space function 327 | --- 328 | local function models_space(self) 329 | return box.space[self.models_space_name] 330 | end 331 | 332 | --- 333 | --- clear_schema function 334 | --- 335 | local function clear_schema(self) 336 | _schema_del_version(self) 337 | self:models_space():truncate() 338 | end 339 | 340 | --- 341 | --- get function 342 | --- 343 | local function get(self, version, compile) 344 | if version == nil then 345 | version = self:version() 346 | end 347 | return util.read_migration(self.migrations_path, tostring(version), compile) 348 | end 349 | 350 | --- 351 | --- list function 352 | --- 353 | local function list(self, verbose) 354 | if verbose == nil then 355 | verbose = false 356 | end 357 | return util.list_migrations(self.migrations_path, verbose) 358 | end 359 | 360 | --- 361 | --- version function 362 | --- 363 | local function version(self) 364 | local v = _schema_get_version(self) 365 | if v == nil then 366 | return nil 367 | end 368 | 369 | return lversion.parse(v) 370 | end 371 | 372 | --- 373 | --- migrate_dummy function 374 | --- 375 | local function migrate_dummy(self, version) 376 | local m = self:get(version, false) 377 | if m == nil then 378 | return error(string.format("migration %s not found", tostring(version))) 379 | end 380 | 381 | _schema_set_version(self, m.version) 382 | box.begin() 383 | for name, _ in pairs(self.__models__) do 384 | self:models_space():replace({name}) 385 | end 386 | box.commit() 387 | end 388 | 389 | _init_models_space = function(self) 390 | if box.info.ro then 391 | return 392 | end 393 | 394 | local sp = box.schema.create_space(self.models_space_name, {if_not_exists = true}) 395 | sp:format( 396 | { 397 | {name = "name", type = "string"} 398 | } 399 | ) 400 | local parts = 401 | compat.normalize_index_tuple_format( 402 | { 403 | {1, "string"} 404 | } 405 | ) 406 | parts = compat.index_parts_from_normalized(parts) 407 | 408 | sp:create_index( 409 | "primary", 410 | { 411 | parts = parts, 412 | if_not_exists = true 413 | } 414 | ) 415 | 416 | return sp 417 | end 418 | 419 | local spacer_mt = { 420 | __index = { 421 | _space = _space, 422 | _migrate_one_up = _migrate_one_up, 423 | _migrate_one_down = _migrate_one_down, 424 | _makemigration = _makemigration, 425 | space = space, 426 | space_drop = space_drop, 427 | migrate_up = migrate_up, 428 | migrate_down = migrate_down, 429 | migrate_dummy = migrate_dummy, 430 | makemigration = makemigration, 431 | automigrate = automigrate, 432 | clear_schema = clear_schema, 433 | models_space = models_space, 434 | get = get, 435 | list = list, 436 | version = version 437 | } 438 | } 439 | 440 | local function new_spacer(user_opts) 441 | local valid_options = { 442 | name = { 443 | required = false, 444 | default = "" 445 | }, 446 | migrations = { 447 | required = true, 448 | self_name = "migrations_path" 449 | }, 450 | global_ft = { 451 | required = false, 452 | default = true 453 | }, 454 | keep_obsolete_spaces = { 455 | required = false, 456 | default = false 457 | }, 458 | keep_obsolete_indexes = { 459 | required = false, 460 | default = false 461 | }, 462 | down_migration_fail_on_impossible = { 463 | required = false, 464 | default = true 465 | }, 466 | hints_enabled = { 467 | required = false, 468 | default = nil, 469 | }, 470 | } 471 | 472 | local opts = {} 473 | local invalid_options = {} 474 | -- check user provided options 475 | for key, value in pairs(user_opts) do 476 | local opt_info = valid_options[key] 477 | if opt_info == nil then 478 | table.insert(invalid_options, key) 479 | else 480 | if opt_info.required and value == nil then 481 | error(string.format('Option "%s" is required', key)) 482 | elseif value == nil then 483 | opts[key] = opt_info.default 484 | else 485 | opts[key] = value 486 | end 487 | end 488 | end 489 | 490 | if #invalid_options > 0 then 491 | error(string.format("Unknown options provided: [%s]", table.concat(invalid_options, ", "))) 492 | end 493 | 494 | -- check that user provided all required options 495 | for valid_key, opt_info in pairs(valid_options) do 496 | local value = user_opts[valid_key] 497 | if opt_info.required and value == nil then 498 | error(string.format('Option "%s" is required', valid_key)) 499 | elseif user_opts[valid_key] == nil then 500 | opts[valid_key] = opt_info.default 501 | else 502 | opts[valid_key] = value 503 | end 504 | end 505 | 506 | if not fileio.exists(opts.migrations) then 507 | if not fio.mkdir(opts.migrations) then 508 | local e = errno() 509 | error(string.format("Couldn't create migrations dir '%s': %d/%s", opts.migrations, e, errno.strerror(e))) 510 | end 511 | end 512 | 513 | local self = 514 | setmetatable( 515 | { 516 | name = "", 517 | migrations_path = NULL, 518 | keep_obsolete_spaces = NULL, 519 | keep_obsolete_indexes = NULL, 520 | down_migration_fail_on_impossible = NULL, 521 | __models__ = {}, 522 | F = {}, 523 | F_FULL = {}, 524 | T = {} 525 | }, 526 | spacer_mt 527 | ) 528 | 529 | for valid_key, opt_info in pairs(valid_options) do 530 | if opt_info.self_name == nil then 531 | opt_info.self_name = valid_key 532 | end 533 | 534 | self[opt_info.self_name] = opts[valid_key] 535 | end 536 | 537 | local name_suffix = "" 538 | if self.name ~= "" then 539 | name_suffix = "_" .. self.name 540 | end 541 | self.schema_key = string.format("_spacer%s_ver", name_suffix) 542 | self.models_space_name = string.format("_spacer%s_models", name_suffix) 543 | 544 | -- initialize current spaces fields and transformations 545 | for _, sp in box.space._vspace:pairs() do 546 | _init_fields_and_transform(self, sp[3], sp[7]) 547 | end 548 | 549 | if opts.global_ft then 550 | rawset(_G, "F", self.F) 551 | rawset(_G, "F_FULL", self.F_FULL) 552 | rawset(_G, "T", self.T) 553 | end 554 | 555 | _init_models_space(self) 556 | 557 | if rawget(_G, "__spacer__") == nil then 558 | rawset(_G, "__spacer__", {}) 559 | end 560 | rawget(_G, "__spacer__")[self.name] = self 561 | 562 | return self 563 | end 564 | 565 | local spacers = rawget(_G, "__spacer__") 566 | if spacers ~= nil then 567 | -- 2nd+ load 568 | 569 | local M 570 | for k, M in pairs(spacers) do 571 | local m 572 | local prune_models = {} 573 | for m, _ in pairs(M.F) do 574 | if box.space._vspace.index.name:get({m}) == nil then 575 | table.insert(prune_models, m) 576 | end 577 | end 578 | 579 | M.__models__ = {} -- clean all loaded models 580 | for _, m in ipairs(prune_models) do 581 | M.F[m] = nil 582 | M.F_FULL[m] = nil 583 | M.T[m] = nil 584 | end 585 | 586 | -- initialize current spaces fields and transformations 587 | for _, sp in box.space._vspace:pairs() do 588 | _init_fields_and_transform(M, sp[3], sp[7]) 589 | end 590 | end 591 | end 592 | 593 | return { 594 | new = new_spacer, 595 | get = function(name) 596 | local spacers = rawget(_G, "__spacer__") 597 | if spacers == nil then 598 | error("no spacer created yet") 599 | end 600 | 601 | local self = spacers[name or ""] 602 | if self == nil then 603 | error(string.format("spacer %s not found", name)) 604 | end 605 | 606 | return self 607 | end 608 | } 609 | -------------------------------------------------------------------------------- /spacer/inspect.lua: -------------------------------------------------------------------------------- 1 | local inspect = require 'inspect' 2 | 3 | local remove_all_metatables = function(item, path) 4 | if path[#path] ~= inspect.METATABLE then 5 | return item 6 | end 7 | end 8 | 9 | return function(...) 10 | return inspect.inspect(..., {process = remove_all_metatables}) 11 | end 12 | -------------------------------------------------------------------------------- /spacer/migration.lua: -------------------------------------------------------------------------------- 1 | local compat = require "spacer.compat" 2 | local inspect = require "spacer.inspect" 3 | local ops = require "spacer.ops" 4 | local stmt_obj = require "spacer.stmt" 5 | local util = require "spacer.util" 6 | 7 | local function generate_field_info(space_format) 8 | local f = {} 9 | local f_extra = {} 10 | for k, v in ipairs(space_format) do 11 | f[v.name] = k 12 | 13 | local is_nullable = v.is_nullable 14 | if is_nullable == nil then 15 | is_nullable = false 16 | end 17 | 18 | f_extra[v.name] = { 19 | fieldno = k, 20 | type = v.type, 21 | is_nullable = is_nullable, 22 | collation = v.collation 23 | } 24 | end 25 | return f, f_extra 26 | end 27 | 28 | local function get_changed_opts_for_index(spacer, space_name, existing_index, ind_opts) 29 | if existing_index == nil or ind_opts == nil then 30 | return nil 31 | end 32 | 33 | local space_tuple = box.space._vspace.index.name:get({space_name}) 34 | assert(space_tuple ~= nil, string.format('Space "%s" not found', space_name)) 35 | local index_tuple = box.space._vindex:get({space_tuple[1], existing_index.id}) 36 | assert(index_tuple ~= nil, string.format('Index #%d not found in space "%s"', existing_index.id, space_name)) 37 | 38 | local opts_up = {} 39 | local opts_down = {} 40 | 41 | local changed_opts_count = 0 42 | 43 | if ind_opts.unique == nil then 44 | ind_opts.unique = true -- default value of unique 45 | end 46 | 47 | local index_type = string.lower(existing_index.type) 48 | if index_type ~= "bitset" and index_type ~= "rtree" and existing_index.unique ~= ind_opts.unique then 49 | opts_up.unique = ind_opts.unique 50 | opts_down.unique = existing_index.unique 51 | changed_opts_count = changed_opts_count + 1 52 | end 53 | 54 | if index_type ~= string.lower(ind_opts.type) then 55 | opts_up.type = ind_opts.type 56 | opts_down.type = index_type 57 | changed_opts_count = changed_opts_count + 1 58 | end 59 | 60 | if true then -- check sequence changes 61 | local existing_seq 62 | local seq_changed = false 63 | 64 | local fld_sequence_name = 3 65 | local old_sequence_value 66 | 67 | if existing_index.sequence_id ~= nil then 68 | -- check if some sequence actually exist 69 | existing_seq = box.space._sequence:get({existing_index.sequence_id}) 70 | end 71 | 72 | if type(ind_opts.sequence) == "boolean" then 73 | -- user specified just 'true' or 'false' as sequence, so any sequence is ok 74 | if ind_opts.sequence == true and existing_seq == nil then 75 | seq_changed = true 76 | old_sequence_value = false 77 | elseif ind_opts.sequence == false and existing_seq ~= nil then 78 | seq_changed = true 79 | old_sequence_value = existing_seq[fld_sequence_name] 80 | end 81 | elseif ind_opts.sequence == nil then 82 | -- changed to not using sequence 83 | if existing_seq ~= nil then 84 | seq_changed = true 85 | old_sequence_value = existing_seq[fld_sequence_name] 86 | end 87 | elseif type(ind_opts.sequence) == "string" then 88 | if existing_seq == nil or existing_seq[fld_sequence_name] ~= ind_opts.sequence then 89 | seq_changed = true 90 | old_sequence_value = existing_seq[fld_sequence_name] 91 | end 92 | else 93 | seq_changed = true 94 | old_sequence_value = existing_seq[fld_sequence_name] 95 | end 96 | 97 | if seq_changed then 98 | opts_up.sequence = ind_opts.sequence 99 | opts_down.sequence = old_sequence_value 100 | changed_opts_count = changed_opts_count + 1 101 | end 102 | end 103 | 104 | if ind_opts.type == "rtree" then 105 | if ind_opts.dimension == nil then 106 | ind_opts.dimension = 2 -- default value for dimension 107 | end 108 | 109 | if existing_index.dimension ~= ind_opts.dimension then 110 | opts_up.dimension = ind_opts.dimension 111 | opts_down.dimension = existing_index.dimension 112 | changed_opts_count = changed_opts_count + 1 113 | end 114 | 115 | if existing_index.distance ~= ind_opts.distance then 116 | opts_up.distance = ind_opts.distance 117 | opts_down.distance = existing_index.distance 118 | changed_opts_count = changed_opts_count + 1 119 | end 120 | end 121 | 122 | local parts_changed = false 123 | assert(ind_opts.parts ~= nil, "index parts must not be nil") 124 | local old_parts = compat.normalize_index_tuple_format(index_tuple[6], true) 125 | local new_parts = compat.normalize_index_tuple_format(ind_opts.parts) 126 | 127 | if #old_parts ~= #new_parts then 128 | parts_changed = true 129 | else 130 | for i, _ in ipairs(old_parts) do 131 | local old_part = old_parts[i] 132 | local new_part = new_parts[i] 133 | 134 | for k, _ in pairs(old_part) do 135 | -- check all keys (fieldno, type, is_nullable, collation, ...) 136 | if old_part[k] ~= new_part[k] then 137 | parts_changed = true 138 | break 139 | end 140 | end 141 | end 142 | end 143 | 144 | if parts_changed then 145 | opts_up.parts = compat.index_parts_from_normalized(new_parts) 146 | opts_down.parts = compat.index_parts_from_normalized(old_parts) 147 | changed_opts_count = changed_opts_count + 1 148 | end 149 | 150 | if changed_opts_count == 0 then 151 | return nil, nil 152 | end 153 | 154 | return opts_up, opts_down 155 | end 156 | 157 | local function build_opts_for_space(spacer, space_name) 158 | local sp = box.space[space_name] 159 | if sp == nil then 160 | return nil 161 | end 162 | 163 | local space_opts = { 164 | engine = sp.engine, 165 | temporary = sp.temporary, 166 | field_count = sp.field_count, 167 | user = sp.user 168 | } 169 | 170 | return space_opts 171 | end 172 | 173 | local function build_opts_for_index(spacer, space_name, index_id) 174 | local sp = box.space[space_name] 175 | if sp == nil then 176 | return nil 177 | end 178 | 179 | local ind = sp.index[index_id] 180 | if ind == nil then 181 | return nil 182 | end 183 | 184 | local raw_index_options = box.space._vindex:get({sp.id, ind.id}) 185 | if raw_index_options == nil then 186 | return nil 187 | end 188 | raw_index_options = raw_index_options[5] 189 | 190 | local index_opts = {} 191 | 192 | index_opts.id = ind.id 193 | index_opts.type = ind.type 194 | if string.lower(index_opts.type) == "rtree" then 195 | index_opts.unique = false 196 | else 197 | index_opts.unique = ind.unique 198 | end 199 | index_opts.distance = raw_index_options.distance 200 | index_opts.dimension = raw_index_options.dimension 201 | if ind.sequence_id ~= nil then 202 | index_opts.sequence = true 203 | end 204 | 205 | index_opts.parts = {} 206 | for _, p in ipairs(ind.parts) do 207 | local part = {p.fieldno, p.type} 208 | part.is_nullable = p.is_nullable 209 | part.collation = p.collation 210 | 211 | table.insert(index_opts.parts, part) 212 | end 213 | 214 | return index_opts 215 | end 216 | 217 | local function indexes_migration(spacer, space_name, indexes, f, f_extra, check_alter) 218 | if check_alter == nil then 219 | check_alter = true 220 | end 221 | 222 | local up = {} 223 | local down = {} 224 | local created_indexes = {} 225 | 226 | for _, ind in ipairs(indexes) do 227 | assert(ind.name ~= nil, string.format("Index name cannot be null (space '%s')", space_name)) 228 | if ind.type == nil then 229 | ind.type = "tree" 230 | end 231 | 232 | local ind_opts = {} 233 | ind_opts.id = ind.id 234 | ind_opts.type = string.lower(ind.type) 235 | if ind_opts.type == "rtree" then 236 | if ind.unique == true then 237 | return error("RTREE indexes cannot be unique") 238 | end 239 | ind_opts.unique = false 240 | else 241 | ind_opts.unique = ind.unique 242 | end 243 | ind_opts.if_not_exists = ind.if_not_exists 244 | ind_opts.sequence = ind.sequence 245 | 246 | if compat.check_version({2, 6, 1}) then 247 | ind_opts.hint = ind.hint 248 | 249 | if ind_opts.hint == nil then 250 | ind_opts.hint = spacer.hints_enabled 251 | end 252 | 253 | if ind_opts.hint ~= nil and type(ind_opts.hint) ~= 'boolean' then 254 | return error("hint for indexes must be boolean or nil") 255 | end 256 | end 257 | 258 | if ind_opts.type == "rtree" then 259 | if ind.dimension ~= nil then 260 | ind_opts.dimension = ind.dimension 261 | end 262 | 263 | if ind.distance ~= nil then 264 | ind_opts.distance = ind.distance 265 | end 266 | end 267 | 268 | if ind.parts ~= nil then 269 | ind_opts.parts = compat.index_parts_from_fields(space_name, ind.parts, f_extra) 270 | end 271 | 272 | local sp = box.space[space_name] 273 | local existing_index 274 | if sp ~= nil then 275 | existing_index = sp.index[ind.name] 276 | end 277 | if not check_alter or existing_index == nil then 278 | table.insert(up, {"box.space.%s:create_index(%s, %s)", space_name, inspect(ind.name), inspect(ind_opts)}) 279 | table.insert(down, {"box.space.%s.index.%s:drop()", space_name, ind.name}) 280 | else 281 | local opts_up, opts_down = get_changed_opts_for_index(spacer, space_name, existing_index, ind_opts) 282 | if opts_up then 283 | table.insert(up, {"box.space.%s.index.%s:alter(%s)", space_name, ind.name, inspect(opts_up)}) 284 | end 285 | 286 | if opts_down then 287 | table.insert(down, {"box.space.%s.index.%s:alter(%s)", space_name, ind.name, inspect(opts_down)}) 288 | end 289 | end 290 | 291 | created_indexes[ind.name] = true 292 | end 293 | 294 | -- check obsolete indexes in space 295 | if not spacer.keep_obsolete_indexes then 296 | local sp = box.space[space_name] 297 | if sp ~= nil then 298 | local sp_indexes = box.space._vindex:select({sp.id}) 299 | local primary_index_name 300 | for _, ind in ipairs(sp_indexes) do 301 | -- finding primary index 302 | ind = {id = ind[spacer.F._index.iid], name = ind[spacer.F._index.name]} 303 | if ind.id == 0 then 304 | primary_index_name = ind.name 305 | end 306 | end 307 | 308 | if not created_indexes[primary_index_name] then 309 | -- primary index recreation must be first 310 | local ind_opts = build_opts_for_index(spacer, space_name, 0) 311 | table.insert( 312 | down, 313 | {"box.space.%s:create_index(%s, %s)", space_name, inspect(primary_index_name), inspect(ind_opts)} 314 | ) 315 | end 316 | 317 | for _, ind in ipairs(sp_indexes) do 318 | ind = {id = ind[spacer.F._index.iid], name = ind[spacer.F._index.name]} 319 | 320 | if ind.id ~= 0 and not created_indexes[ind.name] then 321 | local ind_opts = build_opts_for_index(spacer, space_name, ind.id) 322 | table.insert(up, {"box.space.%s.index.%s:drop()", space_name, ind.name}) 323 | table.insert( 324 | down, 325 | {"box.space.%s:create_index(%s, %s)", space_name, inspect(ind.name), inspect(ind_opts)} 326 | ) 327 | end 328 | end 329 | 330 | if not created_indexes[primary_index_name] then 331 | -- primary index drop must be last 332 | table.insert(up, {"box.space.%s.index.%s:drop()", space_name, primary_index_name}) 333 | end 334 | end 335 | end 336 | 337 | return up, down 338 | end 339 | 340 | local function find_format_changes(spacer, existing_format, new_format) 341 | -- ignores type changes, field removals and field renames 342 | local changes = {} 343 | 344 | local min_length = math.min(#existing_format, #new_format) 345 | for fieldno = 1, min_length do 346 | local old_field = existing_format[fieldno] 347 | local new_field = new_format[fieldno] 348 | 349 | old_field.type = string.lower(old_field.type) 350 | new_field.type = string.lower(new_field.type) 351 | 352 | if old_field.type == new_field.type and old_field.name ~= new_field.name then 353 | -- field rename 354 | error( 355 | [[Seems like you are trying to rename field. 356 | It is illegal in automatic migrations. 357 | Either write a migration with new format scheme 358 | or add new fields at the bottom of format list]] 359 | ) 360 | end 361 | 362 | if old_field.type ~= new_field.type and old_field.name == new_field.name then 363 | error("Field type changes are not supported yet") 364 | end 365 | 366 | if old_field.is_nullable ~= new_field.is_nullable or old_field.collation ~= new_field.collation then 367 | table.insert( 368 | changes, 369 | { 370 | type = "alter", 371 | fieldno = fieldno, 372 | field_name = new_field.name, 373 | field_type = new_field.type, 374 | is_nullable = new_field.is_nullable, 375 | collation = new_field.collation 376 | } 377 | ) 378 | end 379 | end 380 | 381 | if #new_format < #existing_format then 382 | error("Format field removal is not supported yet") 383 | end 384 | 385 | for fieldno = #existing_format + 1, #new_format do 386 | local new_field = new_format[fieldno] 387 | 388 | table.insert( 389 | changes, 390 | { 391 | type = "new", 392 | fieldno = fieldno, 393 | field_name = new_field.name, 394 | field_type = new_field.type, 395 | is_nullable = new_field.is_nullable, 396 | collation = new_field.collation 397 | } 398 | ) 399 | end 400 | 401 | return changes 402 | end 403 | 404 | local function run_format_changes(spacer, stmt, space_name, format_changes, indexes_decl) 405 | local index0 = box.space[space_name].index[0] 406 | assert(index0 ~= nil, string.format("Index #0 not found in space %s", space_name)) 407 | 408 | local updates = {} 409 | local has_new_changes = false 410 | for _, ch in ipairs(format_changes) do 411 | if ch.type == "new" then 412 | table.insert( 413 | updates, 414 | { 415 | "=", 416 | ch.fieldno, 417 | compat.get_default_for_type(ch.field_type, ch.field_name, indexes_decl) 418 | } 419 | ) 420 | has_new_changes = true 421 | end 422 | end 423 | 424 | if #updates > 0 then 425 | stmt:requires("moonwalker") 426 | stmt:requires("spacer.ops", "ops") 427 | stmt:up( 428 | [[moonwalker { 429 | space = box.space.%s, 430 | actor = function(t) 431 | local key = ops.index_key(%s, 0, t) 432 | box.space.%s:update(key, %s) 433 | end, 434 | }]], 435 | space_name, 436 | inspect(space_name), 437 | space_name, 438 | inspect(updates) 439 | ) 440 | end 441 | 442 | if has_new_changes and spacer.down_migration_fail_on_impossible then 443 | stmt:down('assert(false, "Need to write explicitly a down migration for field removal")') 444 | end 445 | end 446 | 447 | local function spaces_migration(spacer, spaces_decl, check_alter) 448 | if check_alter == nil then 449 | check_alter = true 450 | end 451 | 452 | local stmt = stmt_obj.new() 453 | 454 | local declared_spaces = {} 455 | for _, space_decl in pairs(spaces_decl) do 456 | local space_name = space_decl.space_name 457 | local space_format = space_decl.space_format 458 | local space_indexes = space_decl.space_indexes 459 | local space_opts = space_decl.space_opts 460 | 461 | assert(space_name ~= nil, "Space name cannot be null") 462 | assert(space_format ~= nil, "Space format cannot be null") 463 | assert(space_indexes ~= nil, "Space indexes cannot be null") 464 | 465 | local space_exists = box.space[space_name] ~= nil 466 | 467 | if not space_exists or not check_alter then 468 | local space_opts_str = "nil" 469 | if space_opts ~= nil then 470 | space_opts_str = inspect(space_opts) 471 | end 472 | stmt:up_tx_begin() 473 | stmt:up("box.schema.create_space(%s, %s)", inspect(space_name), space_opts_str) 474 | stmt:up("box.space.%s:format(%s)", space_name, inspect(space_format)) 475 | 476 | local f, f_extra = generate_field_info(space_format) 477 | local up, _ = indexes_migration(spacer, space_name, space_indexes, f, f_extra, check_alter) 478 | stmt:up_apply(up) 479 | stmt:up("box.space.%s:replace({%s})", spacer:models_space().name, inspect(space_name)) 480 | 481 | stmt:down("box.space.%s:drop()", space_name) 482 | stmt:down("box.space.%s:delete({%s})", spacer:models_space().name, inspect(space_name)) 483 | else 484 | -- if space already exists 485 | local sp_tuple = box.space._vspace.index.name:get({space_name}) 486 | assert(sp_tuple ~= nil, string.format("Couldn't find space %s in _vspace", space_name)) 487 | local existing_format = sp_tuple[7] 488 | local format_changes = find_format_changes(spacer, existing_format, space_format) 489 | 490 | stmt:up_tx_begin() 491 | stmt:down_tx_begin() 492 | if #format_changes > 0 then 493 | stmt:up("box.space.%s:format({})", space_name) -- clear format 494 | stmt:down("box.space.%s:format({})", space_name) -- clear format 495 | run_format_changes(spacer, stmt, space_name, format_changes, space_indexes) 496 | stmt:up_last("box.space.%s:format(%s)", space_name, inspect(space_format)) 497 | stmt:down_last("box.space.%s:format(%s)", space_name, inspect(existing_format)) 498 | end 499 | 500 | local f, f_extra = generate_field_info(space_format) 501 | local up, down = indexes_migration(spacer, space_name, space_indexes, f, f_extra) 502 | stmt:up_apply(up) 503 | stmt:down_apply(down) 504 | end 505 | declared_spaces[space_name] = true 506 | end 507 | 508 | if not spacer.keep_obsolete_spaces then 509 | for k, sp in pairs(box.space) do 510 | if type(k) == "string" and spacer:models_space():get({k}) then 511 | if not declared_spaces[k] then 512 | -- space drop 513 | local space_name = k 514 | local space_format = sp:format() 515 | local space_opts = build_opts_for_space(spacer, space_name) 516 | stmt:up("box.space.%s:drop()", space_name) 517 | stmt:up("box.space.%s:delete({%s})", spacer:models_space().name, inspect(space_name)) 518 | stmt:down("box.schema.create_space(%s, %s)", inspect(space_name), inspect(space_opts)) 519 | stmt:down("box.space.%s:format(%s)", space_name, inspect(space_format)) 520 | 521 | if sp.index[0] ~= nil then -- primary index 522 | local ind = sp.index[0] 523 | local ind_opts = build_opts_for_index(spacer, space_name, ind.id) 524 | stmt:down("box.space.%s:create_index(%s, %s)", space_name, inspect(ind.name), inspect(ind_opts)) 525 | end 526 | 527 | for k2, ind in pairs(sp.index) do 528 | if type(k2) == "string" and ind.id ~= 0 then 529 | local ind_opts = build_opts_for_index(spacer, space_name, ind.id) 530 | stmt:down( 531 | "box.space.%s:create_index(%s, %s)", 532 | space_name, 533 | inspect(ind.name), 534 | inspect(ind_opts) 535 | ) 536 | end 537 | end 538 | stmt:down("box.space.%s:replace({%s})", spacer:models_space().name, inspect(space_name)) 539 | end 540 | end 541 | end 542 | end 543 | 544 | stmt:up_tx_commit() 545 | stmt:down_tx_commit() 546 | 547 | return { 548 | requirements = stmt.requirements, 549 | up = stmt:build_up(), 550 | down = stmt:build_down() 551 | } 552 | end 553 | 554 | return { 555 | generate_field_info = generate_field_info, 556 | spaces_migration = spaces_migration 557 | } 558 | -------------------------------------------------------------------------------- /spacer/ops.lua: -------------------------------------------------------------------------------- 1 | local fun = require 'fun' 2 | 3 | 4 | local function index_key(space, index, t) 5 | return fun.map( 6 | function(p) return t[p.fieldno] end, 7 | box.space[space].index[index].parts 8 | ):totable() 9 | end 10 | 11 | return { 12 | index_key = index_key, 13 | } 14 | -------------------------------------------------------------------------------- /spacer/stmt.lua: -------------------------------------------------------------------------------- 1 | local stmt_methods = { 2 | requires = function(self, req, name) 3 | self.requirements[req] = { 4 | name = name or req 5 | } 6 | end, 7 | only_up = function(self, only_up) 8 | if only_up == nil then 9 | only_up = true 10 | end 11 | self._only_up = only_up 12 | end, 13 | up = function(self, f, ...) 14 | table.insert(self.statements_up, string.format(f, ...)) 15 | end, 16 | down = function(self, f, ...) 17 | if self._only_up then return end 18 | table.insert(self.statements_down, string.format(f, ...)) 19 | end, 20 | up_apply = function(self, statements) 21 | if statements == nil then return end 22 | 23 | for _, s in ipairs(statements) do 24 | local f = table.remove(s, 1) 25 | self:up(f, unpack(s)) 26 | end 27 | end, 28 | down_apply = function(self, statements) 29 | if self._only_up then return end 30 | if statements == nil then return end 31 | 32 | for _, s in ipairs(statements) do 33 | local f = table.remove(s, 1) 34 | self:down(f, unpack(s)) 35 | end 36 | end, 37 | up_last = function(self, f, ...) 38 | -- insert in fifo order 39 | table.insert(self.statements_up_last, string.format(f, ...)) 40 | end, 41 | down_last = function(self, f, ...) 42 | -- insert in fifo order 43 | if self._only_up then return end 44 | table.insert(self.statements_down_last, string.format(f, ...)) 45 | end, 46 | up_tx_begin = function(self) 47 | if self.up_in_transaction then 48 | return 49 | end 50 | 51 | -- Space _space does not support multi-statement transactions 52 | --self:up('box.begin()') 53 | self.up_in_transaction = true 54 | end, 55 | up_tx_commit = function(self) 56 | if not self.up_in_transaction then 57 | return 58 | end 59 | 60 | -- Space _space does not support multi-statement transactions 61 | --self:up('box.commit()') 62 | self.up_in_transaction = false 63 | end, 64 | down_tx_begin = function(self) 65 | if self._only_up then return end 66 | if self.up_in_transaction then 67 | return 68 | end 69 | 70 | -- Space _space does not support multi-statement transactions 71 | --self:down('box.begin()') 72 | self.down_in_transaction = true 73 | end, 74 | down_tx_commit = function(self) 75 | if self._only_up then return end 76 | if not self.down_in_transaction then 77 | return 78 | end 79 | 80 | -- Space _space does not support multi-statement transactions 81 | --self:down('box.commit()') 82 | self.down_in_transaction = false 83 | end, 84 | build_up = function(self) 85 | local statements = {} 86 | for _, s in ipairs(self.statements_up) do 87 | table.insert(statements, s) 88 | end 89 | 90 | for i = #self.statements_up_last, 1, -1 do 91 | table.insert(statements, self.statements_up_last[i]) 92 | end 93 | 94 | return statements 95 | end, 96 | build_down = function(self) 97 | local statements = {} 98 | for _, s in ipairs(self.statements_down) do 99 | table.insert(statements, s) 100 | end 101 | 102 | for i = #self.statements_down_last, 1, -1 do 103 | table.insert(statements, self.statements_down_last[i]) 104 | end 105 | 106 | return statements 107 | end 108 | } 109 | 110 | return { 111 | new = function() 112 | return setmetatable({ 113 | _only_up = false, 114 | up_in_transaction = false, 115 | down_in_transaction = false, 116 | requirements = {}, 117 | statements_up = {}, 118 | statements_down = {}, 119 | 120 | statements_up_last = {}, 121 | statements_down_last = {}, 122 | }, { 123 | __index = stmt_methods 124 | }) 125 | end 126 | } 127 | -------------------------------------------------------------------------------- /spacer/transformations.lua: -------------------------------------------------------------------------------- 1 | local function tuple2hash ( f ) 2 | local idx = {} 3 | for k,v in pairs(f) do 4 | if type(v) == 'number' then 5 | idx[ v ] = k 6 | end 7 | end 8 | local rows = {} 9 | for k,v in ipairs(idx) do 10 | table.insert(rows,"\t"..v.." = t["..tostring(k).."];\n") 11 | end 12 | return dostring("return function(t) return t and {\n"..table.concat(rows, "").."} or nil end\n") 13 | end 14 | 15 | local function hash2tuple ( f ) 16 | local idx = {} 17 | for k,v in pairs(f) do 18 | if type(v) == 'number' then 19 | idx[ v ] = k 20 | end 21 | end 22 | local rows = {} 23 | for k,v in ipairs(idx) do 24 | if k < #idx then 25 | table.insert(rows,"\th."..v..",\n") 26 | else 27 | table.insert(rows,"\th."..v.." == nil and require'msgpack'.NULL or h."..v.."\n") 28 | end 29 | end 30 | return dostring("return function(h) return h and box.tuple.new({\n"..table.concat(rows, "").."}) or nil end\n") 31 | end 32 | 33 | return { 34 | tuple2hash = tuple2hash, 35 | hash2tuple = hash2tuple 36 | } 37 | -------------------------------------------------------------------------------- /spacer/util.lua: -------------------------------------------------------------------------------- 1 | local fio = require 'fio' 2 | local fun = require 'fun' 3 | local fileio = require 'spacer.fileio' 4 | local lversion = require 'spacer.version' 5 | 6 | local NULL = require 'msgpack'.NULL 7 | 8 | local function string_split(inputstr, sep) 9 | if sep == nil then 10 | sep = "%s" 11 | end 12 | local t = {} 13 | local i = 1 14 | for str in string.gmatch(inputstr, "([^"..sep.."]+)") do 15 | t[i] = str 16 | i = i + 1 17 | end 18 | return t 19 | end 20 | 21 | 22 | local function tabulate_string(body, tab) 23 | if tab == nil then 24 | tab = string.rep(' ', 4) 25 | end 26 | 27 | body = string_split(body, '\n') 28 | body = fun.iter(body):map( 29 | function(s) return tab .. s end 30 | ):totable() 31 | 32 | return table.concat(body, '\n') 33 | end 34 | 35 | 36 | local function reverse_table(tbl) 37 | for i=1, math.floor(#tbl / 2) do 38 | tbl[i], tbl[#tbl - i + 1] = tbl[#tbl - i + 1], tbl[i] 39 | end 40 | end 41 | 42 | 43 | local function compile_migration(data) 44 | return loadstring(data) 45 | end 46 | 47 | local function _list_migration_files(path) 48 | local res = {} 49 | local files = fileio.listdir(path) 50 | for _, f in ipairs(files) do 51 | if f.mode == 'file' then 52 | local filename = fio.basename(f.path) 53 | local filename_no_ext = fio.basename(f.path, '.lua') 54 | local version = lversion.parse(filename_no_ext) 55 | if version ~= nil then 56 | table.insert(res, { 57 | version = version, 58 | path = f.path, 59 | filename = filename 60 | }) 61 | end 62 | end 63 | end 64 | 65 | return res 66 | end 67 | 68 | 69 | local function read_migration(path, version, compile) 70 | if compile == nil then 71 | compile = true 72 | end 73 | 74 | assert(version ~= nil, 'version is required') 75 | 76 | for _, m in ipairs(_list_migration_files(path)) do 77 | if tostring(m.version) == version 78 | or m.filename == version 79 | or m.version.ts == tonumber(version) 80 | or m.version.name == version then 81 | local data = fileio.read_file(m.path) 82 | if compile then 83 | local err 84 | data, err = loadstring(data) 85 | if data == nil then 86 | error(string.format("Error compiling migration '%s': \n%s", m.filename, err)) 87 | end 88 | 89 | data = data() 90 | end 91 | 92 | m.migration = data 93 | return m 94 | end 95 | end 96 | 97 | return nil 98 | end 99 | 100 | 101 | local function list_migrations(path, verbose) 102 | local res = {} 103 | 104 | for _, m in ipairs(_list_migration_files(path)) do 105 | if not verbose then 106 | table.insert(res, m.filename) 107 | else 108 | table.insert(res, m) 109 | end 110 | end 111 | 112 | return res 113 | end 114 | 115 | 116 | local function read_migrations(path, direction, from_migration, n) 117 | if from_migration ~= nil then 118 | from_migration = lversion.parse(from_migration) 119 | end 120 | 121 | local migrations = {} 122 | local files = _list_migration_files(path) 123 | 124 | if direction == 'down' then 125 | reverse_table(files) 126 | end 127 | 128 | for _, m in ipairs(files) do 129 | local cond = from_migration == nil or m.version > from_migration 130 | if direction == 'down' then 131 | cond = from_migration == nil or m.version <= from_migration 132 | end 133 | if cond then 134 | local data = fileio.read_file(m.path) 135 | local compiled_code, err = loadstring(data) 136 | if compiled_code == nil then 137 | error(string.format("Error compiling migration '%s': \n%s", m.filename, err)) 138 | end 139 | m.migration = compiled_code 140 | table.insert(migrations, m) 141 | end 142 | 143 | if n ~= nil and #migrations == n then 144 | break 145 | end 146 | end 147 | return migrations 148 | end 149 | 150 | 151 | local function check_version_exists(path, ts, name) 152 | local version = lversion.new(ts, name) 153 | for _, m in ipairs(_list_migration_files(path)) do 154 | if m.version == version or m.version.ts == ts or m.version.name == name then 155 | error(string.format('Version with timestamp %d or name %s already exists', ts, name)) 156 | end 157 | end 158 | end 159 | 160 | 161 | return { 162 | string_split = string_split, 163 | tabulate_string = tabulate_string, 164 | read_migration = read_migration, 165 | read_migrations = read_migrations, 166 | list_migrations = list_migrations, 167 | check_version_exists = check_version_exists, 168 | } 169 | -------------------------------------------------------------------------------- /spacer/version.lua: -------------------------------------------------------------------------------- 1 | local version_parse 2 | local version_make 3 | 4 | local function compare_versions(v1, v2) 5 | if type(v1) == 'string' then 6 | v1 = version_parse(v1) 7 | end 8 | if type(v2) == 'string' then 9 | v2 = version_parse(v2) 10 | end 11 | 12 | if v1 == nil or v1.ts == nil or v1.name == nil then 13 | error(string.format('version "%s" is invalid', version1)) 14 | end 15 | 16 | if v2 == nil or v2.ts == nil or v2.name == nil then 17 | error(string.format('version "%s" is invalid', version2)) 18 | end 19 | 20 | if v1.ts < v2.ts then 21 | return -1 22 | end 23 | 24 | if v1.ts > v2.ts then 25 | return 1 26 | end 27 | 28 | -- ts1 and ts2 are the smae 29 | 30 | if v1.name < v2.name then 31 | return -1 32 | end 33 | 34 | if v1.name > v2.name then 35 | return 1 36 | end 37 | 38 | return 0 39 | end 40 | 41 | 42 | local M = {} 43 | M.__index = M 44 | 45 | function M.new(ts, name) 46 | if ts == nil or name == nil then 47 | return nil 48 | end 49 | 50 | local self = setmetatable({}, M) 51 | self.ts = tonumber(ts) or NULL 52 | self.name = name 53 | return self 54 | end 55 | 56 | function M.parse(s) 57 | local ts, name = string.match(s, '(%d+)_(.+)') 58 | return M.new(ts, name) 59 | end 60 | 61 | function M.str(self, postfix) 62 | if postfix == nil then 63 | postfix = '' 64 | end 65 | return string.format("%d_%s%s", tonumber(self.ts), self.name, postfix) 66 | end 67 | 68 | function M.__tostring(self) 69 | return self:str() 70 | end 71 | 72 | function M.__eq(lhs, rhs) 73 | return compare_versions(lhs, rhs) == 0 74 | end 75 | 76 | function M.__lt(lhs, rhs) 77 | return compare_versions(lhs, rhs) < 0 78 | end 79 | 80 | M.__serialize = M.__tostring 81 | 82 | return M 83 | -------------------------------------------------------------------------------- /tests/01-basic.lua: -------------------------------------------------------------------------------- 1 | #! /user/bin/env tarantool 2 | 3 | package.path = "../?.lua;../?/init.lua;./?/init.lua;" .. package.path 4 | package.cpath = "../?.so;../?.dylib;./?.so;./?.dylib;" .. package.cpath 5 | 6 | local fiber = require 'fiber' 7 | local fio = require 'fio' 8 | local json = require 'json' 9 | local tap = require 'tap' 10 | local tnt = require 'tnt' 11 | 12 | local function rmtree(path) 13 | for _, f in ipairs(fio.listdir(path)) do 14 | local f_path = fio.pathjoin(path, f) 15 | local f_stat = fio.stat(f_path) 16 | 17 | if f_stat:is_dir() then 18 | rmtree(f_path) 19 | else 20 | fio.unlink(f_path) 21 | end 22 | end 23 | end 24 | 25 | local function recreate_migrations() 26 | rmtree('./migrations') 27 | fio.mktree('./migrations') 28 | end 29 | 30 | local function cmp_parts(t, got, expected) 31 | t:is(#got, #expected, 'lengths are same') 32 | for i, p in ipairs(got) do 33 | t:is(p.fieldno, expected[i].fieldno, 'fieldno correct') 34 | t:is(p.type, expected[i].type, 'field type correct') 35 | end 36 | end 37 | 38 | local function spacer_up(t, spacer, ...) 39 | local err = spacer:migrate_up(...) 40 | if err ~= nil then 41 | t:fail(string.format('migrate_up failed: %s', json.encode(err))) 42 | else 43 | t:ok(true, 'migrate_up ok') 44 | end 45 | end 46 | 47 | local function spacer_down(t, spacer, ...) 48 | local err = spacer:migrate_down(...) 49 | if err ~= nil then 50 | t:fail(string.format('migrate_down failed: %s', json.encode(err))) 51 | else 52 | t:ok(true, 'migrate_down ok') 53 | end 54 | end 55 | 56 | 57 | 58 | local function spacer_create_space_object(spacer) 59 | local fmt = { 60 | { name = 'id', type = 'unsigned' } 61 | } 62 | spacer:space({ 63 | name = 'object', 64 | format = fmt, 65 | indexes = { 66 | { name = 'primary', type = 'tree', unique = true, parts = { 'id' } } 67 | } 68 | }) 69 | spacer:makemigration('object_init') 70 | spacer:migrate_up(1) 71 | end 72 | 73 | local function test__space_create(t, spacer) 74 | t:plan(11) 75 | 76 | local fmt = { 77 | { name = 'id', type = 'unsigned' } 78 | } 79 | spacer:space({ 80 | name = 'object', 81 | format = fmt, 82 | indexes = { 83 | { name = 'primary', type = 'tree', unique = true, parts = { 'id' } } 84 | } 85 | }) 86 | 87 | spacer:makemigration('object_init') 88 | spacer_up(t, spacer) 89 | 90 | local sp = box.space.object 91 | t:isnt(sp, nil, 'object space created') 92 | t:is_deeply(sp:format(), fmt, 'format correct') 93 | t:isnt(sp.index.primary, nil, 'primary index created') 94 | t:is(string.upper(sp.index.primary.type), 'TREE', 'type correct') 95 | t:is(sp.index.primary.unique, true, 'unique correct') 96 | 97 | cmp_parts(t, sp.index.primary.parts, { 98 | { fieldno = 1, type = 'unsigned' } 99 | }) 100 | 101 | spacer_down(t, spacer) 102 | t:isnil(box.space.object, 'object deleted') 103 | end 104 | 105 | 106 | local function test__add_field_name_and_index(t, spacer) 107 | recreate_migrations() 108 | t:plan(15) 109 | 110 | spacer_create_space_object(spacer) 111 | fiber.sleep(1) -- just to make sure migrations have different ids 112 | 113 | box.space.object:insert({1}) 114 | 115 | local fmt = { 116 | { name = 'id', type = 'unsigned' }, 117 | { name = 'name', type = 'string' }, 118 | } 119 | 120 | spacer:space({ 121 | name = 'object', 122 | format = fmt, 123 | indexes = { 124 | { name = 'primary', type = 'tree', unique = true, parts = { 'id' } }, 125 | { name = 'name', type = 'hash', unique = true, parts = { 'name', 'id' } } 126 | } 127 | }) 128 | 129 | spacer:makemigration('object_name') 130 | spacer_up(t, spacer) 131 | 132 | local sp = box.space.object 133 | t:is_deeply(sp:format(), fmt, 'format correct') 134 | t:isnt(sp.index.name, nil, 'name index created') 135 | t:is(string.upper(sp.index.name.type), 'HASH', 'type correct') 136 | t:is(sp.index.name.unique, true, 'unique correct') 137 | 138 | cmp_parts(t, sp.index.name.parts, { 139 | { fieldno = 2, type = 'string' }, 140 | { fieldno = 1, type = 'unsigned' } 141 | }) 142 | 143 | -- check tuples 144 | t:is(box.space.object:get({1})[2], '', 'name added') 145 | 146 | spacer_down(t, spacer) 147 | t:is_deeply(sp:format(), { 148 | { name = 'id', type = 'unsigned' }, 149 | }, 'format correct') 150 | t:is(sp.index.name, nil, 'name index dropped') 151 | 152 | spacer_up(t, spacer) 153 | end 154 | 155 | 156 | local function test__alter_index_parts(t, spacer) 157 | t:plan(10) 158 | fiber.sleep(1) -- just to make sure migrations have different ids 159 | 160 | local fmt = { 161 | { name = 'id', type = 'unsigned' }, 162 | { name = 'name', type = 'string' }, 163 | } 164 | 165 | spacer:space({ 166 | name = 'object', 167 | format = fmt, 168 | indexes = { 169 | { name = 'primary', type = 'tree', unique = true, parts = { 'id' } }, 170 | { name = 'name', type = 'hash', unique = true, parts = { 'name' } } 171 | } 172 | }) 173 | 174 | spacer:makemigration('object_name_index_alter') 175 | spacer_up(t, spacer) 176 | 177 | local sp = box.space.object 178 | cmp_parts(t, sp.index.name.parts, { 179 | { fieldno = 2, type = 'string' }, 180 | }) 181 | 182 | spacer_down(t, spacer) 183 | cmp_parts(t, sp.index.name.parts, { 184 | { fieldno = 2, type = 'string' }, 185 | { fieldno = 1, type = 'unsigned' }, 186 | }) 187 | end 188 | 189 | 190 | local function test__index_many_alters(t, spacer) 191 | t:plan(10) 192 | fiber.sleep(1) -- just to make sure migrations have different ids 193 | 194 | local fmt = { 195 | { name = 'id', type = 'unsigned' }, 196 | { name = 'name', type = 'string' }, 197 | } 198 | 199 | spacer:space({ 200 | name = 'object', 201 | format = fmt, 202 | indexes = { 203 | { name = 'primary', type = 'tree', unique = true, parts = { 'id' }, sequence = true }, 204 | { name = 'name', type = 'tree', unique = false, parts = { 'name' } } 205 | } 206 | }) 207 | 208 | spacer:makemigration('object_id_sequence') 209 | 210 | spacer_up(t, spacer) 211 | 212 | local sp = box.space.object 213 | t:is_deeply(sp:format(), fmt, 'format correct') 214 | t:isnt(sp.index.primary.sequence_id, nil, 'sequence created') 215 | t:is(string.upper(sp.index.name.type), 'TREE', 'name type changed') 216 | t:is(sp.index.name.unique, false, 'name unique changed') 217 | 218 | spacer_down(t, spacer) 219 | 220 | local sp = box.space.object 221 | t:is_deeply(sp:format(), fmt, 'format correct') 222 | t:is(sp.index.primary.sequence_id, nil, 'sequence dropped') 223 | t:is(string.upper(sp.index.name.type), 'HASH', 'name type changed') 224 | t:is(sp.index.name.unique, true, 'name unique changed') 225 | 226 | spacer:migrate_up() 227 | end 228 | 229 | 230 | local function test__add_rtree_index(t, spacer) 231 | t:plan(11) 232 | fiber.sleep(1) -- just to make sure migrations have different ids 233 | 234 | local fmt = { 235 | { name = 'id', type = 'unsigned' }, 236 | { name = 'name', type = 'string' }, 237 | { name = 'arr', type = 'array' }, 238 | } 239 | 240 | spacer:space({ 241 | name = 'object', 242 | format = fmt, 243 | indexes = { 244 | { name = 'primary', type = 'tree', unique = true, parts = { 'id' }, sequence = true }, 245 | { name = 'name', type = 'tree', unique = false, parts = { 'name' } }, 246 | { name = 'rtree', type = 'rtree', dimension = 3, unique = false, parts = { 'arr' } } 247 | } 248 | }) 249 | spacer:makemigration('object_rtree') 250 | 251 | spacer_up(t, spacer) 252 | 253 | local sp = box.space.object 254 | t:is_deeply(sp:format(), fmt, 'format correct') 255 | t:is(string.upper(sp.index.rtree.type), 'RTREE', 'rtree created') 256 | t:isnil(sp.index.rtree.unique, 'unique correct') 257 | t:is(sp.index.rtree.dimension, 3, 'dimension correct') 258 | 259 | cmp_parts(t, sp.index.rtree.parts, { 260 | { fieldno = 3, type = 'array' }, 261 | }) 262 | 263 | spacer_down(t, spacer) 264 | 265 | local sp = box.space.object 266 | t:isnil(sp.index.rtree, 'rtree deleted') 267 | 268 | spacer:migrate_up() 269 | end 270 | 271 | 272 | local function test__drop_rtree_index(t, spacer) 273 | t:plan(11) 274 | fiber.sleep(1) -- just to make sure migrations have different ids 275 | 276 | local fmt = { 277 | { name = 'id', type = 'unsigned' }, 278 | { name = 'name', type = 'string' }, 279 | { name = 'arr', type = 'array' }, 280 | } 281 | 282 | spacer:space({ 283 | name = 'object', 284 | format = fmt, 285 | indexes = { 286 | { name = 'primary', type = 'tree', unique = true, parts = { 'id' }, sequence = true }, 287 | { name = 'name', type = 'tree', unique = false, parts = { 'name' } }, 288 | } 289 | }) 290 | spacer:makemigration('object_rtree_del') 291 | spacer_up(t, spacer) 292 | 293 | local sp = box.space.object 294 | t:isnil(sp.index.rtree, 'rtree deleted') 295 | 296 | spacer_down(t, spacer) 297 | 298 | t:is_deeply(sp:format(), fmt, 'format correct') 299 | t:is(string.upper(sp.index.rtree.type), 'RTREE', 'rtree created') 300 | t:isnil(sp.index.rtree.unique, 'unique correct') 301 | t:is(sp.index.rtree.dimension, 3, 'dimension correct') 302 | 303 | cmp_parts(t, sp.index.rtree.parts, { 304 | { fieldno = 3, type = 'array' }, 305 | }) 306 | 307 | spacer:migrate_up() 308 | end 309 | 310 | 311 | local function test__drop_space(t, spacer) 312 | t:plan(21) 313 | fiber.sleep(1) -- just to make sure migrations have different ids 314 | 315 | spacer:space_drop('object') 316 | spacer:makemigration('object_drop') 317 | spacer_up(t, spacer) 318 | 319 | local sp = box.space.object 320 | t:isnil(sp, 'space dropped') 321 | 322 | spacer_down(t, spacer) 323 | local sp = box.space.object 324 | t:isnt(sp, nil, 'space recreated') 325 | t:is(sp.engine, 'memtx', 'engine ok') 326 | t:is(sp.temporary, false, 'temporary ok') 327 | t:is(sp.field_count, 0, 'field_count ok') 328 | -- t:is_deeply(sp:format(), fmt, 'format correct') 329 | 330 | t:isnt(sp.index.primary, nil, 'primary index recreated') 331 | t:isnt(sp.index.primary.sequence_id, nil, 'sequence created') 332 | t:is(string.upper(sp.index.primary.type), 'TREE', 'primary type ok') 333 | t:is(sp.index.primary.unique, true, 'primary unique ok') 334 | cmp_parts(t, sp.index.primary.parts, { 335 | { fieldno = 1, type = 'unsigned' }, 336 | }) 337 | 338 | t:isnt(sp.index.name, nil, 'name index recreated') 339 | t:is(string.upper(sp.index.name.type), 'TREE', 'name type ok') 340 | t:is(sp.index.name.unique, false, 'name unique ok') 341 | cmp_parts(t, sp.index.name.parts, { 342 | { fieldno = 2, type = 'string' }, 343 | }) 344 | 345 | spacer_up(t, spacer) 346 | end 347 | 348 | 349 | local function test__no_check_alter(t, spacer) 350 | recreate_migrations() 351 | t:plan(11) 352 | 353 | local fmt = { 354 | { name = 'id', type = 'unsigned' } 355 | } 356 | spacer:space({ 357 | name = 'object', 358 | format = fmt, 359 | indexes = { 360 | { name = 'primary', type = 'tree', unique = true, parts = { 'id' } } 361 | } 362 | }) 363 | 364 | fiber.sleep(1) -- just to make sure migrations have different ids 365 | spacer:makemigration('object_init') 366 | spacer_up(t, spacer) 367 | 368 | -- recreating 369 | fiber.sleep(1) -- just to make sure migrations have different ids 370 | spacer:makemigration('object_init2', {check_alter = false}) 371 | box.space.object:drop() 372 | spacer_up(t, spacer) 373 | 374 | local sp = box.space.object 375 | t:isnt(sp, nil, 'object space created') 376 | t:is_deeply(sp:format(), fmt, 'format correct') 377 | t:isnt(sp.index.primary, nil, 'primary index created') 378 | t:is(string.upper(sp.index.primary.type), 'TREE', 'type correct') 379 | t:is(sp.index.primary.unique, true, 'unique correct') 380 | 381 | cmp_parts(t, sp.index.primary.parts, { 382 | { fieldno = 1, type = 'unsigned' } 383 | }) 384 | 385 | pcall(spacer.migrate_down, spacer, 100) -- allow to fail as there are 2 identical migrations 386 | t:isnil(box.space.object, 'object deleted') 387 | end 388 | 389 | 390 | local function test__migrate_dummy(t, spacer) 391 | recreate_migrations() 392 | spacer:clear_schema() 393 | t:plan(3) 394 | 395 | local fmt = { 396 | { name = 'id', type = 'unsigned' } 397 | } 398 | spacer:space({ 399 | name = 'object', 400 | format = fmt, 401 | indexes = { 402 | { name = 'primary', type = 'tree', unique = true, parts = { 'id' } } 403 | } 404 | }) 405 | 406 | fiber.sleep(1) -- just to make sure migrations have different ids 407 | spacer:makemigration('object_init') 408 | 409 | t:is(spacer:version(), nil, 'version is nil before migrate_dummy') 410 | spacer:migrate_dummy('object_init') 411 | 412 | t:is(spacer:version().name, 'object_init', 'version up') 413 | t:is(spacer:models_space():select()[1][1], 'object', 'space registered') 414 | 415 | spacer:clear_schema() 416 | end 417 | 418 | 419 | local function test__old_format(t, spacer) 420 | recreate_migrations() 421 | spacer:clear_schema() 422 | t:plan(2) 423 | 424 | local s = box.schema.create_space('object') 425 | local f = { 426 | { name = 'id', type = 'unsigned' }, 427 | { name = 'name', type = 'string' }, 428 | } 429 | 430 | s:format(f) 431 | s:create_index('primary', { 432 | unique = true, 433 | parts = { 434 | { 1, 'unsigned' }, { 2, 'string' } 435 | } 436 | }) 437 | spacer:space({ 438 | name = 'object', 439 | format = f, 440 | indexes = { 441 | { name = 'primary', type = 'tree', unique = true, parts = { 'id', 'name' } } 442 | } 443 | }) 444 | 445 | fiber.sleep(1) -- just to make sure migrations have different ids 446 | spacer:makemigration('object_init', {check_alter = false}) 447 | spacer:migrate_dummy('object_init') 448 | 449 | local res = spacer:_makemigration('test') 450 | res = res:gsub('\n', '') 451 | local up_body = res:match('up%s*=%s*function%(%)(.-)end'):gsub('%s+', '') 452 | local down_body = res:match('down%s*=%s*function%(%)(.-)end'):gsub('%s+', '') 453 | 454 | t:is(up_body, '', 'up body is empty') 455 | t:is(down_body, '', 'down body is empty') 456 | end 457 | 458 | local function main() 459 | tnt.cfg{} 460 | 461 | local spacer = require 'spacer'.new({ 462 | migrations = './migrations', 463 | down_migration_fail_on_impossible = false, 464 | }) 465 | 466 | tap.test('test__space_create', test__space_create, spacer) 467 | tap.test('test__add_field_name_and_index', test__add_field_name_and_index, spacer) 468 | tap.test('test__alter_index_parts', test__alter_index_parts, spacer) 469 | tap.test('test__index_many_alters', test__index_many_alters, spacer) 470 | tap.test('test__add_rtree_index', test__add_rtree_index, spacer) 471 | tap.test('test__drop_rtree_index', test__drop_rtree_index, spacer) 472 | tap.test('test__drop_space', test__drop_space, spacer) 473 | tap.test('test__no_check_alter', test__no_check_alter, spacer) 474 | tap.test('test__migrate_dummy', test__migrate_dummy, spacer) 475 | tap.test('test__old_format', test__old_format, spacer) 476 | 477 | rmtree('./migrations') 478 | tnt.finish() 479 | os.exit(0) 480 | end 481 | 482 | xpcall(main, function(err) 483 | print(err .. '\n' .. debug.traceback()) 484 | os.exit(1) 485 | end) 486 | -------------------------------------------------------------------------------- /tests/tnt/init.lua: -------------------------------------------------------------------------------- 1 | local fio = require 'fio' 2 | local errno = require 'errno' 3 | local yaml = require 'yaml' 4 | local log = require 'log' 5 | 6 | local dir = os.getenv('TNT_FOLDER') 7 | local cleanup = false 8 | 9 | if dir == nil then 10 | dir = fio.tempdir() 11 | print(dir) 12 | cleanup = true 13 | end 14 | 15 | local function tnt_prepare(cfg_args) 16 | cfg_args = cfg_args or {} 17 | local files = fio.glob(fio.pathjoin(dir, '*')) 18 | for _, file in pairs(files) do 19 | if fio.basename(file) ~= 'tarantool.log' then 20 | log.info("skip removing %s", file) 21 | fio.unlink(file) 22 | end 23 | end 24 | 25 | if require('tarantool').version >= "1.7.3" then 26 | cfg_args['work_dir'] = dir 27 | cfg_args['memtx_dir'] = dir 28 | cfg_args['log'] = "file:" .. fio.pathjoin(dir, 'tarantool.log') 29 | else 30 | cfg_args['work_dir'] = dir 31 | cfg_args['wal_dir'] = dir 32 | cfg_args['snap_dir'] = dir 33 | --cfg_args['vinyl'] = {} 34 | cfg_args['logger'] = fio.pathjoin(dir, 'tarantool.log') 35 | end 36 | 37 | box.cfg(cfg_args) 38 | end 39 | 40 | return { 41 | finish = function(code) 42 | local files = fio.glob(fio.pathjoin(dir, '*')) 43 | for _, file in pairs(files) do 44 | if fio.basename(file) == 'tarantool.log' and not cleanup then 45 | log.info("skip removing %s", file) 46 | else 47 | log.info("remove %s", file) 48 | fio.unlink(file) 49 | end 50 | end 51 | if cleanup then 52 | log.info("rmdir %s", dir) 53 | fio.rmdir(dir) 54 | end 55 | end, 56 | 57 | dir = function() 58 | return dir 59 | end, 60 | 61 | cleanup = function() 62 | return cleanup 63 | end, 64 | 65 | logfile = function() 66 | return fio.pathjoin(dir, 'tarantool.log') 67 | end, 68 | 69 | log = function() 70 | local fh = fio.open(fio.pathjoin(dir, 'tarantool.log'), 'O_RDONLY') 71 | if fh == nil then 72 | box.error(box.error.PROC_LUA, errno.strerror()) 73 | end 74 | 75 | local data = fh:read(16384) 76 | fh:close() 77 | return data 78 | end, 79 | 80 | cfg = tnt_prepare 81 | } 82 | --------------------------------------------------------------------------------