Merge lp:~thisfred/u1db/fix-get_keys_from_index into lp:u1db

Proposed by Eric Casteleijn
Status: Merged
Approved by: Eric Casteleijn
Approved revision: 333
Merged at revision: 327
Proposed branch: lp:~thisfred/u1db/fix-get_keys_from_index
Merge into: lp:u1db
Diff against target: 441 lines (+186/-56)
11 files modified
include/u1db/u1db.h (+4/-2)
include/u1db/u1db_internal.h (+10/-0)
src/u1db_query.c (+115/-25)
u1db/__init__.py (+1/-2)
u1db/backends/inmemory.py (+3/-1)
u1db/backends/sqlite_backend.py (+18/-14)
u1db/commandline/client.py (+3/-2)
u1db/tests/c_backend_wrapper.pyx (+13/-7)
u1db/tests/test_backends.py (+17/-1)
u1db/tests/test_c_backend.py (+1/-1)
u1todo/u1todo.py (+1/-1)
To merge this branch: bzr merge lp:~thisfred/u1db/fix-get_keys_from_index
Reviewer Review Type Date Requested Status
John A Meinel (community) Approve
Review via email: mp+109451@code.launchpad.net

Commit message

get_keys_from_index now returns simple values for single column indexes, and tuples for multicolumn ones

Description of the change

get_keys_from_index now returns simple values for single column indexes, and tuples for multicolumn ones

Not ecstatic about the C implementation, so suggestions for improvement welcome.

To post a comment you must log in.
Revision history for this message
John A Meinel (jameinel) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

On 6/9/2012 1:11 AM, Eric Casteleijn wrote:
> Eric Casteleijn has proposed merging
> lp:~thisfred/u1db/fix-get_keys_from_index into lp:u1db.
>
> Requested reviews: Ubuntu One hackers (ubuntuone-hackers) Related
> bugs: Bug #1009505 in U1DB: "get_keys_from_index is useless for
> multicolumn indexes" https://bugs.launchpad.net/u1db/+bug/1009505
>
> For more details, see:
> https://code.launchpad.net/~thisfred/u1db/fix-get_keys_from_index/+merge/109451
>
> get_keys_from_index now returns simple values for single column
> indexes, and tuples for multicolumn ones
>
> Not ecstatic about the C implementation, so suggestions for
> improvement welcome.

I don't think we should change the type of the return based on the
index. If we need to return tuples than we should always do so. I'm
willing to be convinced otherwise, but this *strongly* feels like you
should have 2 apis if you want to have 2 different return signatures.

John
=:->

-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.9 (Cygwin)
Comment: Using GnuPG with Mozilla - http://enigmail.mozdev.org/

iEYEARECAAYFAk/VnhoACgkQJdeBCYSNAAPT0ACgrYlYY/fokYHlASC27ecQIXjD
ZqgAoLQ5dIy83PMoq9nYPxnjvnlPxtA8
=i/Oc
-----END PGP SIGNATURE-----

328. By Eric Casteleijn

do not unpack single tuples, no matter how tempting

Revision history for this message
Eric Casteleijn (thisfred) wrote :

thanks, jam. fixed.

Revision history for this message
John A Meinel (jameinel) wrote :

So if I understand the C code correctly, you do a callback for each bit of the key, and then follow it up with a NULL for each run. So if you had a 3 column index (firstname, lastname, irc_nick), then the callback would give you:

 Eric
 Castelijn
 thisfred
 NULL
 John
 Meinel
 jam
 NULL
...

While this is functional, I really think the users would be better served getting:

 Eric, Castelijn, thisfred
 John, Meinel, jam
 ...

Since the C code is now doing the query that way anyway (it is doing the join of document_fields onto itself), I think we should probably just allocate an array to pass the data into the callback.

I would probably do it with const char * stuff. So that we share pointers with the sqlite code, and the callback needs to allocate memory if it wants to hold on to anything (vs allocating the memory, and the callback must free everything/save it into a buffer for later.)

So something like:

typedef int(*u1db_keys_callback)(void *context, int num_fields, const char **key)

and then in the function

