Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / Win32

mscript 3.0: Database Programming, Logging, and Many Language Improvements

4.80/5 (3 votes)
26 May 2022Apache4 min read 5.8K   83  
Check out the third major revision of mscript for replacing your nasty batch files with simple mscripts
Version 3.0 of mscript adds DLLs for cx_Logging-based logging and SQL and NoSQL database programming. A preprocessor allows for line continuations, and // comments that can appear anywhere on a line. The * and & statement prefixes are no longer required. A syntax checker catches many script errors.

Introduction

With the third major version of mscript, I got feedback and guidance that informed my code changes and DLL development. I invite your insights about the good, the bad, and the ugly, and I commit to taking your input into account as I continue to shape mscript to free you from the limits of batch files and the weight of Powershell and Python.

In this article, I will go over the new database and logging DLLs, and the new argument processing function, parseArgs().

Before launching into API and implementation details, let's have a look at a couple sample scripts:

  1. file_search.ms - load the lines of a text file into a 4db, then supply full-text keyword search against the lines
  2. file_sizes.ms - load file size information about a directory, then provide searching for file count and space taken

1. file_search.ms - Full-text Keyword Searching of Lines in a File

C++
/*
This sample program reads lines from an input file 
and writes them to a 4db NoSQL database.
Once the database is loaded, the user enters search terms and gets matching lines.
This sample demonstrates command line processing, file I/O, and 4db database programming.
Note the extensive use of \ line continuations: mscripts may be line-based, 
but they don't have to be ugly!
*/

+ "mscript-db.dll"

/ Define the command line flags we expect as arguments specifications
/ The arguments specifications are represented as a list of indexes
$ arg_specs = \
list \
( \
	index \
	( \
		"flag", "-in", \
		"long-flag", "--input_file_path", \
		"description", "Path of the input file to process", \
		"takes", true, \
		"required", true \
	), \	
	index \
	( \
		"flag", "-db", \
		"long-flag", "--database_file_path", \
		"description", "Path for the database file", \
		"takes", true, \
		"required", true \
	), \
	index \
	( \
		"flag", "-enc", \
		"long-flag", "--text_encoding", \
		"description", "The encoding of the input text file", \
		"takes", true, \
		"required", true, \
		"default", "utf8" \
	) \
)
$ args = parseArgs(arguments, arg_specs)
$ input_file_path = args.get("-in")
$ database_file_path = args.get("-db")
$ text_encoding = args.get("-enc")

>>

>> Command line arguments:
> "Input file:     " + input_file_path
> "Database file:  " + database_file_path
> "Text encoding:  " + text_encoding
! err
	> "Error processing command line parameters: " + err
	exit(1)
}

>>

>> Reading lines from input file...
$ file_lines = readFileLines(input_file_path, text_encoding)
! err
	> "Error reading input file: " + err
	exit(1)
}
> "File Line Count: " + file_lines.length()

>> Getting 4db database up and running...
exec('del "' + database_file_path + '"') // fresh DB file every time
msdb_4db_init("db", database_file_path)
! err
	> "Error initializing database: " + err
	exit(1)
}

>> Importing lines into database...
++ f : 0 -> file_lines.length() -1
	/ NOTE: Using line as the primary key did not handle identical lines,
	/ 		hence the line number
	$ line = file_lines.get(f)
	$ line_number = f + 1
	msdb_4db_define("db", "lines", line_number + ": " + 
                     line, index("len", length(line)))
}
! err
	> "Error importing lines into database: " + err
	exit(1)
}

>> Database loaded, ready to query!
O // loop forever...until Ctrl-C at least
	>>
	>> Enter your search criteria:
	$ criteria = trimmed(input())
	? criteria.length() == 0
		^
	}
	>>
	> "Criteria: " + criteria
	>>
	
	/ Get the results, using the "value" column, which is the primary key
	/ which is the text of the line, to SELECT out, and to MATCH the criteria
	/ We sort by id for top-to-bottom file output
	/ The criteria is passed into msdb_4db_query as a param->value index
	$ results = \
	msdb_4db_query \
	( \
		"db", \
		"SELECT value, id FROM lines WHERE value MATCHES @criteria ORDER BY id", \
		index("@criteria", criteria) \
	)
	
	/ Process the results
	$ result_count = results.length()
	> "Results: " + (result_count - 1) // ignore the column headers "result"
	++ idx : 1 -> result_count - 1
		$ line = results.get(idx)
		> line.get(0) // Ignore the id column which was just needed for sorting
	}
	! err
		> "Error getting search results: " + err
	}
}

2. file_sizes.ms - Directory Indexing for File Size Stats

This samples demonstrates directory processing, logging, and SQL programming:

C++
/*
This sample determines the size of files in a given directory
and then the user can get file size stats
The progress in generating this data is written to a log file
*/
+ "mscript-log.dll"
+ "mscript-db.dll"

