zuzu-rust 0.4.0

Rust implementation of ZuzuScript
Documentation
from test/more import *;
requires_capability( "fs" );
requires_capability( "db" );

from std/data/csv import CSV, CSVReader, CSVWriter;
from std/db import DB;
from std/io import Path;

let csv := new CSV(
	headers: true,
);

let iter_path := Path.tempfile();
iter_path.spew_utf8(
	"name,age\n"
	_ "Ada,32\n"
	_ "Bob,27\n"
	_ "Cam,40\n",
);
let reader := csv.open(iter_path);
ok( reader instanceof CSVReader, "open returns CSVReader" );
is( reader.headers(), [ "name", "age" ], "reader exposes parsed headers" );
is( reader.columns(), [ "name", "age" ], "columns aliases headers" );
is( reader.next(){name}, "Ada", "next returns default row shape" );
is( reader.row_number(), 1, "row_number tracks consumed rows" );
let seen := [];
for ( let row in reader ) {
	seen.push( row{name} );
}
is( seen, [ "Bob", "Cam" ], "reader object is iterable with for loop" );
reader.close();

let array_reader_path := Path.tempfile();
array_reader_path.spew_utf8(
	"skip this line\n"
	_ "\n"
	_ "#comment\n"
	_ "1,2\n"
	_ "3,4\n",
);
let array_reader := ( new CSV(
	headers: false,
	skip_lines: 1,
	skip_empty_rows: true,
	comment_char: "#",
) ).open(array_reader_path);
is(
	array_reader.next_array(),
	[ "1", "2" ],
	"reader skips configured lines, blanks, and comments",
);
is( array_reader.all_array(), [ [ "3", "4" ] ], "all_array returns remaining rows" );
array_reader.close();

let manual_reader_path := Path.tempfile();
manual_reader_path.spew_utf8( "1,2\n3,4\n" );
let manual_reader := ( new CSV( headers: false ) ).open(manual_reader_path);
is(
	manual_reader.set_columns( [ "x", "y" ] ),
	[ "x", "y" ],
	"set_columns installs explicit column names",
);
is( manual_reader.next_dict(){y}, "2", "set_columns switches reader to dict rows" );
manual_reader.close();

let dump_path := Path.tempfile();
let dump_result := csv.dump(
	dump_path,
	[
		{ name: "Ada", age: "32" },
		{ name: "Bob", age: "27" },
	],
);
ok( dump_result ≢ null, "dump returns destination path" );
let loaded_back := csv.load(dump_path);
is( loaded_back[0]{name}, "Ada", "dump + load roundtrips header-based rows" );
is( loaded_back[1]{age}, "27", "dump + load roundtrips later row values" );

let writer_path := Path.tempfile();
let writer := csv.open_writer(writer_path);
ok( writer instanceof CSVWriter, "open_writer returns CSVWriter" );
is( writer.write_header( [ "name", "age" ] ), [ "name", "age" ], "write_header returns active columns" );
is( writer.write_row( { name: "Ada", age: 32 } )[1], 32, "write_row returns serialized field array" );
is( writer.print_row( { name: "Bob", age: 27 } )[0], "Bob", "print_row aliases write_row" );
is( writer.row_number(), 2, "writer row_number tracks written rows" );
writer.close();
let writer_loaded := csv.load(writer_path);
is( writer_loaded.length(), 2, "writer output can be loaded back" );
is( writer_loaded[1]{name}, "Bob", "writer output preserves later row data" );

let append_writer := csv.open_writer(
	writer_path,
	{
		append: true,
		headers: false,
		columns: [ "name", "age" ],
	},
);
append_writer.write_row( { name: "Cam", age: 40 } );
append_writer.close();
let appended_rows := csv.load(writer_path);
is( appended_rows.length(), 3, "append writer adds rows without rewriting header" );
is( appended_rows[2]{name}, "Cam", "append writer adds trailing row" );

like(
	exception( function() {
		csv.load("people.csv");
	} ),
	/TypeException: CSV.load expects Path as first argument/,
	"CSV.load rejects non-Path target",
);

let dbh := DB.temp();
dbh.prepare( "create table users (id integer, name text, role text)" ).execute();
dbh.prepare( "insert into users (id, name, role) values (1, 'Ada', 'dev')" ).execute();
dbh.prepare( "insert into users (id, name, role) values (2, 'Bob', 'ops')" ).execute();

let export_path := Path.tempfile();
csv.dump_table( export_path, dbh, "users" );
let exported := csv.load(export_path);
is( exported.length(), 2, "dump_table writes all database rows" );
is( exported[0]{name}, "Ada", "dump_table includes first row data" );
is( exported[1]{role}, "ops", "dump_table includes later row data" );

let export_query_path := Path.tempfile();
csv.dump_query(
	export_query_path,
	dbh,
	"select name, role from users where id >= ? order by id",
	[ 2 ],
	{
		headers: true,
	},
);
let query_rows := csv.load(export_query_path);
is( query_rows.length(), 1, "dump_query exports filtered query results" );
is( query_rows[0]{role}, "ops", "dump_query preserves selected columns" );

let import_path := Path.tempfile();
import_path.spew_utf8(
	"name,score,active\n"
	_ "Ada,9,true\n"
	_ "Bob,7,false\n",
);
let created_count := csv.load_table(
	import_path,
	dbh,
	"scores",
	{
		create_table: true,
		column_types: {
			name: "text",
			score: "integer",
			active: "text",
		},
	},
);
is( created_count, 2, "load_table reports inserted row count" );
let scores_q := dbh.prepare( "select name, score, active from scores order by name" );
scores_q.execute();
let score_rows := scores_q.all_dict();
is( score_rows.length(), 2, "load_table inserted rows into new table" );
is( score_rows[0]{score}, 9, "load_table preserved integer-like value" );
is( score_rows[1]{active}, "false", "load_table preserved text field" );

let mapped_path := Path.tempfile();
mapped_path.spew_utf8(
	"person_name,team_role\n"
	_ "Eve,qa\n",
);
let mapped_count := csv.load_table(
	mapped_path,
	dbh,
	"people_map",
	{
		create_table: true,
		column_map: {
			person_name: "name",
			team_role: "role",
		},
		column_types: {
			name: "text",
			role: "text",
		},
		batch_size: 1,
		transaction: true,
	},
);
is( mapped_count, 1, "load_table supports column mapping and batched transaction import" );
let mapped_q := dbh.prepare( "select name, role from people_map" );
mapped_q.execute();
is( mapped_q.next_dict(){name}, "Eve", "column mapping writes renamed columns" );

let append_path := Path.tempfile();
append_path.spew_utf8( "3,Cam,qa\n4,Dee,pm\n" );
let append_count := ( new CSV() ).load_table(
	append_path,
	dbh,
	"users",
	{
		headers: false,
		columns: [ "id", "name", "role" ],
	},
);
is( append_count, 2, "load_table appends rows into existing table" );
let user_q := dbh.prepare( "select count(*) as total from users" );
user_q.execute();
is( user_q.next_dict(){total}, 4, "existing table import increases row count" );

let conflict_path := Path.tempfile();
conflict_path.spew_utf8( "id,name,role\n1,Ada,lead\n" );
let conflict_count := csv.load_table(
	conflict_path,
	dbh,
	"users",
	{
		conflict: "ignore",
		transaction: true,
	},
);
is( conflict_count, 1, "load_table reports attempted inserts even with conflict policy" );
let unchanged_q := dbh.prepare( "select role from users where id = 1" );
unchanged_q.execute();
is( unchanged_q.next_dict(){role}, "dev", "conflict ignore leaves existing row unchanged" );

done_testing();