Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Features:
* Save queries on single location for later use
* Define custom table helpers
* Bind parameters (see `:help vim-dadbod-ui-bind-parameters`)
* Read-only mode for production database safety
* Autocompletion with [vim-dadbod-completion](https://github.com/kristijanhusak/vim-dadbod-completion)
* Jump to foreign keys from the dadbod output (see `:help <Plug>(DBUI_JumpToForeignKey)`)
* Support for nerd fonts (see `:help g:db_ui_use_nerd_fonts`)
Expand Down Expand Up @@ -151,12 +152,31 @@ Just make sure to **NOT COMMIT** these. I suggest using project local vim config
Using `:DBUIAddConnection` command or pressing `A` in dbui drawer opens up a prompt to enter database url and name,
that will be saved in `g:db_ui_save_location` connections file. These connections are available from everywhere.

When adding a connection, you'll be prompted whether it should be read-only to prevent mutation queries.

#### Connection related notes
It is possible to have two connections with same name, but from different source.
for example, you can have `my-db` in env variable, in `g:dbs` and in saved connections.
To view from which source the database is, press `H` in drawer.
If there are duplicate connection names from same source, warning will be shown and first one added will be preserved.

### Read-only mode
Connections can be marked as read-only to prevent accidental data modifications in production databases.
Read-only mode blocks INSERT, UPDATE, DELETE, DROP, ALTER, and other mutation queries while allowing SELECT queries.

To mark a connection as read-only, add `"read_only": 1` to your `connections.json`:
```json
[
{
"name": "production",
"url": "postgresql://user:pass@host:5432/db",
"read_only": 1
}
]
```

Read-only connections show `[READ-ONLY]` in the drawer and statusline.

## Settings

An overview of all settings and their default values can be found at `:help vim-dadbod-ui`.
Expand Down
33 changes: 22 additions & 11 deletions autoload/db_ui.vim
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,11 @@ function! db_ui#statusline(...)
call add(content, entry)
endif
endfor
return prefix.join(content, separator)
let result = prefix.join(content, separator)
if get(b:, 'dbui_read_only', 0)
let result = '[READ-ONLY] ' . result
endif
return result
endfunction

function! s:dbui.new() abort
Expand Down Expand Up @@ -259,7 +263,8 @@ function! s:dbui.generate_new_db_entry(db) abort
\ 'schema_support': 0,
\ 'quote': 0,
\ 'default_scheme': '',
\ 'filetype': ''
\ 'filetype': '',
\ 'read_only': get(a:db, 'read_only', 0)
\ }

call self.populate_schema_info(db)
Expand Down Expand Up @@ -288,7 +293,7 @@ function! s:dbui.populate_from_global_variable() abort
if exists('g:db') && !empty(g:db)
let url = self.resolve_url_global_variable(g:db)
let gdb_name = split(url, '/')[-1]
call self.add_if_not_exists(gdb_name, url, 'g:dbs')
call self.add_if_not_exists(gdb_name, url, 'g:dbs', 0)
endif

if !exists('g:dbs') || empty(g:dbs)
Expand All @@ -297,13 +302,14 @@ function! s:dbui.populate_from_global_variable() abort

if type(g:dbs) ==? type({})
for [db_name, Db_url] in items(g:dbs)
call self.add_if_not_exists(db_name, self.resolve_url_global_variable(Db_url), 'g:dbs')
call self.add_if_not_exists(db_name, self.resolve_url_global_variable(Db_url), 'g:dbs', 0)
endfor
return self
endif

for db in g:dbs
call self.add_if_not_exists(db.name, self.resolve_url_global_variable(db.url), 'g:dbs')
let read_only = get(db, 'read_only', 0)
call self.add_if_not_exists(db.name, self.resolve_url_global_variable(db.url), 'g:dbs', read_only)
endfor

