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
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user