$ arg_specs = \
list \
( \
	index \
	( \
		"flag", "-dir", \
		"long-flag", "--directory", \
		"description", "What directory should be processed to get file sizes?", \
		"takes", true, \
		"default", "." \
	), \
	index \
	( \
		"flag", "-db", \
		"long-flag", "--database-file-path", \
		"description", "Where should the file sizes database be put?", \
		"takes", true, \
		"default", "file-sizes.db" \
	), \
	index \
	( \
		"flag", "-log", \
		"long-flag", "--log-file-path", \
		"description", "Where should log output be written?", \
		"takes", true, \
		"default", "file-sizes.log" \
	), \
	index \
	( \
		"flag", "-ll", \
		"long-flag", "--log-level", \
		"description", "What level to log at? (DEBUG, INFO, ERROR, or NONE)", \
		"takes", true, \
		"default", "INFO" \
	), \
	index \
	( \
		"flag", "-rebuild", \
		"long-flag", "--rebuild-index", \
		"description", "Should the program start over and rebuild the index?", \
		"default", false \
	) \
)
$ args = parseArgs(arguments, arg_specs)

$ starting_dir_path = args.get("-dir")
$ db_file_path = args.get("-db")
$ log_file_path = args.get("-log")
$ log_level = args.get("-ll")
$ rebuild = args.get("-rebuild")

>>
>> Configuration:
> "Dir Path:      " + starting_dir_path
> "DB File Path:  " + db_file_path
> "Log File Path: " + log_file_path
> "Log Level:     " + log_level
> "Rebuild:       " + rebuild
>>
! err
	> "Processing command line arguments failed: " + err
	exit(1)
}

/ Start logging to a fresh file every time
$ exec_options =  index("ignore_errors", true)
exec(fmt('del "{0}"', log_file_path), exec_options)
mslog_start(log_file_path, log_level)
~ logError(msg)
	mslog_error(msg)
	> "ERROR: " + msg
}
~ logMsg(msg)
	mslog_info(msg)
	> msg
}
~ logDebug(msg)
	mslog_debug(msg)
}

$ did_db_file_exist = false
{
	$ exec_result = exec(fmt('dir "{0}"', db_file_path), exec_options)
	did_db_file_exist = 
      firstLocation(trimmed(exec_result.get("output")), "File Not Found") < 0
}

? !did_db_file_exist
	rebuild = true
}

? rebuild
	exec(fmt('del "{0}"', db_file_path), exec_options)
}

? rebuild
	logMsg("Processing files and folders...")
	msdb_sql_init("db", db_file_path)
	msdb_sql_exec \
	( \
		"db", \
		"CREATE TABLE file_sizes (FilePath STRING NOT NULL, 
                                  SizeBytes NUMBER NOT NULL)" \
	)
	processDir(starting_dir_path)
	msdb_sql_close("db")
}
msdb_sql_init("db", db_file_path)
! err
	logError("Setting up index failed: " + err)
	exit(1)
}

O // UI loop
	>>
	>> Enter the path pattern to compute stats.  Like *.mp3
	>>
	$ pattern = trimmed(input())
	$ sql_pattern = "%" + pattern.replaced("*", "%")
	$ sql_query = \
		"SELECT COUNT(*), SUM(SizeBytes) FROM file_sizes WHERE FilePath LIKE @like"
	$ results = msdb_sql_exec("db", sql_query, index("@like", sql_pattern))
	? results.length() <= 1
		>> No results
		^
	}
	$ result = results.get(1)

	>>
	$ file_count = result.get(0)
	? file_count = null
		file_count = 0
	}
	logMsg("Count:     " + file_count)
	/       Size (GB): 
	
	$ size_bytes = result.get(1)
	? size_bytes = null
		size_bytes = 0
	}
	$ size_str = ""
	? size_bytes > 1024 * 1024 * 1024
		size_str = "Size (GB): " + round(size_bytes / 1024 / 1024 / 1024, 2)
	}
	? size_bytes > 1024 * 1024
		size_str = "Size (MB): " + round(size_bytes / 1024 / 1024, 2)
	}
	<>
		size_str = "Size (KB): " + round(size_bytes / 1024, 2)
	}
	logMsg(size_str)
	
	! err
		> "Querying interface failed: " + err
		^
	}
}

/ Unreachable...
msdb_sql_close("db")
mslog_stop()
>> All done.

/*
Implementation of search index population
Recursive function that runs DIR at each level and parses out files to add to index
and directories to recurse on
*/
~ processDir(dirPath)
	logDebug("DIR Path: " + dirPath)
	> dirPath
	
	$ dir_output_lines = null
	{
		$ dir_result = exec(fmt('dir "{0}"', dirPath))
		$ dir_output = dir_result.get("output")
		dir_output_lines = splitLines(dir_output)
	}
	
	$ found_dirs = list()
	$ found_file_sizes = index()
	$ line_pattern = \
		"[0-9\/]+" + \
		"\s*" + \
		"[0-9\:]+\s*(AM|PM)" + \
		"\s*" + \
		"((\<DIR\>\s*)|([0-9\,]+))" + \
		"\s*" + \
		"(\S.*)"
	@ line : dir_output_lines
		/ Skip header lines
		? line.firstLocation(" ") == 0
			^
		}
		
		/ Match up the DIR parts
		$ matches = line.getMatches(line_pattern, true)
		// > "DEBUG: matches: " + matches
		? matches.length() < 2 // get at least the file size and path stem
			^
		}

		$ path_stem = trimmed(matches.get(matches.length() - 1))
		? path_stem = "." OR path_stem = ".."
			^
		}
		
		$ full_path = path_stem
		? dirPath <> "."
			full_path = dirPath + "\" + full_path
		}
		
		$ len_str = replaced(trimmed(matches.get(matches.length() - 2)), ",", "")
		? len_str.length() == 0
			found_dirs.add(full_path)
		}
		<>
			found_file_sizes.add(full_path, number(len_str))
		}
	}
	! err
		> fmt('Error processing directory "{0}": {1}', dirPath, err
		exit(1)
	}
	
	/ load the file size data we found into the DB
	$ insert_statement = \
		"INSERT INTO file_sizes (FilePath, SizeBytes) VALUES (@filePath, @sizeBytes)"
	$ insert_params = index()
	@ file_path : found_file_sizes
		insert_params.set("@filePath", file_path)
		insert_params.set("@sizeBytes", found_file_sizes.get(file_path))
		msdb_sql_exec("db", insert_statement, insert_params)
	}

	/ recurse on the dirs we found
	@ dir_path : found_dirs
		processDir(dir_path)
	}
}