return self
Expand All @@ -326,7 +332,7 @@ function! s:dbui.populate_from_dotenv() abort
for [name, url] in items(all_envs)
if stridx(name, prefix) != -1
let db_name = tolower(join(split(name, prefix)))
call self.add_if_not_exists(db_name, url, 'dotenv')
call self.add_if_not_exists(db_name, url, 'dotenv', 0)
endif
endfor
endfunction
Expand All @@ -350,7 +356,7 @@ function! s:dbui.populate_from_env() abort
\ printf('Found %s variable for db url, but unable to parse the name. Please provide name via %s', g:db_ui_env_variable_url, g:db_ui_env_variable_name))
endif

call self.add_if_not_exists(env_name, env_url, 'env')
call self.add_if_not_exists(env_name, env_url, 'env', 0)
return self
endfunction

Expand All @@ -371,20 +377,25 @@ function! s:dbui.populate_from_connections_file() abort
let file = db_ui#utils#readfile(self.connections_path)

for conn in file
call self.add_if_not_exists(conn.name, conn.url, 'file')
call self.add_if_not_exists(conn.name, conn.url, 'file', get(conn, 'read_only', 0))
endfor

return self
endfunction

function! s:dbui.add_if_not_exists(name, url, source) abort
function! s:dbui.add_if_not_exists(name, url, source, ...) abort
let existing = get(filter(copy(self.dbs_list), 'v:val.name ==? a:name && v:val.source ==? a:source'), 0, {})
if !empty(existing)
return db_ui#notifications#warning(printf('Warning: Duplicate connection name "%s" in "%s" source. First one added has precedence.', a:name, a:source))
endif
return add(self.dbs_list, {
let read_only = get(a:, 1, 0)
let entry = {
\ 'name': a:name, 'url': db_ui#resolve(a:url), 'source': a:source, 'key_name': printf('%s_%s', a:name, a:source)
\ })
\ }
if read_only
let entry.read_only = read_only
endif
return add(self.dbs_list, entry)
endfunction

function! s:dbui.is_tmp_location_buffer(db, buf) abort
Expand Down
28 changes: 24 additions & 4 deletions autoload/db_ui/connections.vim
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ function! s:connections.add_full_url() abort
return db_ui#notifications#error(v:exception)
endtry

return self.save(name, url)
let read_only = 0
let read_only_choice = confirm('Should this connection be read-only (prevents mutation queries)?', "&No\n&Yes")
if read_only_choice ==? 2
let read_only = 1
endif

return self.save(name, url, read_only)
endfunction

function! s:connections.rename(db) abort
Expand Down Expand Up @@ -99,8 +105,17 @@ function! s:connections.rename(db) abort
return db_ui#notifications#error(v:exception)
endtry

let read_only = get(entry, 'read_only', 0)
let read_only_default = read_only ? 2 : 1
let read_only_choice = confirm('Should this connection be read-only (prevents mutation queries)?', "&No\n&Yes", read_only_default)
let read_only = read_only_choice ==? 2 ? 1 : 0

call remove(connections, idx)
let connections = insert(connections, {'name': name, 'url': url }, idx)
let new_conn = {'name': name, 'url': url}
if read_only
let new_conn.read_only = 1
endif
let connections = insert(connections, new_conn, idx)
return self.write(connections)
endfunction

Expand All @@ -119,7 +134,7 @@ function! s:connections.get_file() abort
return printf('%s/%s', save_folder, 'connections.json')
endfunction

function s:connections.save(name, url) abort
function s:connections.save(name, url, ...) abort
let file = self.get_file()
let dir = fnamemodify(file, ':p:h')

Expand All @@ -137,7 +152,12 @@ function s:connections.save(name, url) abort
call db_ui#notifications#error('Connection with that name already exists. Please enter different name.')
return 0
endif
call add(file, {'name': a:name, 'url': a:url})
let read_only = get(a:, 1, 0)
let conn = {'name': a:name, 'url': a:url}
if read_only
let conn.read_only = 1
endif
call add(file, conn)
return self.write(file)
endfunction

Expand Down
4 changes: 4 additions & 0 deletions autoload/db_ui/drawer.vim
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,10 @@ endfunction
function! s:drawer.add_db(db) abort
let db_name = a:db.name