int
u1db_get_index_keys(db, index_name, context, cb)
{
...

  char **key = NULL;
...

  key = (char**)calloc(num_fields, sizeof(char*));
...
  finally:
    if (key != NULL) {
      free(key)
    }

And then the inner loop becomes:

    while (status == SQLITE_ROW) {
        for (i = 0; i < num_fields; ++i) {
          field = (char*)sqlite3_column_text(statement, i);
          key[i] = field;
      }
      if ((status = cb(context, num_fields, key)) != U1DB_OK) {
          goto finish;
      }
      status = sqlite3_step(statement);
    }

Note that I'm intentionally calling the whole tuple a singular 'key' made up of 'fields'.

329. By Eric Casteleijn

changed callback signature

Revision history for this message
Eric Casteleijn (thisfred) wrote :

Fixed, thanks!

Revision history for this message
Eric Casteleijn (thisfred) wrote :

now to fix the conflicts :)

Revision history for this message
Eric Casteleijn (thisfred) wrote :

resolved

330. By Eric Casteleijn

merged trunk, resolved conflicts, removed unnecessary intermediate variable

331. By Eric Casteleijn

rereresolved conflicts

Revision history for this message
John A Meinel (jameinel) wrote :

+ key[i] = (char*)sqlite3_column_text(statement, i);

I think this line we probably want
key[i] = (const char*)...

are you getting a warning about const changing? (I know we need the cast at all because sqlite3 returns an unsigned char*).

You are mixing styles here:

167 + if (key != NULL)
168 + free(key);
169 + if (query_str != NULL) {
170 + free(query_str);
171 + }

Personally, I'm a fan of always using {} and putting it at the end of line like you did for query_str.

366 + a_list.append(field_list)

^ I think you want a tuple(field_list) here.

You are using "GROUP BY" in the query, which was useful when you were counting. I wonder if we would rather use "SELECT DISTINCT" rather than "SELECT ... GROUP BY". Probably best would be to try both and see if one is significantly faster. (SQL means that they could be identical internally).

Overall, I think it looks good, though.

review: Approve
332. By Eric Casteleijn

added missing const, cosmetic fix

Revision history for this message
Eric Casteleijn (thisfred) wrote :

Thanks!

> + key[i] = (char*)sqlite3_column_text(statement, i);
>
> I think this line we probably want
> key[i] = (const char*)...

fixed

> are you getting a warning about const changing? (I know we need the cast at
> all because sqlite3 returns an unsigned char*).

There was no warning.

> You are mixing styles here:
>
> 167 + if (key != NULL)
> 168 + free(key);
> 169 + if (query_str != NULL) {
> 170 + free(query_str);
> 171 + }

Fixed.

> 366 + a_list.append(field_list)
>
> ^ I think you want a tuple(field_list) here.

The tuple cast was done later, but you're right, it's better to do it here and not iterate over the list later. Fixed.

> You are using "GROUP BY" in the query, which was useful when you were
> counting. I wonder if we would rather use "SELECT DISTINCT" rather than
> "SELECT ... GROUP BY". Probably best would be to try both and see if one is
> significantly faster. (SQL means that they could be identical internally).

I will test this, and fix it on a separate branch if necessary.

333. By Eric Casteleijn