New DLL APIs

Let's have a look under the hood to see the code that powered those mscripts.

mscript-log

I've found that debugging scripts is best done with good logging. I have markedly sped up my scripting with logging used properly. It can be difficult and messy without it.

cx_Logging has been around for a long time. The source code is available as a .h / .c pair at GitHub in the src directory. The code is Python-centric and portable. I was able to take a machete to my copy and get it down to supporting Win32 with no provisions for anything else. Besides erasing a lot of code, I changed the global critical section into std::mutex / std::unique_lock, and I (had to) use strcpy_s and sprintf_s wherever possible, doing spot inspections of char* usage to validate security. Not for the faint of heart, but it's done, and now we can enjoy it as a simple mscript API:

  • mslog_start(filename, log_level, optional_settings_index)
  • mslog_stop()
  • mslog_setlevel(log_level)
  • mslog_getlevel()
  • mslog_error(message)
  • mslog_info(message)
  • mslog_debug(message)

Here is the source code for the logging DLL:

C++
#include "pch.h"
#include "cx_Logging.h"

using namespace mscript;

wchar_t* __cdecl mscript_GetExports()
{
	std::vector<std::wstring> exports
	{
		L"mslog_getlevel",
		L"mslog_setlevel",

		L"mslog_start",
		L"mslog_stop",
		
		L"mslog_error",
		L"mslog_info",
		L"mslog_debug",
	};
	return module_utils::getExports(exports);
}

void mscript_FreeString(wchar_t* str)
{
	delete[] str;
}

static unsigned long getLogLevel(const std::wstring& level)
{
	if (level == L"INFO")
		return LOG_LEVEL_INFO;
	else if (level == L"DEBUG")
		return LOG_LEVEL_DEBUG;
	else if (level == L"ERROR")
		return LOG_LEVEL_ERROR;
	else if (level == L"NONE")
		return LOG_LEVEL_NONE;
	else
		raiseWError(L"Invalid log level: " + level);
}

static std::wstring getLogLevel(unsigned long level)
{
	switch (level)
	{
	case LOG_LEVEL_INFO: return L"INFO";
	case LOG_LEVEL_DEBUG: return L"DEBUG";
	case LOG_LEVEL_ERROR: return L"ERROR";
	case LOG_LEVEL_NONE: return L"NONE";
	default: raiseError("Invalid log level: " + num2str(level));
	}
}