if get(a:db, 'read_only', 0)
let db_name = '[READ-ONLY] ' . db_name
endif

if !empty(a:db.conn_error)
let db_name .= ' '.g:db_ui_icons.connection_error
elseif !empty(a:db.conn)
Expand Down
13 changes: 12 additions & 1 deletion autoload/db_ui/query.vim
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ function! s:query.setup_buffer(db, opts, buffer_name, was_single_win) abort
let b:dbui_table_name = get(a:opts, 'table', '')
let b:dbui_schema_name = get(a:opts, 'schema', '')
let b:db = a:db.conn
let b:dbui_read_only = get(a:db, 'read_only', 0)
let is_existing_buffer = get(a:opts, 'existing_buffer', 0)
let is_tmp = self.drawer.dbui.is_tmp_location_buffer(a:db, a:buffer_name)
let db_buffers = self.drawer.dbui.dbs[a:db.key_name].buffers
Expand Down Expand Up @@ -213,12 +214,22 @@ endfunction
function! s:query.execute_query(...) abort
let is_visual_mode = get(a:, 1, 0)
let lines = self.get_lines(is_visual_mode)

" Check for read-only mode
let db = self.drawer.dbui.dbs[b:dbui_db_key_name]
if get(db, 'read_only', 0)
let query_text = join(lines, "\n")
let error = db_ui#utils#validate_query_for_read_only(query_text)
if !empty(error)
return db_ui#notifications#error(error)
endif
endif

call s:start_query()
if !is_visual_mode && search(s:bind_param_rgx, 'n') <= 0
call db_ui#utils#print_debug({ 'message': 'Executing whole buffer', 'command': '%DB' })
silent! exe '%DB'
else
let db = self.drawer.dbui.dbs[b:dbui_db_key_name]
call self.execute_lines(db, lines, is_visual_mode)
endif
let has_async = exists('*db#cancel')
Expand Down
53 changes: 53 additions & 0 deletions autoload/db_ui/utils.vim
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,56 @@ function! db_ui#utils#print_debug(msg) abort

echom '[DBUI Debug] '.string(a:msg)
endfunction

function! db_ui#utils#is_query_mutation(query) abort
let blocked_keywords = [
\ 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER',
\ 'TRUNCATE', 'REPLACE', 'MERGE',
\ 'GRANT', 'REVOKE', 'RENAME',
\ 'CREATE TABLE', 'CREATE INDEX', 'CREATE DATABASE',
\ 'CREATE SCHEMA', 'CREATE VIEW', 'CREATE FUNCTION',
\ 'CREATE PROCEDURE', 'CREATE TRIGGER'
\ ]

let upper_query = toupper(trim(a:query))

" Remove single line comments
let upper_query = substitute(upper_query, '--[^\n]*', '', 'g')
" Remove multi-line comments
let upper_query = substitute(upper_query, '/\*\_.\{-}\*/', '', 'g')
" Remove string literals to avoid false positives
let upper_query = substitute(upper_query, '''[^'']*''', '''''', 'g')
let upper_query = substitute(upper_query, '"[^"]*"', '""', 'g')
let upper_query = trim(upper_query)

" Split by semicolons to check each statement
let statements = split(upper_query, ';')

for statement in statements
let statement = trim(statement)
if empty(statement)
continue
endif

" Check for blocked mutation keywords at the start of each statement
for blocked_keyword in blocked_keywords
if statement =~# '^\s*' . blocked_keyword . '\>'
return 1
endif

" Check for WITH clause followed by mutation
if statement =~# '^\s*WITH\s\+.\{-}\s\+' . blocked_keyword . '\>'
return 1
endif
endfor
endfor

return 0
endfunction