various small fixes

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'include/u1db/u1db.h'
2--- include/u1db/u1db.h 2012-06-12 15:17:40 +0000
3+++ include/u1db/u1db.h 2012-06-13 11:19:23 +0000
4@@ -40,7 +40,8 @@
5
6 typedef struct _u1query u1query;
7 typedef int (*u1db_doc_callback)(void *context, u1db_document *doc);
8-typedef int (*u1db_key_callback)(void *context, const char *key);
9+typedef int (*u1db_key_callback)(void *context, int num_fields,
10+ const char **key);
11 typedef int (*u1db_doc_gen_callback)(void *context, u1db_document *doc, int gen);
12 typedef int (*u1db_doc_id_gen_callback)(void *context, const char *doc_id, int gen);
13 typedef int (*u1db_trans_info_callback)(void *context, const char *doc_id,
14@@ -352,7 +353,8 @@
15 * Get keys under which documents are indexed.
16 *
17 * @param index_name Name of the index for which to get keys.
18- * @param context Will be returned via the document callback
19+ * @param context Will be returned via the document callback. cb will be called
20+ * once for each column, with a NULL value to separate rows.
21 */
22 int u1db_get_index_keys(u1database *db, char *index_name, void *context,
23 u1db_key_callback cb);
24
25=== modified file 'include/u1db/u1db_internal.h'
26--- include/u1db/u1db_internal.h 2012-06-12 15:17:40 +0000
27+++ include/u1db/u1db_internal.h 2012-06-13 11:19:23 +0000
28@@ -368,6 +368,16 @@
29 int *start_wildcard, int *end_wildcard);
30
31 /**
32+ * Format an index keys query
33+ *
34+ * @param n_fields The number of fields being passed in, (the number of args
35+ * in argp)
36+ * @param buf (OUT) The character array. This will be dynamically allocated,
37+ * and callers must free() it.
38+ */
39+int u1db__format_index_keys_query(int n_fields, char **buf);
40+
41+/**
42 * Given this document content, update the indexed fields in the db.
43 */
44 int u1db__update_indexes(u1database *db, const char *doc_id,
45
46=== modified file 'src/u1db_query.c'
47--- src/u1db_query.c 2012-06-12 15:17:40 +0000
48+++ src/u1db_query.c 2012-06-13 11:19:23 +0000
49@@ -40,7 +40,6 @@
50 int value_type;
51 } operation;
52
53-
54 typedef struct string_list_item_
55 {
56 char *data;
57@@ -78,7 +77,8 @@
58 } transformation;
59
60 typedef int(*op_function)(string_list *, const string_list *,
61- const string_list *);
62+ const string_list *);
63+
64
65 static int
66 init_list(string_list **list)
67@@ -749,14 +749,21 @@
68 void *context, u1db_key_callback cb)
69 {
70 int status = U1DB_OK;
71- char *key = NULL;
72+ int num_fields = 0;
73+ int bind_arg, i;
74+ const char **key = NULL;
75+ string_list *field_names = NULL;
76+ string_list_item *field_name = NULL;
77+ char *query_str = NULL;
78 sqlite3_stmt *statement;
79+
80+ status = init_list(&field_names);
81+ if (status != U1DB_OK)
82+ goto finish;
83 status = sqlite3_prepare_v2(
84 db->sql_handle,
85- "SELECT document_fields.value FROM "
86- "index_definitions INNER JOIN document_fields ON "
87- "index_definitions.field = document_fields.field_name WHERE "
88- "index_definitions.name = ? GROUP BY document_fields.value;",
89+ "SELECT field FROM index_definitions WHERE name = ? ORDER BY "
90+ "offset;",
91 -1, &statement, NULL);
92 if (status != SQLITE_OK) {
93 goto finish;
94@@ -768,37 +775,64 @@
95 }
96 status = sqlite3_step(statement);
97 if (status == SQLITE_DONE) {
98- sqlite3_finalize(statement);
99- status = sqlite3_prepare_v2(
100- db->sql_handle,
101- "SELECT field FROM index_definitions WHERE name = ?;",
102- -1, &statement, NULL);
103- if (status != SQLITE_OK) {
104+ status = U1DB_INDEX_DOES_NOT_EXIST;
105+ goto finish;
106+ }
107+ while (status == SQLITE_ROW) {
108+ num_fields++;
109+ status = append(field_names, (char*)sqlite3_column_text(statement, 0));
110+ if (status != U1DB_OK)
111 goto finish;
112- }
113+ status = sqlite3_step(statement);
114+ }
115+ if (status != SQLITE_DONE) {
116+ goto finish;
117+ }
118+ sqlite3_finalize(statement);
119+ status = u1db__format_index_keys_query(num_fields, &query_str);
120+
121+ if (status != U1DB_OK) {
122+ goto finish;
123+ }
124+ status = sqlite3_prepare_v2(
125+ db->sql_handle, query_str, -1, &statement, NULL);
126+ if (status != SQLITE_OK) {
127+ goto finish;
128+ }
129+ bind_arg = 1;
130+ for (field_name = field_names->head; field_name != NULL; field_name =
131+ field_name->next) {
132 status = sqlite3_bind_text(
133- statement, 1, index_name, -1, SQLITE_TRANSIENT);
134- if (status != SQLITE_OK) {
135+ statement, bind_arg++, field_name->data, -1, SQLITE_TRANSIENT);
136+ if (status != SQLITE_OK)
137 goto finish;
138- }
139- status = sqlite3_step(statement);
140- if (status == SQLITE_DONE) {
141- status = U1DB_INDEX_DOES_NOT_EXIST;
142- } else {
143- status = U1DB_OK;
144- }
145+ }
146+ status = sqlite3_step(statement);
147+ key = (const char**)calloc(num_fields, sizeof(char*));
148+ if (key == NULL) {
149+ status = U1DB_NOMEM;
150 goto finish;
151 }
152 while (status == SQLITE_ROW) {
153- key = (char*)sqlite3_column_text(statement, 0);
154- if ((status = cb(context, key)) != U1DB_OK)
155+ for (i = 0; i < num_fields; ++i) {
156+ key[i] = (const char*)sqlite3_column_text(statement, i);
157+ }
158+ if ((status = cb(context, num_fields, key)) != U1DB_OK) {
159 goto finish;
160+ }
161 status = sqlite3_step(statement);
162 }
163 if (status == SQLITE_DONE) {
164 status = U1DB_OK;
165 }
166 finish:
167+ if (key != NULL) {
168+ free(key);
169+ }
170+ if (query_str != NULL) {
171+ free(query_str);
172+ }
173+ destroy_list(field_names);
174 sqlite3_finalize(statement);
175 return status;
176 }
177@@ -1007,6 +1041,62 @@
178 return status;
179 }
180
181+int
182+u1db__format_index_keys_query(int n_fields, char **buf)
183+{
184+ int status = U1DB_OK;
185+ int buf_size, i;
186+ char *cur = NULL;
187+
188+ if (n_fields < 1) {
189+ return U1DB_INVALID_PARAMETER;
190+ }
191+ // 81 for 1 doc, 166 for 2, 251 for 3
192+ buf_size = (1 + n_fields) * 100;
193+ // The first field is treated specially
194+ cur = (char*)calloc(buf_size, 1);
195+ if (cur == NULL) {
196+ return U1DB_NOMEM;
197+ }
198+ *buf = cur;
199+ add_to_buf(&cur, &buf_size, "SELECT ");
200+ for (i = 0; i < n_fields; ++i) {
201+ if (i != 0) {
202+ add_to_buf(&cur, &buf_size, ", ");
203+ }
204+ add_to_buf(&cur, &buf_size, " d%d.value", i);
205+ }
206+ add_to_buf(&cur, &buf_size, " FROM ");
207+ for (i = 0; i < n_fields; ++i) {
208+ if (i != 0) {
209+ add_to_buf(&cur, &buf_size, ", ");
210+ }
211+ add_to_buf(&cur, &buf_size, "document_fields d%d", i);
212+ }
213+ add_to_buf(&cur, &buf_size, " WHERE d0.field_name = ?");
214+ for (i = 0; i < n_fields; ++i) {
215+ if (i != 0) {
216+ add_to_buf(&cur, &buf_size,
217+ " AND d0.doc_id = d%d.doc_id"
218+ " AND d%d.field_name = ?",
219+ i, i);
220+ }
221+ add_to_buf(&cur, &buf_size, " AND d%d.value NOT NULL", i);
222+ }
223+ add_to_buf(&cur, &buf_size, " GROUP BY ");
224+ for (i = 0; i < n_fields; ++i) {
225+ if (i != 0) {
226+ add_to_buf(&cur, &buf_size, ", ");
227+ }
228+ add_to_buf(&cur, &buf_size, "d%d.value", i);
229+ }
230+ if (status != U1DB_OK && *buf != NULL) {
231+ free(*buf);
232+ *buf = NULL;
233+ }
234+ return status;
235+}
236+
237 struct sqlcb_to_field_cb {
238 void *user_context;
239 int (*user_cb)(void *, const char*, transformation *tr);
240
241=== modified file 'u1db/__init__.py'
242--- u1db/__init__.py 2012-06-12 15:17:40 +0000
243+++ u1db/__init__.py 2012-06-13 11:19:23 +0000
244@@ -222,8 +222,7 @@
245 def get_index_keys(self, index_name):
246 """Return all keys under which documents are indexed in this index.
247
248- :return: [(key, frequency)] A list of indexed keys and the frequency
249- they occur.
250+ :return: [] A list of tuples of indexed keys.
251 :param index_name: The index to query
252 """
253 raise NotImplementedError(self.get_index_keys)
254
255=== modified file 'u1db/backends/inmemory.py'
256--- u1db/backends/inmemory.py 2012-06-05 21:17:09 +0000
257+++ u1db/backends/inmemory.py 2012-06-13 11:19:23 +0000
258@@ -250,7 +250,9 @@
259 index = self._indexes[index_name]
260 except KeyError:
261 raise errors.IndexDoesNotExist
262- return list(set(index.keys()))
263+ keys = index.keys()
264+ # XXX inefficiency warning
265+ return list(set([tuple(key.split('\x01')) for key in keys]))
266
267 def whats_changed(self, old_generation=0):
268 changes = []
269
270=== modified file 'u1db/backends/sqlite_backend.py'
271--- u1db/backends/sqlite_backend.py 2012-06-06 17:08:22 +0000
272+++ u1db/backends/sqlite_backend.py 2012-06-13 11:19:23 +0000
273@@ -34,12 +34,6 @@
274 vectorclock,
275 )
276
277-SQL_INDEX_KEYS = """
278- SELECT document_fields.value FROM index_definitions INNER JOIN
279- document_fields ON field = field_name WHERE index_definitions.name = ?
280- GROUP BY document_fields.value;
281- """
282-
283
284 class SQLiteDatabase(CommonBackend):
285 """A U1DB implementation that uses SQLite as its persistence layer."""
286@@ -734,18 +728,28 @@
287 res = c.fetchall()
288 return [self._factory(r[0], r[1], r[2]) for r in res]
289
290- def get_index_keys(self, index):
291+ def get_index_keys(self, index_name):
292 c = self._db_handle.cursor()
293+ definition = self._get_index_definition(index_name)
294+ value_fields = ', '.join([
295+ 'd%d.value' % i for i in range(len(definition))])
296+ tables = ["document_fields d%d" % i for i in range(len(definition))]
297+ novalue_where = [
298+ "d.doc_id = d%d.doc_id AND d%d.field_name = ?" % (i, i) for i in
299+ range(len(definition))]
300+ where = [
301+ novalue_where[i] + (" AND d%d.value NOT NULL" % (i,)) for i in
302+ range(len(definition))]
303+ statement = (
304+ "SELECT %s FROM document d, %s WHERE %s GROUP BY %s;" % (
305+ value_fields, ', '.join(tables), ' AND '.join(where),
306+ value_fields))
307 try:
308- c.execute(SQL_INDEX_KEYS, (index,))
309+ c.execute(statement, tuple(definition))
310 except dbapi2.OperationalError, e:
311 raise dbapi2.OperationalError(str(e) +
312- '\nstatement: %s\nargs: %s\n' % (SQL_INDEX_KEYS, (index,)))
313- res = c.fetchall()
314- if not res:
315- # raise IndexDoesNotExist if appropriate
316- self._get_index_definition(index)
317- return [r[0] for r in res]
318+ '\nstatement: %s\nargs: %s\n' % (statement, tuple(definition)))
319+ return c.fetchall()
320
321 def delete_index(self, index_name):
322 with self._db_handle:
323
324=== modified file 'u1db/commandline/client.py'
325--- u1db/commandline/client.py 2012-06-06 13:28:10 +0000
326+++ u1db/commandline/client.py 2012-06-13 11:19:23 +0000
327@@ -401,8 +401,9 @@
328 def run(self, database, index):
329 try:
330 db = self._open(database, create=False)
331- for i in db.get_index_keys(index):
332- self.stdout.write("%s\n" % (i.encode('utf-8'),))
333+ for key in db.get_index_keys(index):
334+ self.stdout.write("%s\n" % (", ".join(
335+ [i.encode('utf-8') for i in key],)))
336 except errors.DatabaseDoesNotExist:
337 self.stderr.write("Database does not exist.\n")
338 except errors.IndexDoesNotExist:
339
340=== modified file 'u1db/tests/c_backend_wrapper.pyx'
341--- u1db/tests/c_backend_wrapper.pyx 2012-06-12 15:17:40 +0000
342+++ u1db/tests/c_backend_wrapper.pyx 2012-06-13 11:19:23 +0000
343@@ -58,7 +58,8 @@
344
345 ctypedef char* const_char_ptr "const char*"
346 ctypedef int (*u1db_doc_callback)(void *context, u1db_document *doc)
347- ctypedef int (*u1db_key_callback)(void *context, char *key)
348+ ctypedef int (*u1db_key_callback)(void *context, int num_fields,
349+ const_char_ptr *key)
350 ctypedef int (*u1db_doc_gen_callback)(void *context,
351 u1db_document *doc, int gen)
352 ctypedef int (*u1db_trans_info_callback)(void *context,
353@@ -283,9 +284,14 @@
354 a_list.append(pydoc)
355 return 0
356
357-cdef int _append_key_to_list(void *context, const_char_ptr key) with gil:
358- a_list = <object>context
359- a_list.append(key)
360+cdef int _append_key_to_list(void *context, int num_fields,
361+ const_char_ptr *key) with gil:
362+ a_list = <object>(context)
363+ field_list = []
364+ for i from 0 <= i < num_fields:
365+ field = key[i]
366+ field_list.append(field.decode('utf-8'))
367+ a_list.append(tuple(field_list))
368 return 0
369
370 cdef _list_to_array(lst, const_char_ptr **res, int *count):
371@@ -1190,12 +1196,12 @@
372
373 def get_index_keys(self, index_name):
374 cdef int status
375- result = []
376+ keys = []
377 status = U1DB_OK
378 status = u1db_get_index_keys(
379- self._db, index_name, <void*>result, _append_key_to_list)
380+ self._db, index_name, <void*>keys, _append_key_to_list)
381 handle_status("get_index_keys", status)
382- return result
383+ return keys
384
385 def _query_init(self, index_name):
386 cdef CQuery query
387
388=== modified file 'u1db/tests/test_backends.py'
389--- u1db/tests/test_backends.py 2012-06-06 16:59:22 +0000
390+++ u1db/tests/test_backends.py 2012-06-13 11:19:23 +0000
391@@ -1300,7 +1300,23 @@
392 self.db.create_doc(content2)
393 self.db.create_doc(content3)
394 self.assertEqual(
395- ['value1', 'value2'],
396+ [('value1',), ('value2',)],
397+ sorted(self.db.get_index_keys('test-idx')))
398+
399+ def test_get_index_keys_from_multicolumn_index(self):
400+ self.db.create_index('test-idx', 'key1', 'key2')
401+ content1 = '{"key1": "value1", "key2": "val2-1"}'
402+ content2 = '{"key1": "value2", "key2": "val2-2"}'
403+ content3 = '{"key1": "value2", "key2": "val2-2"}'
404+ content4 = '{"key1": "value2", "key2": "val3"}'
405+ self.db.create_doc(content1)
406+ self.db.create_doc(content2)
407+ self.db.create_doc(content3)
408+ self.db.create_doc(content4)
409+ self.assertEqual([
410+ ('value1', 'val2-1'),
411+ ('value2', 'val2-2'),
412+ ('value2', 'val3')],
413 sorted(self.db.get_index_keys('test-idx')))
414
415
416
417=== modified file 'u1db/tests/test_c_backend.py'
418--- u1db/tests/test_c_backend.py 2012-06-12 13:03:18 +0000
419+++ u1db/tests/test_c_backend.py 2012-06-13 11:19:23 +0000
420@@ -199,7 +199,7 @@
421 self.db.create_doc(tests.simple_doc)
422 self.db.create_index("key-idx", "key")
423 keys = self.db.get_index_keys('key-idx')
424- self.assertEqual(["value"], keys)
425+ self.assertEqual([("value",)], keys)
426
427 def test__query_init_one_field(self):
428 self.db = c_backend_wrapper.CDatabase(':memory:')
429
430=== modified file 'u1todo/u1todo.py'
431--- u1todo/u1todo.py 2012-05-31 18:51:20 +0000
432+++ u1todo/u1todo.py 2012-06-13 11:19:23 +0000
433@@ -78,7 +78,7 @@
434
435 def get_all_tags(self):
436 """Get all tags in use in the entire database."""
437- return self.db.get_index_keys(TAGS_INDEX)
438+ return [key[0] for key in self.db.get_index_keys(TAGS_INDEX)]
439
440 def get_tasks_by_tags(self, tags):
441 """Get all tasks that have every tag in tags."""

Subscribers

People subscribed via source and target branches