feat: migrate system + cross-DB engine + IMPORT/EXPORT syntax -- 22 files, client+server+docs
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled

This commit is contained in:
2026-05-21 19:32:14 +03:00
parent f427ba5968
commit bb843b9a03
23 changed files with 2306 additions and 52 deletions
+15
View File
@@ -31,6 +31,8 @@ type
nkMigrationStatus
nkMigrationUp
nkMigrationDown
nkImportFrom
nkExportTo
nkMigrationDryRun
nkCreateUser
nkDropUser
@@ -363,6 +365,19 @@ type
mdCount*: int # 0 = 1, N = rollback N migrations
of nkMigrationDryRun:
mdrName*: string # migration name to dry-run
of nkImportFrom:
impPath*: string
impTable*: string
impFormat*: string # "csv", "json", "ndjson"
impDelimiter*: char
impHasHeader*: bool
impBatchSize*: int
of nkExportTo:
expPath*: string
expTable*: string
expFormat*: string
expDelimiter*: char
expIncludeHeader*: bool
of nkCreateIndex:
ciTarget*: string
ciName*: string
+100
View File
@@ -23,6 +23,7 @@ import ../storage/btree
import ../storage/wal
import ../core/mvcc
import ../core/tracing
import ../client/fileops
import ../fts/engine as fts
import ../core/registry
@@ -219,6 +220,24 @@ proc sqlEscapeString*(s: string): string =
## Escape SQL string literals by doubling single-quotes.
result = s.replace("'", "''")
proc buildInsertSql*(table: string, columns: seq[string], rows: seq[seq[string]]): string =
## Build a multi-row INSERT statement for bulk import.
result = "INSERT INTO \"" & sqlEscapeIdent(table) & "\" ("
for i, col in columns:
if i > 0: result &= ", "
result &= "\"" & sqlEscapeIdent(col) & "\""
result &= ") VALUES "
for ri, row in rows:
if ri > 0: result &= ", "
result &= "("
for ci, val in row:
if ci > 0: result &= ", "
if val.len == 0 or val == "\\N":
result &= "NULL"
else:
result &= "'" & sqlEscapeString(val) & "'"
result &= ")"
proc exprToSql(node: Node): string =
if node == nil:
return ""
@@ -5279,6 +5298,87 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
return okResult(msg="Rolled back " & $rolledBackCount & " migrations")
of nkImportFrom:
let path = stmt.impPath
let table = stmt.impTable
let format = stmt.impFormat
if not fileExists(path):
return errResult("File not found: " & path)
let content = readFile(path)
var columns: seq[string] = @[]
var rows: seq[seq[string]] = @[]
case format
of "csv":
(columns, rows) = parseCsvTable(content, stmt.impDelimiter, stmt.impHasHeader)
of "json":
(columns, rows) = parseJsonTable(content)
of "ndjson":
(columns, rows) = parseNdjsonTable(content)
else:
return errResult("Unsupported import format: " & format)
if columns.len == 0:
return errResult("No columns found in import file")
var inserted = 0
let batchSize = stmt.impBatchSize
var batchRows: seq[seq[string]] = @[]
for row in rows:
batchRows.add(row)
if batchRows.len >= batchSize:
# Generate INSERT INTO t (c1,c2) VALUES (...),(...) and execute
let sql = buildInsertSql(table, columns, batchRows)
let tokens = qlex.tokenize(sql)
let astNode = qpar.parse(tokens)
if astNode.stmts.len > 0:
let insResult = executeQueryImpl(ctx, astNode)
if insResult.success:
inserted += batchRows.len
batchRows.setLen(0)
if batchRows.len > 0:
let sql = buildInsertSql(table, columns, batchRows)
let tokens = qlex.tokenize(sql)
let astNode = qpar.parse(tokens)
if astNode.stmts.len > 0:
let insResult = executeQueryImpl(ctx, astNode)
if insResult.success:
inserted += batchRows.len
return okResult(msg="IMPORTED " & $inserted & " rows into " & table)
of nkExportTo:
let path = stmt.expPath
let table = stmt.expTable
let format = stmt.expFormat
var rows: seq[Row] = @[]
var cols: seq[string] = @[]
let scanResult = execScan(ctx, table)
if scanResult.len == 0:
let tbl = ctx.getTableDef(table)
if tbl.columns.len == 0:
return errResult("Table not found: " & table)
for col in tbl.columns:
cols.add(col.name)
else:
for k, v in scanResult[0].pairs:
cols.add(k)
rows = scanResult
var content = ""
var strRows: seq[seq[string]] = @[]
for row in rows:
var strRow: seq[string] = @[]
for col in cols:
strRow.add(if col in row: valueToString(row[col]) else: "")
strRows.add(strRow)
case format
of "csv":
content = toCsv(cols, strRows, stmt.expDelimiter, stmt.expIncludeHeader)
of "json":
content = toJson(cols, strRows)
of "ndjson":
content = toNdjson(cols, strRows)
else:
return errResult("Unsupported export format: " & format)
writeFile(path, content)
return okResult(msg="EXPORTED " & $strRows.len & " rows to " & path)
of nkMigrationDryRun:
let (found, upBody, downBody) = getMigrationBody(ctx, stmt.mdrName)
if not found:
+18
View File
@@ -102,6 +102,15 @@ type
tkAfter
tkInstead
tkOf
tkImport
tkExport
tkFormat
tkDelimiter
tkHeader
tkBatch
tkCsv
tkJsonFmt
tkNdjson
tkMigration
tkApply
tkStatus
@@ -334,6 +343,15 @@ const keywords*: Table[string, TokenKind] = {
"after": tkAfter,
"instead": tkInstead,
"of": tkOf,
"import": tkImport,
"export": tkExport,
"format": tkFormat,
"delimiter": tkDelimiter,
"header": tkHeader,
"batch": tkBatch,
"csv": tkCsv,
"json": tkJsonFmt,
"ndjson": tkNdjson,
"migration": tkMigration,
"apply": tkApply,
"status": tkStatus,
+59
View File
@@ -1494,6 +1494,63 @@ proc parseMigrationDryRun(p: var Parser): Node =
let name = p.expect(tkIdent).value
result = Node(kind: nkMigrationDryRun, mdrName: name, line: tok.line, col: tok.col)
proc parseImportFrom(p: var Parser): Node =
let tok = p.expect(tkImport)
discard p.expect(tkFrom)
let path = p.expect(tkStringLit).value
discard p.expect(tkInto)
let table = p.expect(tkIdent).value
var format = "csv"
var delimiter = ','
var hasHeader = true
var batchSize = 1000
while p.peek().kind in {tkFormat, tkDelimiter, tkHeader, tkBatch}:
let kw = p.advance()
case kw.kind
of tkFormat:
let fmt = p.expect(tkIdent).value.toLower()
format = fmt
of tkDelimiter:
let delim = p.expect(tkStringLit).value
if delim.len > 0: delimiter = delim[0]
of tkHeader:
let hdr = p.expect(tkIdent).value.toLower()
hasHeader = hdr == "true" or hdr == "yes"
of tkBatch:
batchSize = parseInt(p.expect(tkIntLit).value)
else: discard
result = Node(kind: nkImportFrom, impPath: path, impTable: table,
impFormat: format, impDelimiter: delimiter,
impHasHeader: hasHeader, impBatchSize: batchSize,
line: tok.line, col: tok.col)
proc parseExportTo(p: var Parser): Node =
let tok = p.expect(tkExport)
discard p.expect(tkTo)
let path = p.expect(tkStringLit).value
discard p.expect(tkFrom)
let table = p.expect(tkIdent).value
var format = "csv"
var delimiter = ','
var includeHeader = true
while p.peek().kind in {tkFormat, tkDelimiter, tkHeader}:
let kw = p.advance()
case kw.kind
of tkFormat:
let fmt = p.expect(tkIdent).value.toLower()
format = fmt
of tkDelimiter:
let delim = p.expect(tkStringLit).value
if delim.len > 0: delimiter = delim[0]
of tkHeader:
let hdr = p.expect(tkIdent).value.toLower()
includeHeader = hdr == "true" or hdr == "yes"
else: discard
result = Node(kind: nkExportTo, expPath: path, expTable: table,
expFormat: format, expDelimiter: delimiter,
expIncludeHeader: includeHeader,
line: tok.line, col: tok.col)
proc parseCreateUser(p: var Parser): Node =
let tok = p.expect(tkCreate)
discard p.expect(tkUser)
@@ -1701,6 +1758,8 @@ proc parseStatement*(p: var Parser): Node =
case p.peek().kind
of tkWith, tkSelect: p.parseSelect()
of tkInsert: p.parseInsert()
of tkImport: p.parseImportFrom()
of tkExport: p.parseExportTo()
of tkUpdate: p.parseUpdate()
of tkDelete: p.parseDelete()
of tkMerge: p.parseMerge()