function! db_ui#utils#validate_query_for_read_only(query) abort
if db_ui#utils#is_query_mutation(a:query)
return 'Mutation queries are not allowed in read-only mode'
endif
return ''
endfunction
28 changes: 28 additions & 0 deletions doc/dadbod-ui.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vim-dadbod-ui *vim-dadbod-ui*
9. Functions |vim-dadbod-ui-functions|
10. Autocommands |vim-dadbod-ui-autocommands|
11. Highlights |vim-dadbod-ui-highlights|
12. Read-only mode |vim-dadbod-ui-read-only|

==============================================================================
1. Introduction *vim-dadbod-ui-introduction*
Expand All @@ -34,6 +35,7 @@ Main features:
3. Save queries on single location for later use
4. Define custom table helpers
5. Bind parameters
6. Read-only mode for production database safety

==============================================================================
2. Install *vim-dadbod-ui-install*
Expand Down Expand Up @@ -899,6 +901,32 @@ g:db_ui_drawer_sections
<
Default value: `['new_query', 'buffers', 'saved_queries', 'schemas']`

==============================================================================
12. Read-only mode *vim-dadbod-ui-read-only*

Connections can be marked as read-only to prevent accidental data
modifications in production databases. Read-only mode blocks mutation queries
(INSERT, UPDATE, DELETE, DROP, ALTER, etc.) while allowing SELECT queries.

When adding a connection via |DBUIAddConnection|, you'll be prompted to mark
it as read-only. To manually mark connections as read-only, edit the
`connections.json` file in your |g:db_ui_save_location| directory:
>
[
{
"name": "production",
"url": "postgresql://user:pass@host:5432/db",
"read_only": 1
}
]
<

Read-only connections display `[READ-ONLY]` in the drawer and statusline.

Note: The validator scans all statements in your buffer. If any statement
is a mutation, the entire execution is blocked.


*g:db_ui_default_query*
g:db_ui_default_query (DEPRECATED)
This value was intially used as a default value for the table
Expand Down
37 changes: 37 additions & 0 deletions test/test-read-only-mode.vim
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
let s:suite = themis#suite('Read-only mode')
let s:expect = themis#helper('expect')

function! s:suite.before() abort
call SetupTestDbs()
endfunction

function! s:suite.after() abort
call Cleanup()
endfunction

function! s:suite.should_detect_mutation_queries() abort
call s:expect(db_ui#utils#is_query_mutation('INSERT INTO users VALUES (1, "test")')).to_be_true()
call s:expect(db_ui#utils#is_query_mutation('UPDATE users SET name = "test"')).to_be_true()
call s:expect(db_ui#utils#is_query_mutation('DELETE FROM users WHERE id = 1')).to_be_true()
call s:expect(db_ui#utils#is_query_mutation('DROP TABLE users')).to_be_true()
call s:expect(db_ui#utils#is_query_mutation('CREATE TABLE users (id INT)')).to_be_true()
endfunction

function! s:suite.should_allow_select_queries() abort
call s:expect(db_ui#utils#is_query_mutation('SELECT * FROM users')).to_be_false()
call s:expect(db_ui#utils#is_query_mutation('SHOW TABLES')).to_be_false()
call s:expect(db_ui#utils#is_query_mutation('DESCRIBE users')).to_be_false()
endfunction

function! s:suite.should_handle_multi_statement_queries() abort
" The original bug report case - SELECT followed by DELETE
call s:expect(db_ui#utils#is_query_mutation("SELECT\n *\nFROM\n user_entity\nLIMIT 10;\n\nDELETE FROM user_entity WHERE user_id = 'x'")).to_be_true()

call s:expect(db_ui#utils#is_query_mutation("SELECT * FROM users;\nDELETE FROM users WHERE id = 1")).to_be_true()
call s:expect(db_ui#utils#is_query_mutation("SELECT * FROM users;\nSELECT * FROM posts")).to_be_false()
endfunction

function! s:suite.should_ignore_keywords_in_comments_and_strings() abort
call s:expect(db_ui#utils#is_query_mutation("-- DELETE FROM users\nSELECT * FROM users")).to_be_false()
call s:expect(db_ui#utils#is_query_mutation("SELECT * FROM users WHERE name = 'DELETE'")).to_be_false()
endfunction
Loading