TinTin++ Mud Client The TinTin++ message board

 
 FAQFAQ   SearchSearch   MemberlistMemberlist   UsergroupsUsergroups   RegisterRegister 
 ProfileProfile   Log in to check your private messagesLog in to check your private messages   Log inLog in 
TinTin++ Mud Client

#SQL command

 
Post new topic   Reply to topic    The TinTin++ message board Forum Index -> Development
View previous topic :: View next topic  
Author Message
Valenin



Joined: 18 Jan 2014
Posts: 8

PostPosted: Sun Oct 11, 2015 6:57 pm    Post subject: #SQL command Reply with quote

I found myself disappointed with a couple of aspects of interacting with a (sqlite3) database using #SCRIPT to a command line db client, so I build a #SQL command.

The good news:
I can prepare statements, do transactions easily (HUGE speedup for lots of inserts), not have to deal with escaping in tintin scripts for the shell and query parser, and save the overhead of having to do all the forks and other plumbing behind SCRIPT and its cousins.

The bad news:
The syntax is unsatisfying, even a little ugly. I feel like I'm missing some simple way to consolidate it, bring it more in line with tintin-script idioms, or otherwise make it less confusing.

That's why I'm writing this. Input on the syntax is welcome. Tell me where I can clean up the process of using this. If/when it's sorted out I'd be glad to post the patch for anyone who wants it, Scandum included.

What I'm working with now looks like this:

#SYNTAX: #SQL {handle} {OPEN} {db_filename}
#SQL {handle} {CLOSE}
#SQL {handle} {PREPARE} {query_name} {sql}
#SQL {handle} {UNPREPARE} {query_name}
#SQL {handle} {RUN} {query_name} {result} {parameters}
#SQL {handle} {DIRECT} {result} {sql} {parameters}

The idea is that you open a database file with SQL OPEN, and can thereafter refer to that database using the handle. You can prepare queries involving the standard :parameter_name sqlite syntax, and later run them with an argument containing a map of parameter names to values. Or, once you have a database open, you can run a queries one-off using DIRECT. RUN and DIRECT will store their output as a list of rows, each of which is a mapping of column names to values, in 'result' the way things like #MAP GET do.

In usage, it looks something like this, taken from code to grab and store a table of area information:
#SQL {dbh} {OPEN} {/path/to/database.db};
#SQL {dbh} {PREPARE} {insertarea} {INSERT OR REPLACE INTO areas (keyword, name, afrom, ato, alock) VALUES (:keyword, :name, :afrom, :ato, :alock)};
#SQL {dbh} {DIRECT} {readareas_result} {BEGIN TRANSACTION};

(Inside an action firing on lines of mud output:)
#VAR {builddb_params} { {:keyword} {%%6}
{:name} {%%7}
{:afrom} {%%2}
{:ato} {%%3}
{:alock} {%%4} };
#SQL {dbh} {RUN} {insertarea} {readareas_result} {$builddb_params};

(Inside an action firing on the last line of the table:)
#SQL {dbh} {DIRECT} {readareas_result} {COMMIT TRANSACTION};
#SQL {dbh} {CLOSE};

Normally, you probably wouldn't CLOSE until your session was disconnected, but for the sake of illustration it's here.

Any thoughts or inputs are welcome. Like I said, once I decide what to do about this syntax, I'll post the patch here as a followup for anyone who wants it.
Back to top
View user's profile Send private message
PowerGod



Joined: 04 Aug 2014
Posts: 339

PostPosted: Tue Oct 13, 2015 3:49 pm    Post subject: Reply with quote

Maybe the parameters disposition of DIRECT should be put like this:
Code:

#SQL {handle} {DIRECT} {sql} {result} {parameters}

to follow the same order of the RUN command...

Seems to me a good syntax, following the same style of the ones already present.

I think anyway that if something like that will be implemented, it should be more straightforward to use, at least for the most used commands... something like:
Code:

#SQL {mydb} {OPEN} {/path/to/database.db}
#SQL {mydb} {INSERT} {areas} {keyword, name, afrom, ato, alock} {$keyword,$name,$ato,$alock}

#SQL {mydb} {SELECT} {areas} {afrom,alock} {keyword like "key%" AND name != "this"} {result}


This way you don't even know you are using an external program, seems more integrated with the client.

Maybe the functions like PREPARE or the transactions things could be useful just for powerusers who wants the maximum optimization, but as for myself, I never found sqlite slow even using #SCRIPT... I just hated the escaping characters hell Big Smile
Back to top
View user's profile Send private message
Valenin



Joined: 18 Jan 2014
Posts: 8

PostPosted: Thu Oct 15, 2015 5:25 am    Post subject: Reply with quote

PowerGod wrote:
Maybe the parameters disposition of DIRECT should be put like this:
Code:

#SQL {handle} {DIRECT} {sql} {result} {parameters}

to follow the same order of the RUN command...

Seems to me a good syntax, following the same style of the ones already present.

I think anyway that if something like that will be implemented, it should be more straightforward to use, at least for the most used commands... something like:
Code:

#SQL {mydb} {OPEN} {/path/to/database.db}
#SQL {mydb} {INSERT} {areas} {keyword, name, afrom, ato, alock} {$keyword,$name,$ato,$alock}

#SQL {mydb} {SELECT} {areas} {afrom,alock} {keyword like "key%" AND name != "this"} {result}


This way you don't even know you are using an external program, seems more integrated with the client.

Maybe the functions like PREPARE or the transactions things could be useful just for powerusers who wants the maximum optimization, but as for myself, I never found sqlite slow even using #SCRIPT... I just hated the escaping characters hell :D


