clean up loose ends in SqliteMailbox
[org.ibex.mail.git] / src / org / ibex / mail / SqliteMailbox.java
1 package org.ibex.mail;
2
3 import org.ibex.util.*;
4 import org.ibex.io.Fountain;
5 import org.ibex.io.Stream;
6 import java.sql.Timestamp;
7 import java.sql.*;
8 import java.net.*;
9 import java.io.*;
10 import java.util.*;
11
12
13 public class SqliteMailbox extends Mailbox.Default implements MailTree {
14
15     public MailTree     slash(String name, boolean create) { return null; }
16     public String[]     children() { return new String[0]; }
17     public void         rmdir(String subdir) { throw new RuntimeException("invalid"); }
18     public void         rename(String subdir, MailTree newParent, String newName) { throw new RuntimeException("invalid"); }
19     public Mailbox      getMailbox() { return this; }
20
21
22     private Connection conn;
23     private static final String columns  =
24         "                                        messageid_,       from_,to_,date_,subject_,headers_,body_,flags_";
25     private static final String[] indexedColumns = new String[] {
26         "uid_",
27         "messageid_",
28         "flags_",
29         /*
30         "from_",
31         "to_",
32         "subject_",
33         "date_"
34         */
35     };
36     
37     /**
38      *  from http://www.sqlite.org/autoinc.html
39      *  "If a column has the type INTEGER PRIMARY KEY AUTOINCREMENT
40      *   then a slightly different ROWID selection algorithm is
41      *   used. The ROWID chosen for the new row is one larger than the
42      *   largest ROWID that has ever before existed in that same
43      *   table. If the table has never before contained any data, then
44      *   a ROWID of 1 is used. If the table has previously held a row
45      *   with the largest possible ROWID, then new INSERTs are not
46      *   allowed and any attempt to insert a new row will fail with an
47      *   SQLITE_FULL error.
48      */
49     // FIXME: should messageid_ be decared unique?
50     private static final String columns_ =
51         "uid_ INTEGER PRIMARY KEY AUTOINCREMENT, messageid_ unique,from_,to_,date_,subject_,headers_,body_,flags_";
52
53     private final int uidValidity;
54     private final File file;
55     public  int uidValidity()  { return uidValidity; }
56
57     public String toString() { return file.getName(); }
58     public SqliteMailbox(String filename) throws SQLException {
59         try {
60             this.file = new File(filename);
61             Class.forName("org.sqlite.JDBC");
62             conn = DriverManager.getConnection("jdbc:sqlite:"+filename);
63             conn.prepareStatement("create table if not exists uidvalidity (uidvalidity)").executeUpdate();
64             ResultSet rs = conn.prepareStatement("select uidvalidity from uidvalidity").executeQuery();
65             if (!rs.next()) {
66                 this.uidValidity = new Random().nextInt();
67                 PreparedStatement ps = conn.prepareStatement("insert into uidvalidity (uidvalidity) values (?)");
68                 ps.setInt(1, uidValidity);
69                 ps.executeUpdate();
70             } else {
71                 this.uidValidity = rs.getInt(1);
72             }
73             conn.prepareStatement("create table if not exists 'mail' ("+columns_+")").executeUpdate();
74             for(String name : indexedColumns)
75                 conn.prepareStatement("create index if not exists "+name+"index on mail("+name+");").executeUpdate();
76         }
77         catch (SQLException e) { throw new RuntimeException(e); }
78         catch (ClassNotFoundException e) { throw new RuntimeException(e); }
79     }
80
81     private HashMap<Integer,Integer> imapToUid = new HashMap<Integer,Integer>();
82     private HashMap<Integer,Integer> uidToImap = new HashMap<Integer,Integer>();
83     private boolean imapNumberCacheValid = false;
84     public void updateImapNumberCache() throws SQLException {
85         synchronized(this) {
86             Log.warn(this+"", "rebuilding imapNumberCache...");
87             imapToUid.clear();
88             uidToImap.clear();
89             PreparedStatement q = conn.prepareStatement("select uid_ from mail");
90             ResultSet rs = q.executeQuery();
91             int num = 1;
92             while(rs.next()) {
93                 imapToUid.put(num, rs.getInt(1));
94                 uidToImap.put(rs.getInt(1), num);
95                 num++;
96             }
97             imapNumberCacheValid = true;
98         }
99     }
100     public int queryImapNumberCache(int uid) throws SQLException {
101         synchronized(this) {
102             if (!imapNumberCacheValid) updateImapNumberCache();
103             Integer ret = uidToImap.get(uid);
104             if (ret == null) return -1;
105             return ret;
106         }
107     }
108     public int queryUidForImapNum(int imapNumber) throws SQLException {
109         synchronized(this) {
110             if (!imapNumberCacheValid) updateImapNumberCache();
111             Integer ret = imapToUid.get(imapNumber);
112             if (ret == null) return -1;
113             return ret;
114         }
115     }
116
117
118     public int maxuid() { return uidNext(); }
119     public int uidNext() {
120         try {
121             PreparedStatement q = conn.prepareStatement("select max(uid_) from mail");
122             ResultSet rs = q.executeQuery();
123             //if (!rs.next()) return -1;
124             if (!rs.next()) throw new RuntimeException("select max(uid_) returned no rows!");
125             return rs.getInt(1)+1;
126         } catch (Exception e) { throw new RuntimeException(e); }
127     }
128
129     public Mailbox.Iterator iterator() {
130         Log.warn(this, "performance warning: called iterator() on entire mailbox");
131         Log.printStackTrace(this, Log.WARN);
132         return new SqliteJdbcIterator();
133     }
134     private String set(int[] set, String arg) {
135         String whereClause = "";
136         boolean needsOr = false;
137         for(int i=0; i<set.length; i+=2) {
138             if (needsOr) whereClause += " or ";
139             whereClause += "(";
140             whereClause += arg+">=" + set[i];
141             whereClause += " and ";
142             while(i+2 < set.length && set[i+2] == (set[i+1]+1)) i += 2;
143             whereClause += arg+"<=" + set[i+1];
144             whereClause += ")";
145             needsOr = true;
146         }
147         return whereClause;
148     }
149     private String getWhereClause(Query q) throws UnsupportedQueryException {
150         String op;
151         switch(q.type) {
152             case Query.NOT:      return "not ("+getWhereClause(q.q[0])+")";
153             case Query.AND:      op = "and";
154             case Query.OR:       op = "or";
155                 {
156                     boolean add = false;
157                     StringBuffer sb = new StringBuffer();
158                     for(int i=0; i<q.q.length; i++) {
159                         if (add) sb.append(" " + op);
160                         sb.append(" (");
161                         sb.append(getWhereClause(q.q[i]));
162                         sb.append(")");
163                         add = true;
164                     }
165                     return sb.toString();
166                 }
167             case Query.ALL:      return "1=1";
168             case Query.UID:      return set(q.set, "uid_");
169             case Query.DELETED:  return "((flags_ & "+(Mailbox.Flag.DELETED)+")!=0)";
170             case Query.SEEN:     return "((flags_ & "+(Mailbox.Flag.SEEN)+")!=0)";
171             case Query.FLAGGED:  return "((flags_ & "+(Mailbox.Flag.FLAGGED)+")!=0)";
172             case Query.DRAFT:    return "((flags_ & "+(Mailbox.Flag.DRAFT)+")!=0)";
173             case Query.ANSWERED: return "((flags_ & "+(Mailbox.Flag.ANSWERED)+")!=0)";
174             case Query.RECENT:   return "((flags_ & "+(Mailbox.Flag.RECENT)+")!=0)";
175                 /*
176                 public static final int SENT       = 5;
177                 public static final int ARRIVAL    = 6;
178                 public static final int HEADER     = 7;
179                 public static final int SIZE       = 8;
180                 public static final int BODY       = 9;
181                 public static final int FULL       = 10;
182                 public static final int IMAPNUM    = 11;
183                 */
184
185             case Query.IMAPNUM: {
186                 try {
187                     // translate queries in terms of imap numbers into queries in terms of uids
188                     // RELIES ON THE FACT THAT UIDS ARE MONOTONICALLY INCREASING
189                     int[] set = new int[q.set==null ? 2 : q.set.length];
190                     if (q.set==null) { set[0] = q.min; set[1] = q.max; }
191                     else System.arraycopy(q.set, 0, set, 0, q.set.length);
192                     for(int i=0; i<set.length; i++) {
193                         int uid = queryUidForImapNum(set[i]);
194                         if (uid==-1) {
195                             Log.info(SqliteMailbox.class, "PROBLEM => resorting to superclass: " + q);
196                             throw new UnsupportedQueryException();
197                         }
198                         set[i] = uid;
199                     }
200                     return getWhereClause(Query.uid(set));
201                 } catch (SQLException e) {
202                     Log.error(this, e);
203                     Log.info(SqliteMailbox.class, "resorting to superclass: " + q);
204                     throw new UnsupportedQueryException();
205                 }
206             }
207
208             default: {
209                 Log.info(SqliteMailbox.class, "resorting to superclass: " + q);
210                 throw new UnsupportedQueryException();
211             }
212         }
213     }
214     private static class UnsupportedQueryException extends Exception { }
215     public Mailbox.Iterator iterator(Query q) {
216         try {
217             String whereClause = getWhereClause(q);
218             Log.info(this, "whereClause = " + whereClause);
219             return new SqliteJdbcIterator("where "+whereClause+";");
220         } catch (UnsupportedQueryException _) {
221             return super.iterator(q);
222         }
223     }
224     public int count(Query q) {
225         try {
226             String whereClause = getWhereClause(q);
227             Log.info(this, "whereClause = " + whereClause);
228             try {
229                 Log.warn("SQL", "select count(*) from mail where " + whereClause);
230                 ResultSet rs = conn.prepareStatement("select count(*) from mail where " + whereClause).executeQuery();
231                 rs.next();
232                 return rs.getInt(1);
233             } catch (Exception e) { throw new RuntimeException(e); }
234         } catch (UnsupportedQueryException _) {
235             return super.count(q);
236         }
237     }
238     public void             insert(Message m, int flags) {
239         try {
240             PreparedStatement query = conn.prepareStatement("select headers_,body_,flags_ from 'mail' where messageid_=?");
241             query.setString(1, m.messageid);
242             Log.warn("SQL", "select headers_,body_,flags_ from 'mail' where messageid_="+m.messageid);
243             ResultSet rs2 = query.executeQuery();
244             if (rs2.next()) {
245                 Message m2 = Message.newMessage(Fountain.Util.concat(Fountain.Util.create(rs2.getString(1)),
246                                                                      Fountain.Util.create("\r\n\r\n"),
247                                                                      Fountain.Util.create(rs2.getString(2))));
248                 StringBuffer s1 = new StringBuffer();
249                 m.getBody().getStream().transcribe(s1);
250                 StringBuffer s2 = new StringBuffer();
251                 m2.getBody().getStream().transcribe(s2);
252                 if (!s1.toString().equals(s2.toString())) {
253                     Log.error(this.toString(),
254                               "attempt to insert two messages with identical messageid ("+m.messageid+") but different bodies:\n"+
255                               "  (body length="+s1.length()+") "+m.summary()+"\n"+
256                               "  (body length="+s2.length()+") "+m2.summary()+"\n");
257                 } else {
258                     Log.warn(this.toString(),
259                              "silently dropping duplicate insert() [messageids and bodies match]: " + m.summary());
260                     return;
261                 }
262             }
263             PreparedStatement add =
264                 conn.prepareStatement("insert "+/*"or replace "+*/"into 'mail' ("+columns+") values (?,?,?,?,?,?,?,?)");
265             add.setString(1, m.messageid+"");
266             add.setString(2, m.from+"");
267             add.setString(3, m.to+"");
268             add.setString(4, m.date+"");
269             add.setString(5, m.subject+"");
270             add.setString(6, SqliteDB.streamToString(m.headers.getStream()));
271             add.setString(7, SqliteDB.streamToString(m.getBody().getStream()));
272             add.setInt   (8, flags);
273             add.executeUpdate();
274
275             // FIXME: be smarter here?
276             imapNumberCacheValid = false;
277         } catch (Exception e) { throw new RuntimeException(e); }
278     }
279
280     private class SqliteJdbcIterator implements Mailbox.Iterator {
281         // could be more efficient in a ton of ways
282         private ResultSet rs;
283         private int count  = 0;
284         private int flags;
285         private Message m  = null;
286         private int uid = -1;
287         private String whereClause;
288         public SqliteJdbcIterator() { this(""); }
289         public SqliteJdbcIterator(String whereClause) {
290             try {
291                 /*
292                 if (whereClause.equals(""))
293                     Log.warn(this, "performance warning: empty whereClause");
294                 */
295                 this.whereClause = whereClause;
296                 Log.warn("SQL", "select messageid_,uid_,flags_ from 'mail' "+whereClause);
297                 PreparedStatement query = conn.prepareStatement("select messageid_,uid_,flags_ from 'mail' "+whereClause);
298                 rs = query.executeQuery();
299             } catch (Exception e) { throw new RuntimeException(e); }
300         }
301         public Headers head()   {
302             if (m != null) return m.headers;
303             try {
304                 PreparedStatement query = conn.prepareStatement("select headers_,flags_ from 'mail' where messageid_=?");
305                 query.setString(1, rs.getString(1));
306                 Log.warn("SQL", "select headers_,flags_ from 'mail' where messageid_="+rs.getString(1));
307
308                 ResultSet rs2 = query.executeQuery();
309                 if (!rs2.next()) { Log.error("XXX", "should not happen"); return null; }
310                 flags = rs2.getInt(2);
311                 return new Headers(Fountain.Util.create(rs2.getString(1)));
312             } catch (Exception e) { throw new RuntimeException(e); }
313         }
314         public Message cur()    {
315             try {
316                 if (m!=null) return m;
317                 PreparedStatement query = conn.prepareStatement("select headers_,body_,flags_ from 'mail' where messageid_=?");
318                 query.setString(1, rs.getString(1));
319                 Log.warn("SQL", "select headers_,body_,flags_ from 'mail' where messageid_="+rs.getString(1));
320
321                 ResultSet rs2 = query.executeQuery();
322                 if (!rs2.next()) { Log.error("XXX", "should not happen"); return null; }
323                 m = Message.newMessage(Fountain.Util.concat(Fountain.Util.create(rs2.getString(1)),
324                                                             Fountain.Util.create("\r\n\r\n"),
325                                                             Fountain.Util.create(rs2.getString(2))));
326                 flags = rs2.getInt(3);
327                 return m;
328             } catch (Exception e) { throw new RuntimeException(e); }
329         }
330         public int     getFlags()   {
331             try { return rs.getInt("flags_"); } catch (Exception e) { throw new RuntimeException(e); }
332         }
333         public void    setFlags(int flags) {
334             try {
335                 int oldflags = rs.getInt("flags_");
336                 if (oldflags==flags) return;
337                 Log.info(this, "setflags (old="+oldflags+")" + "update mail set flags_="+(flags)+" where uid_="+uid()+"");
338                 if ((flags & Mailbox.Flag.DELETED) != 0) Log.printStackTrace("deletion", Log.WARN);
339                 PreparedStatement update = conn.prepareStatement("update mail set flags_=? where uid_=?");
340                 update.setInt(1, flags);
341                 update.setInt(2, uid());
342                 update.executeUpdate();
343             } catch (Exception e) { throw new RuntimeException(e); }
344         }
345         public boolean next()       {
346             try { m = null; uid = -1; count++;
347             boolean ret = rs.next();
348             return ret;
349             } catch (Exception e) { throw new RuntimeException(e); } }
350         public int     uid()        {
351             if (uid == -1)
352                 try { uid = rs.getInt("uid_"); } catch (Exception e) { throw new RuntimeException(e); }
353             return uid;
354         }
355         public int     imapNumber() {
356             if ("".equals(whereClause)) return count;
357             try { return queryImapNumberCache(uid()); } catch (SQLException s) { throw new RuntimeException(s); }
358         }
359         public int     nntpNumber() { return uid(); }
360         public void    delete()     {
361             try {
362                 Log.error("sqlite", "actually deleting message "+uid()+" "+head().get("subject"));
363                 Log.printStackTrace("sqlite", Log.ERROR);
364
365                 PreparedStatement update = conn.prepareStatement("delete from mail where uid_=?");
366                 update.setInt(1, uid());
367                 update.executeUpdate();
368
369                 // FIXME: be smarter here?
370                 imapNumberCacheValid = false;
371
372             } catch (Exception e) { throw new RuntimeException(e); }
373         }
374     }
375
376
377 }