wchar_t* mscript_ExecuteFunction(const wchar_t* functionName, 
                                 const wchar_t* parametersJson)
{
	try
	{
		std::wstring funcName = functionName;
		auto params = module_utils::getParams(parametersJson);

		if (funcName == L"mslog_getlevel")
		{
			if (params.size() != 0)
				raiseError("Takes no parameters");
			return module_utils::jsonStr(getLogLevel(GetLoggingLevel()));
		}

		if (funcName == L"mslog_setlevel")
		{
			if (params.size() != 1 || params[0].type() != object::STRING)
				raiseError("Pass in the log level: DEBUG, INFO, ERROR, or NONE");
			unsigned result = SetLoggingLevel
                              (getLogLevel(toUpper(params[0].stringVal())));
			return module_utils::jsonStr(bool(result == 0));
		}

		if (funcName == L"mslog_start")
		{
			if (params.size() < 2 || params.size() > 3)
				raiseError("Takes three parameters: filename, 
                            log level, and optional settings index");;

			if (params[0].type() != object::STRING)
				raiseError("First parameter must be filename string");
			if (params[1].type() != object::STRING)
				raiseError("Second parameter must be log level string: 
                            DEBUG, INFO, ERROR, or NONE");
			if (params.size() == 3 && params[2].type() != object::INDEX)
				raiseError("Third parameter must be a settings index");

			std::wstring filename = params[0].stringVal();
			unsigned log_level = getLogLevel(toUpper(params[1].stringVal()));

			object::index index = 
				params.size() == 3 
				? params[2].indexVal() 
				: object::index();

			object max_files;
			if
			(
				index.tryGet(toWideStr("maxFiles"), max_files)
				&&
				(
					max_files.type() != object::NUMBER
					||
					max_files.numberVal() < 0
					||
					unsigned(max_files.numberVal()) != max_files.numberVal()
				)
			)
			{
				raiseError("Invalid maxFiles parameter");
			}
			if (max_files.type() == object::NOTHING)
				max_files = double(1);

			object max_file_size_bytes;
			if
			(
				index.tryGet(toWideStr("maxFileSizeBytes"), max_file_size_bytes)
				&&
				(
					max_file_size_bytes.type() != object::NUMBER
					||
					max_file_size_bytes.numberVal() < 0
					||
					unsigned(max_file_size_bytes.numberVal()) 
                             != max_file_size_bytes.numberVal()
				)
			)
			{
				raiseError("Invalid maxFileSizeBytes parameter");
			}
			if (max_file_size_bytes.type() == object::NOTHING)
				max_file_size_bytes = double(DEFAULT_MAX_FILE_SIZE);

			object prefix;
			if
			(
				index.tryGet(toWideStr("prefix"), prefix)
				&&
				prefix.type() != object::STRING
			)
			{
				raiseError("Invalid prefix parameter");
			}
			if (prefix.type() == object::NOTHING)
				prefix = toWideStr(DEFAULT_PREFIX);

			unsigned result = 
				StartLogging
				(
					toNarrowStr(filename).c_str(),
					log_level,
					unsigned(max_files.numberVal()),
					unsigned(max_file_size_bytes.numberVal()),
					toNarrowStr(prefix.stringVal()).c_str()
				);
			return module_utils::jsonStr(bool(result == 0));
		}

		if (funcName == L"mslog_stop")
		{
			StopLogging();
			return module_utils::jsonStr(true);
		}

		{
			auto under_parts = split(funcName, L"_");
			if (under_parts.size() != 2)
				raiseWError(L"Invalid function name: " + funcName);

			unsigned int log_level = getLogLevel(toUpper(under_parts[1]));

			if (params.size() != 1 || params[0].type() != object::STRING)
				raiseWError(funcName + L": 
                            pass the log message as one string parameter");

			unsigned result =
				LogMessage(log_level, toNarrowStr(params[0].stringVal()).c_str());
			return module_utils::jsonStr(bool(result == 0));
		}

		// Unreachable
		//raiseWError(L"Unknown mscript-log function: " + funcName);
	}
	catch (const user_exception& exp)
	{
		return module_utils::errorStr(functionName, exp);
	}
	catch (const std::exception& exp)
	{
		return module_utils::errorStr(functionName, exp);
	}
	catch (...)
	{
		return nullptr;
	}
}

mscript-db

I've written about 4db and various things that led up to it for ages now. If nothing better falls out of all of that than the db, dbreader and strnum classes for doing direct SQLite programming, then it's been worth it. I'm not going to force you to use 4db if you want to hack away at SQL directly. So there are two APIs in one DLL:

SQLite

  • msdb_sql_init(db_file_path)
  • msdb_sql_close()
  • msdb_sql_exec(sql_query)
  • msdb_sql_rows_affected()
  • msdb_sql_last_inserted_id()

4db

  • msdb_4db_init(db_file_path)
  • msdb_4db_close()
  • msdb_4db_define(table_name, primary_key_value, metadata_index)
  • msdb_4db_query(sql_query, parameters_index)
  • msdb_4db_delete(table_name, primary_key_value)
  • msdb_4db_drop(table_name)

Note that I'm oversimplifying the interface. It looks like there would just be one database open globally. I didn't want it to be that restrictive, so I built in a mechanism for having multiple DBs open at once.

C++
#include "pch.h"

// One thing at a time
std::mutex g_mutex;

// Global database access object storage
// Applications start with a name and a path to the DB file on disk,
// then refer to that database by name after that
std::unordered_map<std::wstring, std::shared_ptr<fourdb::ctxt>> g_contexts;
std::unordered_map<std::wstring, std::shared_ptr<fourdb::db>> g_db_conns;

//
// CONVERSION ROUTINES
//
static fourdb::strnum convert(const mscript::object& obj)
{
	switch (obj.type())
	{
	case mscript::object::NOTHING:
		return double(0.0);

	case mscript::object::STRING:
		return obj.stringVal();

	case mscript::object::NUMBER:
		return obj.numberVal();

	case mscript::object::BOOL:
		return obj.boolVal() ? 1.0 : 0.0;

	default:
		raiseError("Invalid object type for conversion to 4db: " + 
                    mscript::num2str(int(obj.type())));
	}
}

static mscript::object convert(const fourdb::strnum& obj)
{
	if (obj.isStr())
		return obj.str();
	else
		return obj.num();
}

static fourdb::paramap convert(const mscript::object::index& idx)
{
	fourdb::paramap ret_val;
	for (const auto& it : idx.vec())
		ret_val.insert({ it.first.toString(), convert(it.second) });
	return ret_val;
}

// Given a 4db context name, get the context object
static std::shared_ptr<fourdb::ctxt> get4db(const std::wstring& name)
{
	const auto& it = g_contexts.find(name);
	if (it == g_contexts.end())
		raiseWError(L"4db not found: " + name + L" - call msdb_4db_init first");
	return it->second;
}

// Given a SQL DB context name, get the DB object
static std::shared_ptr<fourdb::db> getSqldb(const std::wstring& name)
{
	const auto& it = g_db_conns.find(name);
	if (it == g_db_conns.end())
		raiseWError(L"SQL DB not found: " + name + L" - call msdb_sql_init first");
	return it->second;
}

// Given a DB reader, return a list of lists, 
// column header names in the first list, row data in the following lists
static mscript::object::list processDbReader(fourdb::dbreader& reader)
{
	mscript::object::list ret_val;
	ret_val.push_back(mscript::object::list()); // column names

	unsigned col_count = reader.getColCount();
	ret_val[0].listVal().reserve(col_count);
	for (unsigned c = 0; c < col_count; ++c)
		ret_val[0].listVal().push_back(reader.getColName(c));

	while (reader.read())
	{
		ret_val.emplace_back(mscript::object::list());
		mscript::object::list& row_list = ret_val.back().listVal();
		row_list.reserve(col_count);
		for (unsigned c = 0; c < col_count; ++c)
		{
			bool is_null = false;
			fourdb::strnum val = reader.getStrNum(c, is_null);
			row_list.push_back(is_null ? mscript::object() : convert(val));
		}
	}

	return ret_val;
}

wchar_t* __cdecl mscript_GetExports()
{
	std::vector<std::wstring> exports
	{
		L"msdb_sql_init",
		L"msdb_sql_close",

		L"msdb_sql_exec",
		L"msdb_sql_rows_affected",
		L"msdb_sql_last_inserted_id",

		L"msdb_4db_init",
		L"msdb_4db_close",

		L"msdb_4db_define",
		L"msdb_4db_undefine",

		L"msdb_4db_query",

		L"msdb_4db_delete",

		L"msdb_4db_drop",
		L"msdb_4db_reset",

		L"msdb_4db_get_schema",
	};
	return mscript::module_utils::getExports(exports);
}

void mscript_FreeString(wchar_t* str)
{
	delete[] str;
}

wchar_t* mscript_ExecuteFunction(const wchar_t* functionName, 
                                 const wchar_t* parametersJson)
{
	try
	{
		std::wstring funcName = functionName;
		auto params = mscript::module_utils::getParams(parametersJson);

		std::unique_lock ctx_lock(g_mutex);

		if (funcName == L"msdb_sql_init")
		{
			if
			(
				params.size() != 2
				||
				params[0].type() != mscript::object::STRING
				|| 
				params[1].type() != mscript::object::STRING
			)
			{
				raiseError("Takes two parameters: a name for the database, 
                            and the path to the DB file");
			}

			std::wstring db_name = params[0].stringVal();
			std::wstring db_file_path = params[1].stringVal();

			if (g_db_conns.find(db_name) != g_db_conns.end())
				raiseWError(L"Database already initialized: " + db_name);

			auto new_db = std::make_shared<fourdb::db>
                          (mscript::toNarrowStr(db_file_path));
			g_db_conns.insert({ db_name, new_db });

			return mscript::module_utils::jsonStr(mscript::object());
		}
		else if (funcName == L"msdb_sql_close")
		{
			if
			(
				params.size() != 1
				||
				params[0].type() != mscript::object::STRING
			)
			{
				raiseError("Takes the name of the database");
			}

			auto db_it = g_db_conns.find(params[0].stringVal());
			if (db_it != g_db_conns.end())
				g_db_conns.erase(db_it);

			return mscript::module_utils::jsonStr(mscript::object());
		}
		else if (funcName == L"msdb_sql_exec")
		{
			if
			(
				(params.size() != 2 && params.size() != 3)
				||
				params[0].type() != mscript::object::STRING
				||
				params[1].type() != mscript::object::STRING
				||
				(params.size() == 3 && params[2].type() != mscript::object::INDEX)
			)
			{
				raiseError("Takes the name of the database, the SQL query, 
				            and an optional index of query parameters");
			}

			auto sql_db = getSqldb(params[0].stringVal());
			std::wstring sql_query = params[1].stringVal();
			auto params_idx = 
				params.size() >= 3 
				? params[2].indexVal() 
				: mscript::object::index();
			fourdb::paramap query_params = convert(params_idx);
			
			auto reader = sql_db->execReader(sql_query, query_params);

			auto results = processDbReader(*reader);

			return mscript::module_utils::jsonStr(results);
		}
		else if (funcName == L"msdb_sql_rows_affected")
		{
			if
			(
				params.size() != 1
				||
				params[0].type() != mscript::object::STRING
			)
			{
				raiseError("Takes the database name");
			}

			auto sql_db = getSqldb(params[0].stringVal());
			int64_t rows_affected = 
                    sql_db->execScalarInt64(L"SELECT changes()").value();
			return mscript::module_utils::jsonStr(double(rows_affected));
		}
		else if (funcName == L"msdb_sql_last_inserted_id")
		{
			if
			(
				params.size() != 1
				||
				params[0].type() != mscript::object::STRING
			)
			{
				raiseError("Takes the database name");
			}

			auto sql_db = getSqldb(params[0].stringVal());
			int64_t last_inserted_id = 
                    sql_db->execScalarInt64(L"SELECT last_insert_rowid()").value();
			return mscript::module_utils::jsonStr(double(last_inserted_id));
		}
		else if (funcName == L"msdb_4db_init")
		{
			if
			(
				params.size() != 2 
				|| 
				params[0].type() != mscript::object::STRING 
				|| 
				params[1].type() != mscript::object::STRING
			)
			{
				raiseError("Takes two parameters: a name for the context, 
                            and the path to the DB file");
			}

			std::wstring db_name = params[0].stringVal();
			std::wstring db_file_path = params[1].stringVal();

			if (g_contexts.find(db_name) != g_contexts.end())
				raiseWError(L"Context already initialized: " + db_name);

			auto ctxt_ptr = std::make_shared<fourdb::ctxt>
                            (mscript::toNarrowStr(db_file_path));
			g_contexts.insert({ db_name, ctxt_ptr });

			return mscript::module_utils::jsonStr(mscript::object());
		}
		else if (funcName == L"msdb_4db_close")
		{
			if
			(
				params.size() != 1
				||
				params[0].type() != mscript::object::STRING
			)
			{
				raiseError("Takes the name of the context");
			}

			auto ctxt_it = g_contexts.find(params[0].stringVal());
			if (ctxt_it != g_contexts.end())
				g_contexts.erase(ctxt_it);

			return mscript::module_utils::jsonStr(mscript::object());
		}
		else if (funcName == L"msdb_4db_define")
		{
			if
			(
				params.size() != 4
				||
				params[0].type() != mscript::object::STRING
				||
				params[1].type() != mscript::object::STRING
				//|| object
				//params[2].type() != mscript::object::STRING
				||
				params[3].type() != mscript::object::INDEX
			)
			{
				raiseError("Takes four parameters: the name of the context, 
				the table name, the key value, and an index of name-value pairs");
			}

			auto ctxt = get4db(params[0].stringVal());

			const std::wstring& table_name = params[1].stringVal();
			const fourdb::strnum key_value = convert(params[2]);
			const fourdb::paramap metadata = convert(params[3].indexVal());

			ctxt->define(table_name, key_value, metadata);

			return mscript::module_utils::jsonStr(mscript::object());
		}
		else if (funcName == L"msdb_4db_undefine")
		{
			if
			(
				params.size() != 4
				||
				params[0].type() != mscript::object::STRING
				||
				params[1].type() != mscript::object::STRING
				//|| object
				//params[2].type() != mscript::object::STRING
				||
				params[3].type() != mscript::object::STRING
			)
			{
				raiseError("Takes four parameters: the name of the context, 
				the table name, the key value, and the name of the metadata to remove");
			}

			auto ctxt = get4db(params[0].stringVal());

			const std::wstring& table_name = params[1].stringVal();
			const fourdb::strnum key_value = convert(params[2]);
			const std::wstring& metadata_name = params[3].stringVal();

			ctxt->undefine(table_name, key_value, metadata_name);

			return mscript::module_utils::jsonStr(mscript::object());
		}
		else if (funcName == L"msdb_4db_query")
		{
			if
			(
				params.size() != 3
				||
				params[0].type() != mscript::object::STRING
				||
				params[1].type() != mscript::object::STRING
				||
				params[2].type() != mscript::object::INDEX
			)
			{
				raiseError("Takes three parameters: the name of the context, 
				the SQL query, and an index of name-value parameters");
			}

			auto ctxt = get4db(params[0].stringVal());

			fourdb::select sql_select = fourdb::sql::parse(params[1].stringVal());
			const fourdb::paramap query_params = convert(params[2].indexVal());
			for (const auto& param_it : query_params)
				sql_select.addParam(param_it.first, param_it.second);

			auto reader = ctxt->execQuery(sql_select);
			auto results = processDbReader(*reader);

			return mscript::module_utils::jsonStr(results);
		}
		else if (funcName == L"msdb_4db_delete")
		{
			if
			(
				params.size() != 3
				||
				params[0].type() != mscript::object::STRING
				||
				params[1].type() != mscript::object::STRING
				//|| object
				//params[2].type() != mscript::object::STRING
			)
			{
				raiseError("Takes three parameters: the name of the context, 
				the table name, and the key value");
			}

			auto ctxt = get4db(params[0].stringVal());

			const std::wstring& table_name = params[1].stringVal();
			const fourdb::strnum key_value = convert(params[2]);

			ctxt->deleteRow(table_name, key_value);

			return mscript::module_utils::jsonStr(mscript::object());
		}
		else if (funcName == L"msdb_4db_drop")
		{
			if
			(
				params.size() != 2
				||
				params[0].type() != mscript::object::STRING
				||
				params[1].type() != mscript::object::STRING
			)
			{
				raiseError("Takes two parameters: the name of the context, 
                            and the table name");
			}

			auto ctxt = get4db(params[0].stringVal());
			const std::wstring& table_name = params[1].stringVal();
			ctxt->drop(table_name);
			return mscript::module_utils::jsonStr(mscript::object());
		}
		else if (funcName == L"msdb_4db_reset")
		{
			if
			(
				params.size() != 1
				||
				params[0].type() != mscript::object::STRING
			)
			{
				raiseError("Takes the name of the context to reset");
			}

			auto ctxt = get4db(params[0].stringVal());
			ctxt->reset();
			return mscript::module_utils::jsonStr(mscript::object());
		}
		else if (funcName == L"msdb_4db_get_schema")
		{
			if
			(
				params.size() != 1
				||
				params[0].type() != mscript::object::STRING
			)
			{
				raiseError("Takes the name of the context to get the schema of");
			}

			auto ctxt = get4db(params[0].stringVal());
			const auto schema = ctxt->getSchema();
			mscript::object::index table_schema;
			for (const auto& tables_it : schema.vec())
			{
				const std::wstring& table_name = tables_it.first;
				mscript::object::list table_columns;
				table_columns.reserve(tables_it.second->size());
				for (const std::wstring& col : *tables_it.second)
					table_columns.push_back(col);
				table_schema.set(table_name, table_columns);
			}
			return mscript::module_utils::jsonStr(table_schema);
		}
		else
			raiseWError(L"Unknown function");
	}
	catch (const mscript::user_exception& exp)
	{
		return mscript::module_utils::errorStr(functionName, exp);
	}
	catch (const std::exception& exp)
	{
		return mscript::module_utils::errorStr(functionName, exp);
	}
	catch (...)
	{
		return nullptr;
	}
}

Argument Parsing

A big feature request was powerful command-line argument parsing. You'd want to be able to say what flags the application handled, like -i, and a longer version of the flag, like --input, and a description for the flag, like "Specify the input file", and whether the flag requires a value, like an "--input" flag would, and whether the value has to be numeric, a default value, and whether the flag is required. Wow, that was one sentence! Powerful stuff.

Here's out the header:

C++
#pragma once

#include "object.h"

namespace mscript
{
	object
		parseArgs
		(
			const object::list& arguments,
			const object::list& argumentSpecs
		);
}

It takes the list of argument strings mscripts can pass in the global "arguments" variable set by the mscript EXE, but this code and the mscript wrapper function do not assume that. It also takes a list of "argument specs". Each argument spec is an index that defines an argument that the program can handle, its flag, its long flag, etc. This function returns an index mapping a flag to the value for that flag, or true for flags that don't take values, or false for missing flags. The special pseudo-flag "" returns a list of values not associated with flags, e.g., your primary arguments. Your calling code would probe the returned index to control how it behaves.

Here's the implementation of the argument parser:

C++
#include "pch.h"
#include "parse_args.h"
#include "exe_version.h"
#include "utils.h"

using namespace mscript;

struct arg_spec
{
	// required flag data
	std::wstring flag; // -?
	std::wstring long_flag; // --help
	std::wstring description; // Get some help!

	// optional flags
	bool takes = false;
	bool numeric = false;
	bool required = false;

	object default_value;
};

static std::wstring getStrFlagValue(const object::index& argumentSpec, 
                                    const std::string& flagName)
{
	object flag_value;
	if (!argumentSpec.tryGet(toWideStr(flagName), flag_value) || 
                             flag_value.type() != object::STRING)
		raiseError("Argument spec lacks " + flagName + " string setting");

	std::wstring flag_str = trim(flag_value.stringVal());
	if (flag_str.empty())
		raiseError("Argument spec lacks " + flagName + " non-empty string setting");

	return flag_str;
}

static bool getBoolFlagValue(const object::index& argumentSpec, 
                             const std::string& flagName)
{
	object flag_value;
	if (!argumentSpec.tryGet(toWideStr(flagName), flag_value))
		return false;

	if (flag_value.type() != object::BOOL)
		raiseError("Argument spec " + flagName + " setting is not bool");

	return flag_value.boolVal();
}

object
mscript::parseArgs
(
	const object::list& arguments,
	const object::list& argumentSpecs
)
{
	// Set up shop
	object ret_val = object::index();
	object::index& ret_val_index = ret_val.indexVal();

	// Start off with our list of un-flagged arguments
	// NOTE: The object::list inside raw_arg_list is passed by reference
	//		 so changes made to it along the way are reflected in the returned list
	object raw_arg_list = object::list();
	ret_val_index.set(toWideStr(""), raw_arg_list);

	// Pre-process the argument specs
	std::vector<arg_spec> local_specs;
	for (const auto& cur_input_spec : argumentSpecs)
	{
		if (cur_input_spec.type() != object::INDEX)
			raiseError("Invalid argument spec type: " + 
                        object::getTypeName(cur_input_spec.type()));
		const object::index& cur_input_index = cur_input_spec.indexVal();

		arg_spec cur_spec;
		cur_spec.flag = getStrFlagValue(cur_input_index, "flag");
		cur_spec.long_flag = getStrFlagValue(cur_input_index, "long-flag");
		cur_spec.description = getStrFlagValue(cur_input_index, "description");
		cur_spec.takes = getBoolFlagValue(cur_input_index, "takes");
		cur_spec.numeric = getBoolFlagValue(cur_input_index, "numeric");
		cur_spec.required = getBoolFlagValue(cur_input_index, "required");

		object default_value;
		if (cur_input_index.tryGet(toWideStr("default"), default_value))
			cur_spec.default_value = default_value;
		else if (!cur_spec.takes) // normal flag
			cur_spec.default_value = false;
		local_specs.push_back(cur_spec);
	}

	// Add help flag processing if not already defined
	bool already_had_help = false;
	for (const auto& cur_spec : local_specs)
	{
		if (cur_spec.flag == L"-?" || cur_spec.long_flag == L"--help")
		{
			already_had_help = true;
			break;
		}
	}
	if (!already_had_help)
	{
		arg_spec new_spec;
		new_spec.flag = L"-?";
		new_spec.long_flag = L"--help";
		new_spec.description = L"Get usage of this script";
		new_spec.default_value = false;
		local_specs.insert(local_specs.begin(), new_spec);
	}

	// Add default values to seed the return value index
	for (const auto& arg_spec : local_specs)
		ret_val_index.set(arg_spec.flag, arg_spec.default_value);

	// Validate the arguments
    bool help_exit_suppressed = false;
	for (size_t a = 0; a < arguments.size(); ++a)
	{
		if (arguments[a].type() != object::STRING)
		{
			raiseWError(L"Invalid command-line argument, 
                          not a string: #" + num2wstr(double(a)) +
						L" - " + arguments[a].toString());
		}
		if (arguments[a].stringVal() == L"--suppress-help-quit")
			help_exit_suppressed = true;
	}

	// Loop over the arguments
	bool help_was_output = false;
	for (size_t a = 0; a < arguments.size(); ++a)
	{
		const std::wstring& cur_arg = arguments[a].stringVal();
		if (cur_arg == L"--suppress-help-quit")
			continue;

		bool has_next_arg = false;
		object next_arg;
		if (a < arguments.size() - 1)
		{
			next_arg = arguments[a + 1];
			if (!startsWith(next_arg.toString(), L"-"))
				has_next_arg = true;
		}

		if (cur_arg.empty() || cur_arg[0] != '-')
		{
			raw_arg_list.listVal().push_back(cur_arg);
			continue;
		}

		if (!already_had_help && (cur_arg == L"-?" || cur_arg == L"--help"))
		{
			std::wstring mscript_exe_path = getExeFilePath();
			std::wcout
				<< mscript_exe_path
				<< L" - v" << toWideStr(getBinaryVersion(mscript_exe_path))
				<< L"\n";

			size_t max_flags_len = 0;
			for (const auto& arg_spec : local_specs)
			{
				size_t cur_len = arg_spec.flag.size() + arg_spec.long_flag.size();
				if (cur_len > max_flags_len)
					max_flags_len = cur_len;
			}
			static std::wstring flag_separator = L", ";
			static size_t flag_separator_len = flag_separator.length();
			size_t max_flags_output_len = max_flags_len + flag_separator_len;
			for (const auto& arg_spec : local_specs)
			{
				std::wcout
					<< std::left
					<< std::setfill(L' ')
					<< std::setw(max_flags_output_len)
					<< (arg_spec.flag + flag_separator + arg_spec.long_flag)
					<< L": "
					<< arg_spec.description;

				if (arg_spec.takes)
				{
					std::wcout << " - type=" << (arg_spec.numeric ? "num" : "str");
					
					if (arg_spec.default_value.type() != object::NOTHING)
						std::wcout << " - default=" << arg_spec.default_value.toString();

					if (arg_spec.required)
						std::wcout << " - [REQUIRED]";
				}

				std::wcout << L"\n";
			}

			std::wcout << std::flush;

			if (!help_exit_suppressed)
				exit(0);

			ret_val_index.set(toWideStr("-?"), true);
			help_was_output = true;
			continue;
		}

		// Loop over the argument specs to find this argument as a flag or long flag
		bool found_flag = false;
		for (const auto& cur_spec : local_specs)
		{
			if (!(cur_spec.flag == cur_arg || cur_spec.long_flag == cur_arg))
				continue;
			else
				found_flag = true;

			const std::wstring& cur_flag = cur_spec.flag;
			if (cur_spec.takes)
			{
				if (!has_next_arg)
				{
					if (cur_spec.default_value != object())
						next_arg = cur_spec.default_value;
					else
						raiseWError(L"No value for flag that takes 
                                      next argument: " + cur_arg);
				}
				
				// Convert the next argument into its final value
				if (cur_spec.numeric && next_arg.type() != object::NUMBER)
				{
					try
					{
						ret_val_index.set(cur_flag, std::stod(next_arg.toString()));
					}
					catch (...)
					{
						raiseWError(L"Converting argument to number failed: " +
									cur_spec.flag + L" - " + next_arg.toString());
					}
				}
				else
					ret_val_index.set(cur_flag, next_arg);

				// Skip the next argument we just processed
				a = a + 1; 
			}
			else // non-taking flag
			{
				ret_val_index.set(cur_flag, true);
			}
		}

		if (!found_flag)
			raiseWError(L"Unknown command line flag: " + cur_arg);
	}

	// Enforce that all required flags were specified...unless -? / --help
	if (!help_was_output)
	{
		for (const auto& cur_spec : local_specs)
		{
			if (cur_spec.required && ret_val_index.get
               (cur_spec.flag).type() == object::NOTHING)
				raiseWError(L"Required argument not provided: " + cur_spec.flag);
		}
	}

	// All done
	return ret_val;
}

Conclusion

I invite you to check out mscript.io for version history, including breaking changes in version 3.0, full documentation of functions new and old, and other language changes alluded to above, like the preprocessor, the syntax checker, and the fundamental change that * and & statement prefixes are no longer required.

I really think you'll enjoy writing mscripts. Give it a try, and let me know what you think.

History

  • 26th May, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under The Apache License, Version 2.0