Good call on the order inconsistency between run and prepare. I tried to follow the spirit of the syntax of preexisting commands, I must have turned my brain off and looked at two different things as guidelines for them (#format wants the result-variable name first, #map get wants it last.) but I'll bring them in line.

The idea of having specific INSERT/SELECT 'shortcuts' seems like a good one, especially for users who aren't okay with writing SQL themselves, I'll take a go at putting them together, probably DELETE too. I have a(n irrational?) preference for associative arrays and I'm wondering whether consolidating the last two arguments to insert into one that looks like {{keyword}{$keyword}{name}{$name}...{column_name}{value}} would be overthinking it, but the general point is sound. I'd prefer to keep at least DIRECT though, to let the powerusers you mention take matters into their own hands. I admittedly haven't done much testing to see just how much faster PREPARE lets things get, I will tomorrow, but I have at least one concrete example SCRIPT just wasn't cutting it for me, if it gives you an idea where I'm coming from.

The mud I play sends data every time your inventory changes, more than just the "You get a cool item." human text. You can also get a comma delimited string intended for your client containing the item level, name, type, value, and a handful of other useful things. You get one line per inventory action per item. Wear a helmet, get a line (item's moved from your inventory to your gear). Drop 20 items, get 20 lines. Take 150 items out of your corpse, get 150 lines. People use it to build snazzy inventory plugins to maintain a database to manage their gear sets, build item info repositories, put on levelling gear, etc. SQLite in tintin keeps up pretty well during normal play, but when you buy a dozen potions or loot your corpse (or in a worst case scenario a pkiller gives you 100 junk bracers then backstabs you), there's a multi-second pause during which output stops. Being able to convert that from a #SCRIPT call for each line to a DIRECT "BEGIN TRANSACTION", a RUN for each line, and a DIRECT "COMMIT TRANSACTION" was so effective that I couldn't believe everything was working properly. The whole mess is instantaneous for more than 500 such insert/updates.

I suppose you could create a file whose first line is "BEGIN TRANSACTION" write the batch of inserts instead of doing them until they're done coming, write "COMMIT TRANSACTION", and #SYSTEM {cat queries.sql | sqlite3 whatever.db}. I haven't tried it, I didn't think to until now. So hi future googlers, there's something you can try if you're in the same boat.
Back to top
View user's profile Send private message
Valenin



Joined: 18 Jan 2014
Posts: 8

PostPosted: Fri Oct 16, 2015 12:24 am    Post subject: Reply with quote

I haven't gotten a chance to work on SELECT/INSERT/DELETE, but I did get a chance to run some for-real timing tests. Here's what I'm seeing.

Scenario: Run a command that sends 606 lines from the mud, each will trigger an "INSERT OR REPLACE" type operation on a table with 5 columns. I tested it five times for each of six methods.

1: #SCRIPT calls, no transactions.
-- Min: 150.147s, Max: 152.455s Avg: 151.268s
2: #SCRIPT calls, writing to a temp file with BEGIN/COMMIT wrapepr lines and catting that to sqlite at the end.
-- Min: 0.894s, Max: 0.996s, Avg: 0.942s
3: #SQL PREPARE/RUN without a transaction.
-- Min: 143.001s, Max: 177.770s, Avg: 154.369s
4: #SQL PREPARE/RUN with a transaction.
-- Min: 0.416s, Max: 0.531s, Avg: 0.456s
5: #SQL DIRECT without a transaction.
-- Min: 141.992s, Max: 173.054s, Avg: 151.092s
6: #SQL DIRECT with a transaction.
-- Min: 0.449s, Max: 0.514s, Avg: 0.482s

The obvious source of gain is access to transactions. One-time manual preparation's a gain but not a huge one. I'll probably do SELECT/INSERT/UPDATE and then post what I have unless someone seems especially interested. I imagine the #SCRIPT performance is probably good enough for 99% of the users 99% of the time.

On that note, does anyone have a decent idea for a way to hold open an external process for communication with tintin? Before I started coding, I toyed with trying something involving gluing netcat to sqlite3 via named pipe, then connecting to netcat via #SESSION. It worked, but it was an ugly pain in the neck with regard to getting the results of SELECTs without busywaiting, and using #SCRIPT for selects and the open pipe for transactions and inserts was a concurrency micromanagement I didn't care to tackle.
Back to top
View user's profile Send private message
PowerGod



Joined: 04 Aug 2014
Posts: 339

PostPosted: Fri Oct 16, 2015 12:12 pm    Post subject: Reply with quote

Strange unexpected results you have there O_o

Are you using the sqlite library or the executable itself to run the embedded code ?
Back to top
View user's profile Send private message
Valenin



Joined: 18 Jan 2014
Posts: 8

PostPosted: Fri Oct 16, 2015 1:40 pm    Post subject: Reply with quote

SCRIPT calls are basically invocations of the alias found at http://tintin.sourceforge.net/board/viewtopic.php?t=1112, using sqlite3 3.8.11.1 instead of mysql.

#SQL is compiled into tt++ as C that invokes libsqlite3 as a shared library.

Except maybe the magnitude of the gains, they don't seem that unexpected to me. For 600 updates, doing it the straightforward script way forks 600 times, reads the db file 600 times, compiles the query 600 times, sets up a transaction rollback point 600 times, runs the query 600 times, commits the transaction 600 times, updates the file on disk 600 times. For the transactional methods it reads the db once, compiles the query once, sets up a rollback point once, runs the query 600 times, commits the transaction once, and updates the file once.

2.5 minutes is a bit long for 600 queries. about 4 queries/second. And compared to results other people report, even the 1244 queries/second of the best case test seems pretty sluggy. (http://stackoverflow.com/questions/1711631/improve-insert-per-second-performance-of-sqlite gets 85 and 23000 inserts/second, but he does a lot fewer string ops per query than tintin/my code) But I'm not trying to break records, honestly, just smooth out those output pauses I mentioned.
Back to top
View user's profile Send private message
PowerGod



Joined: 04 Aug 2014
Posts: 339

PostPosted: Sat Oct 17, 2015 11:31 am    Post subject: Reply with quote

I was expecting a better improvement even for the calls without a transaction, it's strange that those seems somehow slower compared to the one launched with #SCRIPT
Back to top
View user's profile Send private message
Valenin



Joined: 18 Jan 2014
Posts: 8

PostPosted: Sat Oct 17, 2015 2:35 pm    Post subject: Reply with quote

Fair point. I just did some timings not live. Basically a setup where I launch tintin with an alias to run each test case (no mud output, just loop counters to vary data) and an empty database with a single table, which received 10000 inserst. Oddly, all the methods performed similarly. I could make guesses why, but they would just be guesses.

I've consolidated RUN and DIRECT into just RUN (is the query parameter the name of a known prepared query? If so then use it, otherwise try running it as raw sql) and I'll try to finish up SELECT/INSERT/UPDATE for completion's sake this weekend, then post what I have. I'm running out of time available to spend on it for a while and I'm on the fence about going back to 'stock' code myself and using the temp-file-as-transaction-wrangler method, considering the alternative "keep a patch around and remember to apply it every new release until/unless similar functionality happens in stock" method.
Back to top
View user's profile Send private message
Valenin



Joined: 18 Jan 2014
Posts: 8

PostPosted: Sat Oct 17, 2015 4:16 pm    Post subject: Reply with quote

I was hoping to be able to clean this up a little more, but I'm going to be pressed for time for a bit, so here's what I have for anyone who wants it. No warranties express or implied, blah blah blah.

Save to a file and apply to tintin-2.01.1 with patch -p1.

Code:

diff --git a/src/Makefile.in b/src/Makefile.in
index e918daa..88902ab 100644
--- a/src/Makefile.in
+++ b/src/Makefile.in
@@ -64,7 +64,7 @@ OFILES = action.o alias.o files.o help.o highlight.o strhash.o input.o log.o \
          math.o split.o debug.o tinexp.o mapper.o tables.o buffer.o prompt.o \
          class.o  event.o utils.o chat.o macro.o  config.o gag.o variable.o \
          list.o forkpty.o cursor.o memory.o system.o line.o tokenize.o \
-         data.o nest.o advertise.o ssl.o
+         data.o nest.o advertise.o ssl.o sql.o
 
 
 default: all
diff --git a/src/configure b/src/configure
index 0d72ef2..c1c0ca0 100755
--- a/src/configure
+++ b/src/configure
@@ -4323,6 +4323,51 @@ _ACEOF
 
 fi
 
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for sqlite3_open in -lsqlite3" >&5
+$as_echo_n "checking for sqlite3_open in -lsqlite3" >&6; }
+if ${ac_cv_lib_sqlite3_sqlite3_open+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lsqlite3 $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char sqlite3_open();
+int
+main ()
+{
+return sqlite3_open();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_sqlite3_sqlite3_open=yes
+else
+  ac_cv_lib_sqlite3_sqlite3_open=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_sqlite3_sqlite3_open" >&5
+$as_echo "$ac_cv_lib_sqlite3_sqlite3_open" >&6; }
+if test "x$ac_cv_lib_sqlite3_sqlite3_open" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBSQLITE3 1
+_ACEOF
+
+  LIBS="-lsqlite3 $LIBS"
+
+fi
+
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for gethostbyname in -lnsl" >&5
 $as_echo_n "checking for gethostbyname in -lnsl... " >&6; }
 if ${ac_cv_lib_nsl_gethostbyname+:} false; then :
diff --git a/src/configure.in b/src/configure.in
index 9f2a561..dc1c6b7 100644
--- a/src/configure.in
+++ b/src/configure.in
@@ -68,6 +68,8 @@ AC_PROG_MAKE_SET
 AC_HEADER_STDC
 
 AC_CHECK_HEADERS([arpa/inet.h ctype.h fcntl.h net/errno.h netdb.h netinet/in.h param.h pthread.h socks.h stdlib.h string.h strings.h sys/ioctl.h sys/param.h sys/ptem.h sys/socket.h sys/termio.h sys/time.h time.h unistd.h util.h pty.h stropts.h])
+AC_CHECK_HEADERS([sqlite3.h],,
+   [AC_MSG_ERROR([sqlite3 header file not found, is the development part present])])
 AC_CHECK_HEADERS([zlib.h],,
    [AC_MSG_ERROR([zlib header file not found, is the development part present])])
 AC_CHECK_HEADERS([pcre.h],,
@@ -101,6 +103,7 @@ AC_CHECK_FUNCS([gethostbyname gethostname gettimeofday inet_ntoa memset select s
 
 AC_CHECK_LIB(z, inflate,,
    [AC_MSG_ERROR([zlib runtime library not working, is it AND the corresponding development (header) file installed])])
+AC_CHECK_LIB(sqlite3, sqlite3_open)
 AC_CHECK_LIB(pthread, pthread_create)
 AC_CHECK_LIB(nsl, gethostbyname)
 AC_CHECK_LIB(socket, rresvport)
diff --git a/src/help.c b/src/help.c
index baf4e3f..c344102 100644
--- a/src/help.c
+++ b/src/help.c
@@ -1372,6 +1372,37 @@ struct help_type help_table[] =
       "\n"
       "<178>Comment<078>: You can remove split mode with the #unsplit command.\n"
    },
+    {
+        "SQL",
+      "<178>Command<078>: #SQL {<178>handle<078>} {OPEN}      {<178>filename<078>}\n"
+      "         #SQL {<178>handle<078>} {CLOSE}\n"
+      "         #SQL {<178>handle<078>} {PREPARE}   {<178>query_name<078>} {<178>sql<078>}\n"
+      "         #SQL {<178>handle<078>} {UNPREPARE} {<178>query_name<078>}\n"
+      "         #SQL {<178>handle<078>} {RUN}       {<178>result<078>} {<178>query<078>} {<178>parameters<078>}\n"
+      "         #SQL {<178>handle<078>} {INSERT}    {<178>table<078>} {<178>columns<078>} {<178>values<078>}\n"
+      "         #SQL {<178>handle<078>} {SELECT}    {<178>tables<078>} {<178>columns<078>} {<178>criteria<078>} {<178>result<078>}\n"
+      "         #SQL {<178>handle<078>} {DELETE}    {<178>table<078>} {<178>criteria<078>}\n"
+        "\n"
+        "         #SQL OPEN opens the given filename as an sqlite database to which you can\n"
+        "             refer via its handle in with the other commands.\n"
+        "         #SQL CLOSE closes a database handle when you are done with it.\n"
+        "         #SQL PREPARE lets you prepare a raw sql statement to be run in the future by\n"
+        "             referring to the given query name. Bound prameters using SQLite syntax are\n"
+        "             supported.\n"
+        "         #SQL UNPREPARE cleans up and removes a prepared statement.\n"
+        "         #SQL RUN will run the given query, specified as a prepared statement's name or\n"
+        "             a string of raw sql. The parameters should be specified as an associative\n"
+        "             array of {:parametername}{value} pairs, and will be bound appropriately.\n"
+        "             The results of the query will be stored in the result parameter.\n"
+        "         #SQL INSERT/SELECT/DELETE\n"
+        "             These are shorthands for specific query types for convenience. The 'table'\n"
+        "             parameter specifies the table upon which to act, in the case of SELECT,\n"
+        "             more than one table may be joined by specifying {{table1}{table2}{tablen}}.\n"
+        "             The columns parameter specifies the column names to be filled in or requested.\n"
+        "             Values provides a list of values to give the named columns. Criteria is a\n"
+        "             string with an SQL-like specification of which rows to effect (the where clause)\n"
+        "             and result is where SELECT will store its result set.\n"
+    },
    {
       "SSL",
       "<178>Command<078>: #ssl <178>{<078>name<178>} {<078>host<178>} {<078>port<178>} {<078>file<178>}\n"
diff --git a/src/main.c b/src/main.c
index ad846bc..a7ce741 100644
--- a/src/main.c
+++ b/src/main.c
@@ -330,6 +330,9 @@ void init_tintin(int greeting)
    gts->socket         = 1;
    gts->read_max       = 16384;
 
+    gts->databases_f    = NULL;
+    gts->databases_l    = NULL;
+
    gtd                 = (struct tintin_data *) calloc(1, sizeof(struct tintin_data));
 
    gtd->ses            = gts;
diff --git a/src/session.c b/src/session.c
index 962ac8c..ed9296c 100644
--- a/src/session.c
+++ b/src/session.c
@@ -219,6 +219,9 @@ struct session *new_session(struct session *ses, char *name, char *arg, int desc
    newses->read_max      = gts->read_max;
    newses->read_buf      = (unsigned char *) calloc(1, gts->read_max);
 
+    newses->databases_f   = NULL;
+    newses->databases_l   = NULL;
+
    LINK(newses, gts->next, gts->prev);
 
    for (cnt = 0 ; cnt < LIST_MAX ; cnt++)
diff --git a/src/sql.c b/src/sql.c
new file mode 100644
index 0000000..83805fc
--- /dev/null
+++ b/src/sql.c
@@ -0,0 +1,646 @@
+/******************************************************************************
+*   TinTin++                                                                  *
+*   Copyright (C) 2004 (See CREDITS file)                                     *
+*                                                                             *
+*   This program is protected under the GNU GPL (See COPYING)                 *
+*                                                                             *
+*   This program is free software; you can redistribute it and/or modify      *
+*   it under the terms of the GNU General Public License as published by      *
+*   the Free Software Foundation; either version 2 of the License, or         *
+*   (at your option) any later version.                                       *
+*                                                                             *
+*   This program is distributed in the hope that it will be useful,           *
+*   but WITHOUT ANY WARRANTY; without even the implied warranty of            *
+*   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the             *
+*   GNU General Public License for more details.                              *
+*                                                                             *
+*   You should have received a copy of the GNU General Public License         *
+*   along with this program; if not, write to the Free Software               *
+*   Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA *
+*******************************************************************************/
+
+/******************************************************************************
+*                (T)he K(I)cki(N) (T)ickin D(I)kumud Clie(N)t                 *
+*                                                                             *
+*                      coded by Igor van den Hoven 2006                       *
+******************************************************************************/
+
+#include <sqlite3.h>
+
+#include "tintin.h"
+
+void sql_syntax_reminder(struct session *ses);
+
+struct sql_handle *open_sqlite_handle(struct session *ses, char *label, char *filename);
+void close_sqlite_handle(struct session *ses, struct sql_handle *dbh);
+struct sql_prepared_query *prepare_query(struct sql_handle *dbh, char *name, char *query);
+void unprepare_query(struct sql_handle *dbh, struct sql_prepared_query *qh);
+void bind_and_run_query(struct session *ses, struct sql_prepared_query *qh, char *parameters, char *destination);
+
+DO_COMMAND(do_sql)
+{
+   char handle[BUFFER_SIZE], subcommand[BUFFER_SIZE];
+    struct sql_handle *sql_it;
+    struct sql_prepared_query *qit;
+    int i;
+
+   arg = sub_arg_in_braces(ses, arg, handle, 0, SUB_VAR|SUB_FUN);
+   arg = sub_arg_in_braces(ses, arg, subcommand, 0, SUB_VAR|SUB_FUN);
+
+    if (*handle == 0)
+    {
+        tintin_header(ses, " SQL HANDLES ");
+
+        for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+        {
+            i = 0;
+            if (sql_it->prepared_queries_f)
+            {
+                qit = sql_it->prepared_queries_f;
+                do {
+                    i++;
+                    qit = qit->next;
+                } while (qit);
+            };
+            show_message(ses, LIST_COMMAND, "%15s %5d   %s", sql_it->name, i, sql_it->file);
+        }
+
+        return ses;
+    }
+    else if (*subcommand == 0)
+    {
+        for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+        {
+            if (!strcmp(sql_it->name, handle))
+            {
+                tintin_header(ses, " PREPARED QUERIES ");
+                for (qit = sql_it->prepared_queries_f; qit; qit = qit->next)
+                {
+                    show_message(ses, LIST_COMMAND, "%-15s %s", qit->name, sqlite3_sql(qit->handle));
+                }
+            }
+        }
+    }
+    else
+    {
+      for (i = 0 ; *sql_table[i].name ; i++)
+      {
+         if (is_abbrev(subcommand, sql_table[i].name))
+         {
+            break;
+         }
+      }
+
+      if (*sql_table[i].name == 0)
+      {
+            sql_syntax_reminder(ses);
+      }
+      else
+      {
+         sql_table[i].fun(ses, handle, arg);
+      }
+    }
+
+   return ses;
+}
+
+DO_SQL(sql_open)
+{
+    char *filename = NULL;
+    struct sql_handle *sql_it;
+
+    filename = calloc(UMAX(strlen(right), BUFFER_SIZE), sizeof(char));
+    right = sub_arg_in_braces(ses, right, filename, 0, SUB_VAR|SUB_FUN);
+
+    if (!strcmp(filename, ""))
+    {
+        sql_syntax_reminder(ses);
+    }
+    else
+    {
+        /*
+         * If this handle is already known, close it. We could make this a nop,
+         * but preemptive closure allows for #sql foo open foo.db to be used
+         * as a shortcut to either reopen foo.db for whatever reason or to
+         * switch the foo handle to another database file.
+         */
+        for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+        {
+            if (!strcmp(sql_it->name, left))
+            {
+                close_sqlite_handle(ses, sql_it);
+                break;
+            }
+        }
+
+        open_sqlite_handle(ses, left, filename);
+    }
+
+    free(filename);
+
+    return ses;
+}
+
+DO_SQL(sql_close)
+{
+    struct sql_handle *sql_it;
+
+    for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+    {
+        if (!strcmp(sql_it->name, left))
+        {
+            close_sqlite_handle(ses, sql_it);
+            break;
+        }
+    }
+
+    return ses;
+}
+
+DO_SQL(sql_prepare)
+{
+    struct sql_handle *sql_it = NULL;
+    struct sql_prepared_query *query_it = NULL;
+   char query_name[BUFFER_SIZE], query[BUFFER_SIZE];
+
+   right = sub_arg_in_braces(ses, right, query_name, 0, SUB_VAR|SUB_FUN);
+   right = sub_arg_in_braces(ses, right, query, 0, SUB_VAR|SUB_FUN);
+
+    if (!strcmp(query_name, "") || !strcmp(query, ""))
+    {
+        sql_syntax_reminder(ses);
+    }
+    else
+    {
+        for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+        {
+            if (!strcmp(sql_it->name, left))
+            {
+                for (query_it = sql_it->prepared_queries_f; query_it; query_it = query_it->next)
+                {
+                    if (!strcmp(query_it->name, query_name))
+                    {
+                        unprepare_query(sql_it, query_it);
+                        break;
+                    }
+                }
+                prepare_query(sql_it, query_name, query);
+                break;
+            }
+        }
+
+        if (!sql_it)
+        {
+            sql_syntax_reminder(ses);
+        };
+    }
+
+    return ses;
+}
+
+DO_SQL(sql_unprepare)
+{
+    struct sql_handle *sql_it;
+    struct sql_prepared_query *query_it;
+   char query_name[BUFFER_SIZE];
+
+   right = sub_arg_in_braces(ses, right, query_name, 0, SUB_VAR|SUB_FUN);
+
+    if (!strcmp(query_name, ""))
+    {
+        sql_syntax_reminder(ses);
+    }
+    else
+    {
+        for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+        {
+            if (!strcmp(sql_it->name, left))
+            {
+                for (query_it = sql_it->prepared_queries_f; query_it; query_it = query_it->next)
+                {
+                    if (!strcmp(query_it->name, query_name))
+                    {
+                        unprepare_query(sql_it, query_it);
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+    return ses;
+}
+
+DO_SQL(sql_run)
+{
+    struct sql_handle *sql_it;
+    struct sql_prepared_query *query_it;
+   char query[BUFFER_SIZE], result[BUFFER_SIZE], parameters[BUFFER_SIZE];
+
+   right = sub_arg_in_braces(ses, right, result, 0, SUB_VAR|SUB_FUN);
+   right = sub_arg_in_braces(ses, right, query, 0, SUB_VAR|SUB_FUN);
+   right = sub_arg_in_braces(ses, right, parameters, 0, SUB_VAR|SUB_FUN);
+
+    if (!strcmp(query, "") || !strcmp(result, ""))
+    {
+        sql_syntax_reminder(ses);
+    }
+    else
+    {
+        for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+        {
+            if (!strcmp(sql_it->name, left))
+            {
+                for (query_it = sql_it->prepared_queries_f; query_it; query_it = query_it->next)
+                {
+                    if (!strcmp(query_it->name, query))
+                    {
+                        bind_and_run_query(ses, query_it, parameters, result);
+                        break;
+                    }
+                }
+                if (!query_it)
+                {
+                    if ( (query_it = prepare_query(sql_it, "__direct_query__", query)) )
+                    {
+                        bind_and_run_query(ses, query_it, parameters, result);
+                        unprepare_query(sql_it, query_it);
+                    };
+                }
+                break;
+            }
+        }
+        if (!sql_it)
+        {
+            show_error(ses, LIST_COMMAND, "#SQL: DB handle '%s' unknown.", left);
+        }
+    }
+
+    return ses;
+}
+
+DO_SQL(sql_insert)
+{
+    struct sql_handle *sql_it;
+   char table[BUFFER_SIZE], columns[BUFFER_SIZE], values[BUFFER_SIZE];
+    char chunk[BUFFER_SIZE];
+    char *built_query;
+    char *ptr;
+    int need_comma = 0;
+    struct sql_prepared_query *prep;
+
+    built_query = calloc(BUFFER_SIZE, sizeof(char));
+   right = sub_arg_in_braces(ses, right, table, 0, SUB_VAR|SUB_FUN);
+   right = sub_arg_in_braces(ses, right, columns, 0, SUB_VAR|SUB_FUN);
+   right = sub_arg_in_braces(ses, right, values, 0, SUB_VAR|SUB_FUN);
+
+    if (!strcmp(table, "") || !strcmp(columns, "") || !strcmp(values, ""))
+    {
+        sql_syntax_reminder(ses);
+    }
+    else
+    {
+        for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+        {
+            if (!strcmp(sql_it->name, left))
+            {
+                cat_sprintf(built_query, "INSERT INTO %s (", table);
+
+                ptr = columns;
+                do
+                {
+                    ptr = sub_arg_in_braces(ses, ptr, chunk, 1, SUB_VAR|SUB_FUN);
+                    if (strcmp(chunk, ""))
+                    {
+                        if (need_comma)
+                        {
+                            cat_sprintf(built_query, ", ");
+                        }
+                        cat_sprintf(built_query, "%s", chunk);
+                        need_comma = 1;
+                    }
+                }
+                while (strcmp(chunk, ""));
+                cat_sprintf(built_query, ") VALUES (");
+
+                need_comma = 0;
+                ptr = values;
+                do
+                {
+                    ptr = sub_arg_in_braces(ses, ptr, chunk, 0, SUB_VAR|SUB_FUN);
+                    if (strcmp(chunk, ""))
+                    {
+                        if (need_comma)
+                        {
+                            cat_sprintf(built_query, ", ");
+                        }
+                        cat_sprintf(built_query, "'%s'", chunk);
+                        need_comma = 1;
+                    }
+                }
+                while (strcmp(chunk, ""));
+                cat_sprintf(built_query, ")");
+
+                if ( (prep = prepare_query(sql_it, "__direct_query__", built_query)) )
+                {
+                    bind_and_run_query(ses, prep, "", "");
+                    unprepare_query(sql_it, prep);
+                };
+
+                break;
+            }
+        }
+        if (!sql_it)
+        {
+            show_error(ses, LIST_COMMAND, "#SQL: DB handle '%s' unknown.", left);
+        }
+    }
+
+    free(built_query);
+
+    return ses;
+}
+
+DO_SQL(sql_select)
+{
+    struct sql_handle *sql_it;
+   char columns[BUFFER_SIZE], table[BUFFER_SIZE], criteria[BUFFER_SIZE];
+    char chunk[BUFFER_SIZE];
+    char *built_query;
+    char *ptr;
+    int need_comma = 0;
+    struct sql_prepared_query *prep;
+
+    built_query = calloc(BUFFER_SIZE, sizeof(char));
+   right = sub_arg_in_braces(ses, right, table, 0, SUB_VAR|SUB_FUN);
+   right = sub_arg_in_braces(ses, right, columns, 0, SUB_VAR|SUB_FUN);
+   right = sub_arg_in_braces(ses, right, criteria, 0, SUB_VAR|SUB_FUN);
+
+    if (!strcmp(table, "") || !strcmp(columns, "") || !strcmp(criteria, ""))
+    {
+        sql_syntax_reminder(ses);
+    }
+    else
+    {
+        for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+        {
+            if (!strcmp(sql_it->name, left))
+            {
+                cat_sprintf(built_query, "SELECT ");
+               
+                /* Columns */
+                ptr = columns;
+                need_comma = 0;
+                do
+                {
+                    ptr = sub_arg_in_braces(ses, ptr, chunk, 1, SUB_VAR|SUB_FUN);
+                    if (strcmp(chunk, ""))
+                    {
+                        if (need_comma)
+                        {
+                            cat_sprintf(built_query, ", ");
+                        }
+                        cat_sprintf(built_query, "%s", chunk);
+                        need_comma = 1;
+                    }
+                }
+                while (strcmp(chunk, ""));
+
+                cat_sprintf(built_query, " FROM ");
+
+                /* Tables */
+                ptr = table;
+                need_comma = 0;
+                do
+                {
+                    ptr = sub_arg_in_braces(ses, ptr, chunk, 1, SUB_VAR|SUB_FUN);
+                    if (strcmp(chunk, ""))
+                    {
+                        if (need_comma)
+                        {
+                            cat_sprintf(built_query, ", ");
+                        }
+                        cat_sprintf(built_query, "%s", chunk);
+                        need_comma = 1;
+                    }
+                }
+                while (strcmp(chunk, ""));
+
+                cat_sprintf(built_query, " WHERE %s", criteria);
+
+                /* Reusing 'criteria'. It's a no-no, but we already use so much buffer memory... */
+                right = sub_arg_in_braces(ses, right, criteria, 0, SUB_VAR|SUB_FUN);
+                if ( (prep = prepare_query(sql_it, "__direct_query__", built_query)) )
+                {
+                    bind_and_run_query(ses, prep, "", criteria);
+                    unprepare_query(sql_it, prep);
+                };
+
+                break;
+            }
+        }
+        if (!sql_it)
+        {
+            show_error(ses, LIST_COMMAND, "#SQL: DB handle '%s' unknown.", left);
+        }
+    }
+
+    free(built_query);
+
+    return ses;
+}
+
+DO_SQL(sql_delete)
+{
+    struct sql_handle *sql_it;
+   char table[BUFFER_SIZE], criteria[BUFFER_SIZE];
+    char *built_query;
+    struct sql_prepared_query *prep;
+
+    built_query = calloc(BUFFER_SIZE, sizeof(char));
+   right = sub_arg_in_braces(ses, right, table, 0, SUB_VAR|SUB_FUN);
+   right = sub_arg_in_braces(ses, right, criteria, 0, SUB_VAR|SUB_FUN);
+
+    if (!strcmp(table, "") || !strcmp(criteria, ""))
+    {
+        sql_syntax_reminder(ses);
+    }
+    else
+    {
+        for (sql_it = ses->databases_f; sql_it; sql_it = sql_it->next)
+        {
+            if (!strcmp(sql_it->name, left))
+            {
+                cat_sprintf(built_query, "DELETE FROM %s WHERE %s", table, criteria);
+
+                if ( (prep = prepare_query(sql_it, "__direct_query__", built_query)) )
+                {
+                    bind_and_run_query(ses, prep, "", criteria);
+                    unprepare_query(sql_it, prep);
+                };
+
+                break;
+            }
+        }
+        if (!sql_it)
+        {
+            show_error(ses, LIST_COMMAND, "#SQL: DB handle '%s' unknown.", left);
+        }
+    }
+
+    free(built_query);
+
+    return ses;
+}
+
+void sql_syntax_reminder(struct session *ses)
+{
+    show_message(ses, LIST_COMMAND, "#SYNTAX: #SQL {handle} {OPEN} {db_filename}");
+    show_message(ses, LIST_COMMAND, "         #SQL {handle} {CLOSE}");
+    show_message(ses, LIST_COMMAND, "");
+    show_message(ses, LIST_COMMAND, "         #SQL {handle} {PREPARE} {query_name} {sql}");
+    show_message(ses, LIST_COMMAND, "         #SQL {handle} {UNPREPARE} {query_name}");
+    show_message(ses, LIST_COMMAND, "         #SQL {handle} {RUN} {result} {query} {parameters}");
+    show_message(ses, LIST_COMMAND, "");
+    show_message(ses, LIST_COMMAND, "         #SQL {handle} {INSERT} {table} {columns} {values}");
+    show_message(ses, LIST_COMMAND, "         #SQL {handle} {SELECT} {tables} {columns} {criteria} {result}");
+    show_message(ses, LIST_COMMAND, "         #SQL {handle} {DELETE} {table} {criteria}");
+};
+
+
+struct sql_handle *open_sqlite_handle(struct session *ses, char *label, char *filename)
+{
+    struct sql_handle *new_db_handle;
+
+    new_db_handle                     = (struct sql_handle *) calloc(1, sizeof(struct sql_handle));
+    new_db_handle->name               = strdup(label);
+    new_db_handle->file               = strdup(filename);
+    new_db_handle->prepared_queries_f = NULL;
+    new_db_handle->prepared_queries_l = NULL;
+
+    if  (sqlite3_open(filename, &(new_db_handle->db_handle)))
+    {
+        show_message(ses, LIST_COMMAND, "Error opening sqlite database.");
+        sqlite3_close(new_db_handle->db_handle);
+        free(new_db_handle->file);
+        free(new_db_handle->name);
+        free(new_db_handle);
+        new_db_handle = NULL;
+    }
+    else
+    {
+        LINK(new_db_handle, ses->databases_f, ses->databases_l);
+    }
+
+    return new_db_handle;
+}
+
+void close_sqlite_handle(struct session *ses, struct sql_handle *dbh)
+{
+    if (!dbh) {return;};
+   
+    while (dbh->prepared_queries_f)
+    {
+        unprepare_query(dbh, dbh->prepared_queries_f);
+    };
+
+    UNLINK(dbh, ses->databases_f, ses->databases_l);
+    sqlite3_close(dbh->db_handle);
+    free(dbh->file);
+    free(dbh->name);
+    free(dbh);
+};
+
+struct sql_prepared_query *prepare_query(struct sql_handle *dbh, char *name, char *query)
+{
+    struct sql_prepared_query *prep;
+
+    prep            = (struct sql_prepared_query *) calloc(1, sizeof(struct sql_prepared_query));
+    prep->name      = strdup(name);
+    prep->handle    = NULL;
+    prep->next      = NULL;
+    prep->prev      = NULL;
+
+    if (sqlite3_prepare_v2(dbh->db_handle, query, strlen(query), &(prep->handle), NULL) == SQLITE_OK)
+    {
+        LINK(prep, dbh->prepared_queries_f, dbh->prepared_queries_l);
+    }
+    else
+    {
+        show_error(gts, LIST_COMMAND, "#SQL Error: %s", sqlite3_errmsg(dbh->db_handle));
+        sqlite3_finalize(prep->handle);
+        free(prep->name);
+        free(prep);
+        prep = NULL;
+    }
+
+    return prep;
+}
+
+void unprepare_query(struct sql_handle *dbh, struct sql_prepared_query *qh)
+{
+    UNLINK(qh, dbh->prepared_queries_f, dbh->prepared_queries_l);
+    sqlite3_finalize(qh->handle);
+    free(qh->name);
+    free(qh);
+};
+
+void bind_and_run_query(struct session *ses, struct sql_prepared_query *qh, char *parameters, char *destination)
+{
+    char buf[BUFFER_SIZE];
+    char result[BUFFER_SIZE];
+    int rc;
+    int cols, row = 0, col = 0;
+
+    /* Set bindings. */
+    while (parameters[0])
+    {
+        parameters = get_arg_in_braces(ses, parameters, buf, GET_ALL);
+        rc = sqlite3_bind_parameter_index(qh->handle, buf);
+
+        parameters = get_arg_in_braces(ses, parameters, buf, GET_ALL);
+        if (rc)
+        {
+            sqlite3_bind_text(qh->handle, rc, buf, strlen(buf), SQLITE_TRANSIENT);
+        }
+    };
+
+    /* Execute. */
+    result[0] = '\0';
+    do
+    {
+        rc = sqlite3_step(qh->handle);
+
+        if (rc == SQLITE_ERROR || rc == SQLITE_MISUSE)
+        {
+            show_error(ses, LIST_COMMAND, "#SQL: Query error: %s", sqlite3_errstr(rc));
+        }
+        else if (rc == SQLITE_BUSY)
+        {
+            show_error(ses, LIST_COMMAND, "#SQL: Query failed, database busy: %s.", sqlite3_errstr(rc));
+        }
+        else if (rc == SQLITE_ROW)
+        {
+            row = row + 1;
+            col = 0;
+            cols = sqlite3_data_count(qh->handle);
+
+            cat_sprintf(result, "{%d}{", row);
+
+            while (col < cols)
+            {
+                cat_sprintf(result, "{%s}{%s}",
+                        sqlite3_column_name(qh->handle, col),
+                        sqlite3_column_text(qh->handle, col));
+                col = col + 1;
+            }
+            cat_sprintf(result, "}");
+        }
+    }
+    while (rc == SQLITE_ROW);
+
+    sqlite3_clear_bindings(qh->handle);
+    sqlite3_reset(qh->handle);
+
+    set_nest_node(ses->list[LIST_VARIABLE], destination, "%s", result);
+}
diff --git a/src/tables.c b/src/tables.c
index 6aac97e..d0480de 100644
--- a/src/tables.c
+++ b/src/tables.c
@@ -92,6 +92,7 @@ struct command_type command_table[] =
    {    "showme",            do_showme,            TOKEN_TYPE_COMMAND },
    {    "snoop",             do_snoop,             TOKEN_TYPE_COMMAND },
    {    "split",             do_split,             TOKEN_TYPE_COMMAND },
+   {    "sql",               do_sql,               TOKEN_TYPE_COMMAND },
    {    "ssl",               do_ssl,               TOKEN_TYPE_COMMAND },
    {    "substitute",        do_substitute,        TOKEN_TYPE_COMMAND },
    {    "switch",            do_nop,               TOKEN_TYPE_SWITCH  },
@@ -144,7 +145,8 @@ struct list_type list_table[LIST_MAX] =
    {    "TAB",               "TABS",               ALPHA,       1,  LIST_FLAG_MESSAGE|LIST_FLAG_READ|LIST_FLAG_WRITE|LIST_FLAG_CLASS|LIST_FLAG_INHERIT },
 //   {    "TABCYCLE",          "TABCYCLE",           APPEND,      1,  LIST_FLAG_MESSAGE|LIST_FLAG_HIDE                                                   },
    {    "TICKER",            "TICKERS",            ALPHA,       3,  LIST_FLAG_MESSAGE|LIST_FLAG_READ|LIST_FLAG_WRITE|LIST_FLAG_CLASS|LIST_FLAG_INHERIT },
-   {    "VARIABLE",          "VARIABLES",          ALPHA,       2,  LIST_FLAG_MESSAGE|LIST_FLAG_READ|LIST_FLAG_WRITE|LIST_FLAG_CLASS|LIST_FLAG_INHERIT|LIST_FLAG_NEST }
+   {    "VARIABLE",          "VARIABLES",          ALPHA,       2,  LIST_FLAG_MESSAGE|LIST_FLAG_READ|LIST_FLAG_WRITE|LIST_FLAG_CLASS|LIST_FLAG_INHERIT|LIST_FLAG_NEST },
+   {    "SQL",               "SQL",                ALPHA,       2,  LIST_FLAG_MESSAGE|LIST_FLAG_CLASS|LIST_FLAG_INHERIT}
 };
 
 struct substitution_type substitution_table[] =
@@ -923,6 +925,19 @@ struct buffer_type buffer_table[] =
    {    "",                  NULL,                ""                                               }
 };
 
+struct sql_type sql_table[] =
+{
+    {   "OPEN",               sql_open      },
+    {   "CLOSE",              sql_close     },
+    {   "PREPARE",            sql_prepare   },
+    {   "UNPREPARE",          sql_unprepare },
+    {   "RUN",                sql_run       },
+    {   "INSERT",             sql_insert    },
+    {   "SELECT",             sql_select    },
+    {   "DELETE",             sql_delete    },
+    {   "",                   NULL          }
+};
+
 #define NEG_U 0
 
 struct telopt_type telopt_table[] =
diff --git a/src/tintin.h b/src/tintin.h
index 8226380..8f7197e 100644
--- a/src/tintin.h
+++ b/src/tintin.h
@@ -195,7 +195,8 @@
 #define LIST_TAB                        16
 #define LIST_TICKER                     17
 #define LIST_VARIABLE                   18
-#define LIST_MAX                        19
+#define LIST_SQL                        19
+#define LIST_MAX                        20
 
 /*
    Command type
@@ -559,7 +560,7 @@ enum operators
 #define DO_CURSOR(cursor) void cursor (struct session *ses, char *arg)
 #define DO_HISTORY(history) void history (struct session *ses, char *arg)
 #define DO_BUFFER(buffer) void buffer (struct session *ses, char *arg)
-
+#define DO_SQL(sql) struct session *sql (struct session *ses, char *left, char *right)
 
 
 
@@ -645,6 +646,8 @@ struct session
    long long               check_output;
    int                     auto_tab;
    gnutls_session_t        ssl;
+    struct sql_handle     * databases_f;
+    struct sql_handle     * databases_l;
 };
 
 
@@ -732,6 +735,25 @@ struct link_data
    char                 * str3;
 };
 
+struct sql_prepared_query
+{
+    struct sql_prepared_query * next;
+    struct sql_prepared_query * prev;
+    char                      * name;
+    struct sqlite3_stmt       * handle;
+};
+
+struct sql_handle
+{
+    struct sql_handle         * next;
+    struct sql_handle         * prev;
+    char                      * name;
+    char                      * file;
+    struct sqlite3            * db_handle;
+    struct sql_prepared_query * prepared_queries_f;
+    struct sql_prepared_query * prepared_queries_l;
+};
+
 /*
    Typedefs
 */
@@ -747,6 +769,7 @@ typedef void            PATH    (struct session *ses, char *arg);
 typedef struct session *LINE    (struct session *ses, char *arg);
 typedef void            HISTORY (struct session *ses, char *arg);
 typedef void            BUFFER  (struct session *ses, char *arg);
+typedef struct session *SQL     (struct session *ses, char *left, char *right);
 
 /*
    Structures for tables.c
@@ -876,6 +899,12 @@ struct term_type
    int                    flag;
 };
 
+struct sql_type
+{
+   char                  * name;
+   SQL                   * fun;
+};
+
 struct str_data
 {
    unsigned int              max;
@@ -979,6 +1008,21 @@ struct search_data
    Function declarations
 */
 
+#ifndef __SQL_H__
+#define __SQL_H__
+
+extern DO_COMMAND(do_sql);
+extern DO_SQL(sql_open);
+extern DO_SQL(sql_close);
+extern DO_SQL(sql_prepare);
+extern DO_SQL(sql_unprepare);
+extern DO_SQL(sql_run);
+extern DO_SQL(sql_insert);
+extern DO_SQL(sql_select);
+extern DO_SQL(sql_delete);
+
+#endif
+
 #ifndef __ACTION_H__
 #define __ACTION_H__
 
@@ -1819,6 +1863,7 @@ extern struct path_type path_table[];
 extern struct line_type line_table[];
 extern struct history_type history_table[];
 extern struct buffer_type buffer_table[];
+extern struct sql_type sql_table[];
 extern struct telopt_type telopt_table[];
 extern struct term_type term_table[];
[/code]
Back to top
View user's profile Send private message
Display posts from previous:   
Post new topic   Reply to topic    The TinTin++ message board Forum Index -> Development All times are GMT - 5 Hours
Page 1 of 1

 
Jump to:  
You cannot post new topics in this forum
You cannot reply to topics in this forum
You cannot edit your posts in this forum
You cannot delete your posts in this forum
You cannot vote in polls in this forum
Get TinTin++ Mud Client at SourceForge.net. Fast, secure and Free Open Source software downloads Get TinTin++ Mud Client at SourceForge.net. Fast, secure and Free Open Source software downloads
TinTin++ Homepage

Powered by phpBB © 2001, 2002 phpBB Group