Merge ~cjwatson/launchpad:sqlvalues-text into launchpad:master
- Git
- lp:~cjwatson/launchpad
- sqlvalues-text
- Merge into master
Status: | Merged |
---|---|
Approved by: | Colin Watson |
Approved revision: | e03e5221a279de4c68a3cdbada05d37cb0d2e8b6 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~cjwatson/launchpad:sqlvalues-text |
Merge into: | launchpad:master |
Prerequisite: | ~cjwatson/launchpad:abolish-quote-like |
Diff against target: |
723 lines (+180/-157) 17 files modified
lib/lp/bugs/model/structuralsubscription.py (+5/-5) lib/lp/oci/tests/test_ocirecipe.py (+7/-2) lib/lp/registry/model/distribution.py (+2/-4) lib/lp/registry/model/distroseries.py (+1/-1) lib/lp/registry/model/person.py (+2/-2) lib/lp/registry/model/pillar.py (+42/-31) lib/lp/registry/model/product.py (+9/-8) lib/lp/registry/model/productseries.py (+4/-6) lib/lp/registry/model/projectgroup.py (+17/-19) lib/lp/registry/tests/test_pillar.py (+12/-0) lib/lp/registry/vocabularies.py (+6/-7) lib/lp/services/database/sqlbase.py (+7/-2) lib/lp/services/database/stormexpr.py (+4/-3) lib/lp/services/identity/model/emailaddress.py (+10/-4) lib/lp/services/statistics/model/statistics.py (+0/-1) lib/lp/soyuz/model/archive.py (+16/-17) lib/lp/soyuz/scripts/gina/handlers.py (+36/-45) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Thiago F. Pappacena (community) | Approve | ||
Review via email: mp+400241@code.launchpad.net |
Commit message
Avoid quote() and sqlvalues() on arbitrary text
Description of the change
The `quote` and `sqlvalues` functions purported to do safe SQL quoting, but in fact they don't get it quite right: when a constructed query is executed, `psycopg2` expects to be able to do %-substitution of parameters, and so '%' characters in parameters must also be escaped. This can cause problems when substituting arbitrary user-supplied text into queries using these functions; I don't believe SQL injection is possible, but it can cause `IndexError` exceptions due to the parameter count being wrong. `ProjectGroupSe
A better approach is to avoid these functions where arbitrary text is involved, and instead use the style of passing parameters separately (either directly or via the Storm query compiler) so that `psycopg2` can deal with all the substitutions.
Ultimately we should remove all these old quoting functions entirely, but that's quite a bit more work.
Preview Diff
1 | diff --git a/lib/lp/bugs/model/structuralsubscription.py b/lib/lp/bugs/model/structuralsubscription.py |
2 | index 59955d3..9d69921 100644 |
3 | --- a/lib/lp/bugs/model/structuralsubscription.py |
4 | +++ b/lib/lp/bugs/model/structuralsubscription.py |
5 | @@ -19,6 +19,7 @@ import six |
6 | from storm.base import Storm |
7 | from storm.expr import ( |
8 | And, |
9 | + Cast, |
10 | Count, |
11 | In, |
12 | Join, |
13 | @@ -82,8 +83,8 @@ from lp.registry.interfaces.sourcepackage import ISourcePackage |
14 | from lp.registry.model.teammembership import TeamParticipation |
15 | from lp.services.database.constants import UTC_NOW |
16 | from lp.services.database.interfaces import IStore |
17 | -from lp.services.database.sqlbase import quote |
18 | from lp.services.database.stormexpr import ( |
19 | + Array, |
20 | ArrayAgg, |
21 | ArrayContains, |
22 | ArrayIntersects, |
23 | @@ -927,8 +928,7 @@ def _calculate_tag_query(conditions, tags): |
24 | # space as, effectively, NULL. This is safe because a |
25 | # single space is not an acceptable tag. Again, the |
26 | # clearest alternative is defining a custom Postgres aggregator. |
27 | - tags_array = "ARRAY[%s,' ']::TEXT[]" % ",".join( |
28 | - quote(tag) for tag in tags) |
29 | + tags_array = Cast(Array(tuple(tags) + (u" ",)), "text[]") |
30 | # Now let's build the select itself. |
31 | second_select = Select( |
32 | BugSubscriptionFilter.id, |
33 | @@ -944,7 +944,7 @@ def _calculate_tag_query(conditions, tags): |
34 | # The list of tags should be a superset of the filter tags to |
35 | # be included. |
36 | ArrayContains( |
37 | - SQL(tags_array), |
38 | + tags_array, |
39 | # This next line gives us an array of the tags that the |
40 | # filter wants to include. Notice that it includes the |
41 | # empty string when the condition does not match, per the |
42 | @@ -957,7 +957,7 @@ def _calculate_tag_query(conditions, tags): |
43 | # tags that the filter wants to exclude. |
44 | Not( |
45 | ArrayIntersects( |
46 | - SQL(tags_array), |
47 | + tags_array, |
48 | # This next line gives us an array of the tags |
49 | # that the filter wants to exclude. We do not bother |
50 | # with the empty string, and therefore allow NULLs |
51 | diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py |
52 | index 2879b37..34e19b4 100644 |
53 | --- a/lib/lp/oci/tests/test_ocirecipe.py |
54 | +++ b/lib/lp/oci/tests/test_ocirecipe.py |
55 | @@ -600,10 +600,14 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory): |
56 | for recipe in oci_proj1_recipes + oci_proj2_recipes: |
57 | self.assertFalse(recipe.official) |
58 | |
59 | + # Cache permissions. |
60 | + oci_project1.setOfficialRecipeStatus |
61 | + |
62 | # Set official for project1 and make sure nothing else got changed. |
63 | with StormStatementRecorder() as recorder: |
64 | oci_project1.setOfficialRecipeStatus(oci_proj1_recipes[0], True) |
65 | - self.assertEqual(1, recorder.count) |
66 | + Store.of(oci_project1).flush() |
67 | + self.assertEqual(1, recorder.count) |
68 | |
69 | self.assertTrue(oci_project2.getOfficialRecipes().is_empty()) |
70 | self.assertEqual( |
71 | @@ -615,7 +619,8 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory): |
72 | # Set back no recipe as official. |
73 | with StormStatementRecorder() as recorder: |
74 | oci_project1.setOfficialRecipeStatus(oci_proj1_recipes[0], False) |
75 | - self.assertEqual(0, recorder.count) |
76 | + Store.of(oci_project1).flush() |
77 | + self.assertEqual(1, recorder.count) |
78 | |
79 | for recipe in oci_proj1_recipes + oci_proj2_recipes: |
80 | self.assertFalse(recipe.official) |
81 | diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py |
82 | index 1000bea..26271b5 100644 |
83 | --- a/lib/lp/registry/model/distribution.py |
84 | +++ b/lib/lp/registry/model/distribution.py |
85 | @@ -874,10 +874,8 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements, |
86 | |
87 | def getMilestone(self, name): |
88 | """See `IDistribution`.""" |
89 | - return Milestone.selectOne(""" |
90 | - distribution = %s AND |
91 | - name = %s |
92 | - """ % sqlvalues(self.id, name)) |
93 | + return Store.of(self).find( |
94 | + Milestone, distribution=self, name=name).one() |
95 | |
96 | def getOCIProject(self, name): |
97 | oci_project = getUtility(IOCIProjectSet).getByPillarAndName( |
98 | diff --git a/lib/lp/registry/model/distroseries.py b/lib/lp/registry/model/distroseries.py |
99 | index 3242c33..0ceff83 100644 |
100 | --- a/lib/lp/registry/model/distroseries.py |
101 | +++ b/lib/lp/registry/model/distroseries.py |
102 | @@ -1180,7 +1180,7 @@ class DistroSeries(SQLBase, BugTargetBase, HasSpecificationsMixin, |
103 | find_spec = ( |
104 | DistroSeriesPackageCache, |
105 | BinaryPackageName, |
106 | - SQL('ts_rank(fti, ftq(%s)) AS rank' % sqlvalues(text))) |
107 | + SQL('ts_rank(fti, ftq(?)) AS rank', params=(text,))) |
108 | origin = [ |
109 | DistroSeriesPackageCache, |
110 | Join( |
111 | diff --git a/lib/lp/registry/model/person.py b/lib/lp/registry/model/person.py |
112 | index 69ea961..6785698 100644 |
113 | --- a/lib/lp/registry/model/person.py |
114 | +++ b/lib/lp/registry/model/person.py |
115 | @@ -3243,8 +3243,8 @@ class PersonSet: |
116 | user_id = user.id |
117 | cur = cursor() |
118 | cur.execute( |
119 | - "SELECT is_blacklisted_name(%(name)s, %(user_id)s)" % sqlvalues( |
120 | - name=name, user_id=user_id)) |
121 | + "SELECT is_blacklisted_name(%(name)s, %(user_id)s)", |
122 | + {"name": name, "user_id": user_id}) |
123 | return bool(cur.fetchone()[0]) |
124 | |
125 | def getTopContributors(self, limit=50): |
126 | diff --git a/lib/lp/registry/model/pillar.py b/lib/lp/registry/model/pillar.py |
127 | index e138952..c591925 100644 |
128 | --- a/lib/lp/registry/model/pillar.py |
129 | +++ b/lib/lp/registry/model/pillar.py |
130 | @@ -17,10 +17,14 @@ from sqlobject import ( |
131 | ForeignKey, |
132 | StringCol, |
133 | ) |
134 | +from storm.databases.postgres import Case |
135 | from storm.expr import ( |
136 | And, |
137 | + Coalesce, |
138 | + Desc, |
139 | LeftJoin, |
140 | - SQL, |
141 | + Lower, |
142 | + Or, |
143 | ) |
144 | from storm.info import ClassAlias |
145 | from storm.store import Store |
146 | @@ -51,9 +55,10 @@ from lp.services.config import config |
147 | from lp.services.database.bulk import load_related |
148 | from lp.services.database.decoratedresultset import DecoratedResultSet |
149 | from lp.services.database.interfaces import IStore |
150 | -from lp.services.database.sqlbase import ( |
151 | - SQLBase, |
152 | - sqlvalues, |
153 | +from lp.services.database.sqlbase import SQLBase |
154 | +from lp.services.database.stormexpr import ( |
155 | + fti_search, |
156 | + rank_by_fti, |
157 | ) |
158 | from lp.services.librarian.model import LibraryFileAlias |
159 | |
160 | @@ -172,20 +177,16 @@ class PillarNameSet: |
161 | LeftJoin( |
162 | Distribution, PillarName.distribution == Distribution.id), |
163 | ] |
164 | - conditions = SQL(''' |
165 | - PillarName.active = TRUE |
166 | - AND (PillarName.name = lower(%(text)s) OR |
167 | - |
168 | - Product.fti @@ ftq(%(text)s) OR |
169 | - lower(Product.title) = lower(%(text)s) OR |
170 | - |
171 | - Project.fti @@ ftq(%(text)s) OR |
172 | - lower(Project.title) = lower(%(text)s) OR |
173 | - |
174 | - Distribution.fti @@ ftq(%(text)s) OR |
175 | - lower(Distribution.title) = lower(%(text)s) |
176 | - ) |
177 | - ''' % sqlvalues(text=text)) |
178 | + conditions = And( |
179 | + PillarName.active, |
180 | + Or( |
181 | + PillarName.name == Lower(text), |
182 | + fti_search(Product, text), |
183 | + Lower(Product._title) == Lower(text), |
184 | + fti_search(ProjectGroup, text), |
185 | + Lower(ProjectGroup._title) == Lower(text), |
186 | + fti_search(Distribution, text), |
187 | + Lower(Distribution._title) == Lower(text))) |
188 | columns = [ |
189 | PillarName, OtherPillarName, Product, ProjectGroup, Distribution] |
190 | return IStore(PillarName).using(*origin).find( |
191 | @@ -193,14 +194,21 @@ class PillarNameSet: |
192 | And(conditions, ProductSet.getProductPrivacyFilter(user))) |
193 | |
194 | def count_search_matches(self, user, text): |
195 | + text = six.ensure_text(text) |
196 | result = self.build_search_query(user, text) |
197 | return result.count() |
198 | |
199 | def search(self, user, text, limit): |
200 | """See `IPillarSet`.""" |
201 | - # Avoid circular import. |
202 | - from lp.registry.model.product import get_precached_products |
203 | + # Avoid circular imports. |
204 | + from lp.registry.model.distribution import Distribution |
205 | + from lp.registry.model.product import ( |
206 | + get_precached_products, |
207 | + Product, |
208 | + ) |
209 | + from lp.registry.model.projectgroup import ProjectGroup |
210 | |
211 | + text = six.ensure_text(text) |
212 | if limit is None: |
213 | limit = config.launchpad.default_batch_size |
214 | |
215 | @@ -216,17 +224,20 @@ class PillarNameSet: |
216 | # of either the Product, Project, or Distribution tables, |
217 | # so the coalesce() is necessary to find the ts_rank() which |
218 | # is not null. |
219 | - result.order_by(SQL(''' |
220 | - (CASE WHEN PillarName.name = lower(%(text)s) |
221 | - OR lower(Product.title) = lower(%(text)s) |
222 | - OR lower(Project.title) = lower(%(text)s) |
223 | - OR lower(Distribution.title) = lower(%(text)s) |
224 | - THEN 9999999 |
225 | - ELSE coalesce(ts_rank(Product.fti, ftq(%(text)s)), |
226 | - ts_rank(Project.fti, ftq(%(text)s)), |
227 | - ts_rank(Distribution.fti, ftq(%(text)s))) |
228 | - END) DESC, PillarName.name |
229 | - ''' % sqlvalues(text=text))) |
230 | + result.order_by( |
231 | + Desc(Case( |
232 | + cases=( |
233 | + (Or( |
234 | + PillarName.name == Lower(text), |
235 | + Lower(Product._title) == Lower(text), |
236 | + Lower(ProjectGroup._title) == Lower(text), |
237 | + Lower(Distribution._title) == Lower(text)), |
238 | + 9999999),), |
239 | + default=Coalesce( |
240 | + rank_by_fti(Product, text, desc=False), |
241 | + rank_by_fti(ProjectGroup, text, desc=False), |
242 | + rank_by_fti(Distribution, text, desc=False)))), |
243 | + PillarName.name) |
244 | # People shouldn't be calling this method with too big limits |
245 | longest_expected = 2 * config.launchpad.default_batch_size |
246 | if limit > longest_expected: |
247 | diff --git a/lib/lp/registry/model/product.py b/lib/lp/registry/model/product.py |
248 | index 5ab5d1b..441e95f 100644 |
249 | --- a/lib/lp/registry/model/product.py |
250 | +++ b/lib/lp/registry/model/product.py |
251 | @@ -33,8 +33,10 @@ from storm.expr import ( |
252 | Coalesce, |
253 | Desc, |
254 | Exists, |
255 | + Func, |
256 | Join, |
257 | LeftJoin, |
258 | + Lower, |
259 | Not, |
260 | Or, |
261 | Select, |
262 | @@ -1171,8 +1173,7 @@ class Product(SQLBase, BugTargetBase, MakesAnnouncements, |
263 | |
264 | def getMilestone(self, name): |
265 | """See `IProduct`.""" |
266 | - return Milestone.selectOne(""" |
267 | - product = %s AND name = %s""" % sqlvalues(self.id, name)) |
268 | + return IStore(Milestone).find(Milestone, product=self, name=name).one() |
269 | |
270 | def getBugSummaryContextWhereClause(self): |
271 | """See BugTargetBase.""" |
272 | @@ -1859,12 +1860,12 @@ class ProductSet: |
273 | conditions.append(Product.active == active) |
274 | |
275 | if search_text is not None and search_text.strip() != '': |
276 | - conditions.append(SQL(''' |
277 | - Product.fti @@ ftq(%(text)s) OR |
278 | - Product.name = %(text)s OR |
279 | - strpos(lower(Product.license_info), %(text)s) > 0 OR |
280 | - strpos(lower(Product.reviewer_whiteboard), %(text)s) > 0 |
281 | - ''' % sqlvalues(text=search_text.lower()))) |
282 | + text = search_text.lower() |
283 | + conditions.append(Or( |
284 | + fti_search(Product, text), |
285 | + Product.name == text, |
286 | + Func("strpos", Lower(Product.license_info), text) > 0, |
287 | + Func("strpos", Lower(Product.reviewer_whiteboard), text) > 0)) |
288 | |
289 | def dateToDatetime(date): |
290 | """Convert a datetime.date to a datetime.datetime |
291 | diff --git a/lib/lp/registry/model/productseries.py b/lib/lp/registry/model/productseries.py |
292 | index 3459214..82904cf 100644 |
293 | --- a/lib/lp/registry/model/productseries.py |
294 | +++ b/lib/lp/registry/model/productseries.py |
295 | @@ -72,10 +72,8 @@ from lp.services.database.constants import UTC_NOW |
296 | from lp.services.database.datetimecol import UtcDateTimeCol |
297 | from lp.services.database.decoratedresultset import DecoratedResultSet |
298 | from lp.services.database.enumcol import EnumCol |
299 | -from lp.services.database.sqlbase import ( |
300 | - SQLBase, |
301 | - sqlvalues, |
302 | - ) |
303 | +from lp.services.database.interfaces import IStore |
304 | +from lp.services.database.sqlbase import SQLBase |
305 | from lp.services.propertycache import cachedproperty |
306 | from lp.services.webapp.publisher import canonical_url |
307 | from lp.services.webapp.sorting import sorted_dotted_numbers |
308 | @@ -274,8 +272,8 @@ class ProductSeries(SQLBase, BugTargetBase, HasMilestonesMixin, |
309 | |
310 | def getPOTemplate(self, name): |
311 | """See IProductSeries.""" |
312 | - return POTemplate.selectOne( |
313 | - "productseries = %s AND name = %s" % sqlvalues(self.id, name)) |
314 | + return IStore(POTemplate).find( |
315 | + POTemplate, productseries=self, name=name).one() |
316 | |
317 | @property |
318 | def title(self): |
319 | diff --git a/lib/lp/registry/model/projectgroup.py b/lib/lp/registry/model/projectgroup.py |
320 | index cfeda26..b56499a 100644 |
321 | --- a/lib/lp/registry/model/projectgroup.py |
322 | +++ b/lib/lp/registry/model/projectgroup.py |
323 | @@ -10,6 +10,7 @@ __all__ = [ |
324 | 'ProjectGroupSet', |
325 | ] |
326 | |
327 | +import six |
328 | from sqlobject import ( |
329 | AND, |
330 | BoolCol, |
331 | @@ -95,10 +96,12 @@ from lp.registry.model.productseries import ProductSeries |
332 | from lp.services.database.constants import UTC_NOW |
333 | from lp.services.database.datetimecol import UtcDateTimeCol |
334 | from lp.services.database.enumcol import EnumCol |
335 | +from lp.services.database.interfaces import IStore |
336 | from lp.services.database.sqlbase import ( |
337 | SQLBase, |
338 | sqlvalues, |
339 | ) |
340 | +from lp.services.database.stormexpr import fti_search |
341 | from lp.services.helpers import shortlist |
342 | from lp.services.propertycache import cachedproperty |
343 | from lp.services.webapp.authorization import check_permission |
344 | @@ -592,32 +595,27 @@ class ProjectGroupSet: |
345 | should be limited to projects that are active in those Launchpad |
346 | applications. |
347 | """ |
348 | - if text: |
349 | - text = text.replace("%", "%%") |
350 | - clauseTables = set() |
351 | - clauseTables.add('Project') |
352 | - queries = [] |
353 | + joining_product = False |
354 | + clauses = [] |
355 | |
356 | if text: |
357 | + text = six.ensure_text(text) |
358 | if search_products: |
359 | - clauseTables.add('Product') |
360 | - product_query = "Product.fti @@ ftq(%s)" % sqlvalues(text) |
361 | - queries.append(product_query) |
362 | + joining_product = True |
363 | + clauses.extend([ |
364 | + Product.projectgroup == ProjectGroup.id, |
365 | + fti_search(Product, text), |
366 | + ]) |
367 | else: |
368 | - projectgroup_query = "Project.fti @@ ftq(%s)" % sqlvalues(text) |
369 | - queries.append(projectgroup_query) |
370 | - |
371 | - if 'Product' in clauseTables: |
372 | - queries.append('Product.project=Project.id') |
373 | + clauses.append(fti_search(ProjectGroup, text)) |
374 | |
375 | if not show_inactive: |
376 | - queries.append('Project.active IS TRUE') |
377 | - if 'Product' in clauseTables: |
378 | - queries.append('Product.active IS TRUE') |
379 | + clauses.append(ProjectGroup.active) |
380 | + if joining_product: |
381 | + clauses.append(Product.active) |
382 | |
383 | - query = " AND ".join(queries) |
384 | - return ProjectGroup.select( |
385 | - query, distinct=True, clauseTables=clauseTables) |
386 | + return IStore(ProjectGroup).find( |
387 | + ProjectGroup, *clauses).config(distinct=True) |
388 | |
389 | |
390 | @implementer(IProjectGroupSeries) |
391 | diff --git a/lib/lp/registry/tests/test_pillar.py b/lib/lp/registry/tests/test_pillar.py |
392 | index 10a259e..0ff1db2 100644 |
393 | --- a/lib/lp/registry/tests/test_pillar.py |
394 | +++ b/lib/lp/registry/tests/test_pillar.py |
395 | @@ -42,6 +42,18 @@ class TestPillarNameSet(TestCaseWithFactory): |
396 | getUtility(IPersonSet).getByName('mark'), 'lz', limit=5)] |
397 | self.assertEqual(result_names, [u'launchzap', u'lz-bar', u'lz-foo']) |
398 | |
399 | + def test_search_percent(self): |
400 | + """Searches involving '%' characters work correctly.""" |
401 | + login('mark@example.com') |
402 | + self.factory.makeProduct(name='percent', title='contains % character') |
403 | + self.factory.makeProduct() |
404 | + pillar_set = getUtility(IPillarNameSet) |
405 | + mark = getUtility(IPersonSet).getByName('mark') |
406 | + result_names = [ |
407 | + pillar.name |
408 | + for pillar in pillar_set.search(mark, '% character', limit=5)] |
409 | + self.assertEqual([u'percent'], result_names) |
410 | + |
411 | |
412 | class TestPillarPerson(TestCaseWithFactory): |
413 | |
414 | diff --git a/lib/lp/registry/vocabularies.py b/lib/lp/registry/vocabularies.py |
415 | index a38082b..02e3158 100644 |
416 | --- a/lib/lp/registry/vocabularies.py |
417 | +++ b/lib/lp/registry/vocabularies.py |
418 | @@ -1517,7 +1517,7 @@ class DistributionVocabulary(NamedSQLObjectVocabulary): |
419 | |
420 | def getTermByToken(self, token): |
421 | """See `IVocabularyTokenized`.""" |
422 | - obj = Distribution.selectOne("name=%s" % sqlvalues(token)) |
423 | + obj = IStore(Distribution).find(Distribution, name=token).one() |
424 | if obj is None: |
425 | raise LookupError(token) |
426 | else: |
427 | @@ -1565,12 +1565,11 @@ class DistroSeriesVocabulary(NamedSQLObjectVocabulary): |
428 | except ValueError: |
429 | raise LookupError(token) |
430 | |
431 | - obj = DistroSeries.selectOne(''' |
432 | - Distribution.id = DistroSeries.distribution AND |
433 | - Distribution.name = %s AND |
434 | - DistroSeries.name = %s |
435 | - ''' % sqlvalues(distroname, distroseriesname), |
436 | - clauseTables=['Distribution']) |
437 | + obj = IStore(DistroSeries).find( |
438 | + DistroSeries, |
439 | + DistroSeries.distribution == Distribution.id, |
440 | + Distribution.name == distroname, |
441 | + DistroSeries.name == distroseriesname).one() |
442 | if obj is None: |
443 | raise LookupError(token) |
444 | else: |
445 | diff --git a/lib/lp/services/database/sqlbase.py b/lib/lp/services/database/sqlbase.py |
446 | index 0476df1..9d53175 100644 |
447 | --- a/lib/lp/services/database/sqlbase.py |
448 | +++ b/lib/lp/services/database/sqlbase.py |
449 | @@ -368,8 +368,8 @@ def quote(x): |
450 | def sqlvalues(*values, **kwvalues): |
451 | """Return a tuple of converted sql values for each value in some_tuple. |
452 | |
453 | - This safely quotes strings, or gives representations of dbschema items, |
454 | - for example. |
455 | + This safely quotes strings (except for '%'!), or gives representations |
456 | + of dbschema items, for example. |
457 | |
458 | Use it when constructing a string for use in a SELECT. Always use |
459 | %s as the replacement marker. |
460 | @@ -377,6 +377,11 @@ def sqlvalues(*values, **kwvalues): |
461 | ('SELECT foo from Foo where bar = %s and baz = %s' |
462 | % sqlvalues(BugTaskSeverity.CRITICAL, 'foo')) |
463 | |
464 | + This is DEPRECATED in favour of passing parameters to SQL statements |
465 | + using the second parameter to `cursor.execute` (normally via the Storm |
466 | + query compiler), because it does not deal with escaping '%' characters |
467 | + in strings. |
468 | + |
469 | >>> sqlvalues() |
470 | Traceback (most recent call last): |
471 | ... |
472 | diff --git a/lib/lp/services/database/stormexpr.py b/lib/lp/services/database/stormexpr.py |
473 | index 37772f1..648bec2 100644 |
474 | --- a/lib/lp/services/database/stormexpr.py |
475 | +++ b/lib/lp/services/database/stormexpr.py |
476 | @@ -329,8 +329,9 @@ def fti_search(table, text, ftq=True): |
477 | tables=(table,)) |
478 | |
479 | |
480 | -def rank_by_fti(table, text, ftq=True): |
481 | +def rank_by_fti(table, text, ftq=True, desc=True): |
482 | table, query_fragment = determine_table_and_fragment(table, ftq) |
483 | return SQL( |
484 | - '-ts_rank(%s.fti, %s)' % (table.name, query_fragment), params=(text,), |
485 | - tables=(table,)) |
486 | + '%sts_rank(%s.fti, %s)' % ( |
487 | + '-' if desc else '', table.name, query_fragment), |
488 | + params=(text,), tables=(table,)) |
489 | diff --git a/lib/lp/services/identity/model/emailaddress.py b/lib/lp/services/identity/model/emailaddress.py |
490 | index a866a0a..c9c9a4e 100644 |
491 | --- a/lib/lp/services/identity/model/emailaddress.py |
492 | +++ b/lib/lp/services/identity/model/emailaddress.py |
493 | @@ -13,17 +13,21 @@ __all__ = [ |
494 | import hashlib |
495 | import operator |
496 | |
497 | +import six |
498 | from sqlobject import ( |
499 | ForeignKey, |
500 | StringCol, |
501 | ) |
502 | +from storm.expr import Lower |
503 | from zope.interface import implementer |
504 | |
505 | from lp.app.validators.email import valid_email |
506 | from lp.services.database.enumcol import EnumCol |
507 | -from lp.services.database.interfaces import IMasterStore |
508 | +from lp.services.database.interfaces import ( |
509 | + IMasterStore, |
510 | + IStore, |
511 | + ) |
512 | from lp.services.database.sqlbase import ( |
513 | - quote, |
514 | SQLBase, |
515 | sqlvalues, |
516 | ) |
517 | @@ -111,8 +115,10 @@ class EmailAddressSet: |
518 | |
519 | def getByEmail(self, email): |
520 | """See `IEmailAddressSet`.""" |
521 | - return EmailAddress.selectOne( |
522 | - "lower(email) = %s" % quote(email.strip().lower())) |
523 | + return IStore(EmailAddress).find( |
524 | + EmailAddress, |
525 | + Lower(EmailAddress.email) == |
526 | + six.ensure_text(email).strip().lower()).one() |
527 | |
528 | def new(self, email, person=None, status=EmailAddressStatus.NEW): |
529 | """See IEmailAddressSet.""" |
530 | diff --git a/lib/lp/services/statistics/model/statistics.py b/lib/lp/services/statistics/model/statistics.py |
531 | index 75487be..e7bce2d 100644 |
532 | --- a/lib/lp/services/statistics/model/statistics.py |
533 | +++ b/lib/lp/services/statistics/model/statistics.py |
534 | @@ -33,7 +33,6 @@ from lp.services.database.interfaces import IStore |
535 | from lp.services.database.sqlbase import ( |
536 | cursor, |
537 | SQLBase, |
538 | - sqlvalues, |
539 | ) |
540 | from lp.services.statistics.interfaces.statistic import ( |
541 | ILaunchpadStatistic, |
542 | diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py |
543 | index df275c1..9696b8f 100644 |
544 | --- a/lib/lp/soyuz/model/archive.py |
545 | +++ b/lib/lp/soyuz/model/archive.py |
546 | @@ -2593,16 +2593,16 @@ class ArchiveSet: |
547 | def getPPAByDistributionAndOwnerName(self, distribution, person_name, |
548 | ppa_name): |
549 | """See `IArchiveSet`""" |
550 | - query = """ |
551 | - Archive.purpose = %s AND |
552 | - Archive.distribution = %s AND |
553 | - Person.id = Archive.owner AND |
554 | - Archive.name = %s AND |
555 | - Person.name = %s |
556 | - """ % sqlvalues( |
557 | - ArchivePurpose.PPA, distribution, ppa_name, person_name) |
558 | + # Circular import. |
559 | + from lp.registry.model.person import Person |
560 | |
561 | - return Archive.selectOne(query, clauseTables=['Person']) |
562 | + return IStore(Archive).find( |
563 | + Archive, |
564 | + Archive.purpose == ArchivePurpose.PPA, |
565 | + Archive.distribution == distribution, |
566 | + Archive.owner == Person.id, |
567 | + Archive.name == ppa_name, |
568 | + Person.name == person_name).one() |
569 | |
570 | def _getDefaultArchiveNameByPurpose(self, purpose): |
571 | """Return the default for a archive in a given purpose. |
572 | @@ -2641,11 +2641,11 @@ class ArchiveSet: |
573 | |
574 | def getByDistroAndName(self, distribution, name): |
575 | """See `IArchiveSet`.""" |
576 | - return Archive.selectOne(""" |
577 | - Archive.distribution = %s AND |
578 | - Archive.name = %s AND |
579 | - Archive.purpose != %s |
580 | - """ % sqlvalues(distribution, name, ArchivePurpose.PPA)) |
581 | + return IStore(Archive).find( |
582 | + Archive, |
583 | + Archive.distribution == distribution, |
584 | + Archive.name == name, |
585 | + Archive.purpose != ArchivePurpose.PPA).one() |
586 | |
587 | def _getDefaultDisplayname(self, name, owner, distribution, purpose): |
588 | """See `IArchive`.""" |
589 | @@ -2697,9 +2697,8 @@ class ArchiveSet: |
590 | # For non-PPA archives we enforce unique names within the context of a |
591 | # distribution. |
592 | if purpose != ArchivePurpose.PPA: |
593 | - archive = Archive.selectOne( |
594 | - "Archive.distribution = %s AND Archive.name = %s" % |
595 | - sqlvalues(distribution, name)) |
596 | + archive = IStore(Archive).find( |
597 | + Archive, distribution=distribution, name=name).one() |
598 | if archive is not None: |
599 | raise AssertionError( |
600 | "archive '%s' exists already in '%s'." % |
601 | diff --git a/lib/lp/soyuz/scripts/gina/handlers.py b/lib/lp/soyuz/scripts/gina/handlers.py |
602 | index 94aac78..432efcf 100644 |
603 | --- a/lib/lp/soyuz/scripts/gina/handlers.py |
604 | +++ b/lib/lp/soyuz/scripts/gina/handlers.py |
605 | @@ -29,6 +29,11 @@ from sqlobject import ( |
606 | SQLObjectMoreThanOneResultError, |
607 | SQLObjectNotFound, |
608 | ) |
609 | +from storm.exceptions import NotOneError |
610 | +from storm.expr import ( |
611 | + Cast, |
612 | + Desc, |
613 | + ) |
614 | from zope.component import getUtility |
615 | |
616 | from lp.archivepublisher.diskpool import poolify |
617 | @@ -46,10 +51,7 @@ from lp.registry.model.distroseries import DistroSeries |
618 | from lp.registry.model.sourcepackagename import SourcePackageName |
619 | from lp.services.database.constants import UTC_NOW |
620 | from lp.services.database.interfaces import IStore |
621 | -from lp.services.database.sqlbase import ( |
622 | - quote, |
623 | - sqlvalues, |
624 | - ) |
625 | +from lp.services.database.sqlbase import quote |
626 | from lp.services.librarian.interfaces import ILibraryFileAliasSet |
627 | from lp.services.scripts import log |
628 | from lp.soyuz.enums import ( |
629 | @@ -561,26 +563,19 @@ class SourcePackageHandler: |
630 | |
631 | # Check here to see if this release has ever been published in |
632 | # the distribution, no matter what status. |
633 | - query = """ |
634 | - SourcePackageRelease.sourcepackagename = %s AND |
635 | - SourcePackageRelease.version::text = %s AND |
636 | - SourcePackagePublishingHistory.sourcepackagerelease = |
637 | - SourcePackageRelease.id AND |
638 | - SourcePackagePublishingHistory.distroseries = |
639 | - DistroSeries.id AND |
640 | - SourcePackagePublishingHistory.archive = %s AND |
641 | - SourcePackagePublishingHistory.sourcepackagename = %s AND |
642 | - DistroSeries.distribution = %s |
643 | - """ % sqlvalues(sourcepackagename, version, |
644 | - distroseries.main_archive, |
645 | - sourcepackagename, |
646 | - distroseries.distribution) |
647 | - ret = SourcePackageRelease.select(query, |
648 | - clauseTables=['SourcePackagePublishingHistory', 'DistroSeries'], |
649 | - orderBy=["-SourcePackagePublishingHistory.datecreated"]) |
650 | - if not ret: |
651 | - return None |
652 | - return ret[0] |
653 | + SPR = SourcePackageRelease |
654 | + SPPH = SourcePackagePublishingHistory |
655 | + rows = IStore(SPR).find( |
656 | + SPR, |
657 | + SPR.sourcepackagename == sourcepackagename, |
658 | + Cast(SPR.version, "text") == version, |
659 | + SPPH.sourcepackagerelease == SPR.id, |
660 | + SPPH.distroseries == DistroSeries.id, |
661 | + SPPH.archive == distroseries.main_archive, |
662 | + SPPH.sourcepackagename == sourcepackagename, |
663 | + DistroSeries.distribution == distroseries.distribution) |
664 | + return rows.order_by( |
665 | + Desc(SourcePackagePublishingHistory.datecreated)).first() |
666 | |
667 | def createSourcePackageRelease(self, src, distroseries): |
668 | """Create a SourcePackagerelease and db dependencies if needed. |
669 | @@ -749,37 +744,33 @@ class BinaryPackageHandler: |
670 | version = binarypackagedata.version |
671 | architecture = binarypackagedata.architecture |
672 | |
673 | - clauseTables = ["BinaryPackageRelease", "DistroSeries", |
674 | - "DistroArchSeries", "BinaryPackageBuild", |
675 | - "BinaryPackagePublishingHistory"] |
676 | distroseries = distroarchseries.distroseries |
677 | |
678 | # When looking for binaries, we need to remember that they are |
679 | # shared between distribution releases, so match on the |
680 | # distribution and the architecture tag of the distroarchseries |
681 | # they were built for |
682 | - query = ( |
683 | - "BinaryPackagePublishingHistory.archive = %s AND " |
684 | - "BinaryPackagePublishingHistory.binarypackagename = %s AND " |
685 | - "BinaryPackageRelease.id =" |
686 | - " BinaryPackagePublishingHistory.binarypackagerelease AND " |
687 | - "BinaryPackageRelease.binarypackagename=%s AND " |
688 | - "BinaryPackageRelease.version::text = %s AND " |
689 | - "BinaryPackageRelease.build = BinaryPackageBuild.id AND " |
690 | - "BinaryPackageBuild.distro_arch_series = DistroArchSeries.id AND " |
691 | - "DistroArchSeries.distroseries = DistroSeries.id AND " |
692 | - "DistroSeries.distribution = %s" % |
693 | - sqlvalues(distroseries.main_archive, binaryname, binaryname, |
694 | - version, distroseries.distribution)) |
695 | + BPR = BinaryPackageRelease |
696 | + BPPH = BinaryPackagePublishingHistory |
697 | + BPB = BinaryPackageBuild |
698 | + clauses = [ |
699 | + BPPH.archive == distroseries.main_archive, |
700 | + BPPH.binarypackagename == binaryname, |
701 | + BPPH.binarypackagerelease == BPR.id, |
702 | + BPR.binarypackagename == binaryname, |
703 | + Cast(BPR.version, "text") == version, |
704 | + BPR.build == BPB.id, |
705 | + BPB.distro_arch_series == DistroArchSeries.id, |
706 | + DistroArchSeries.distroseries == DistroSeries.id, |
707 | + DistroSeries.distribution == distroseries.distribution, |
708 | + ] |
709 | |
710 | if architecture != "all": |
711 | - query += ("AND DistroArchSeries.architecturetag = %s" % |
712 | - quote(architecture)) |
713 | + clauses.append(DistroArchSeries.architecturetag == architecture) |
714 | |
715 | try: |
716 | - bpr = BinaryPackageRelease.selectOne( |
717 | - query, clauseTables=clauseTables, distinct=True) |
718 | - except SQLObjectMoreThanOneResultError: |
719 | + bpr = IStore(BPR).find(BPR, *clauses).config(distinct=True).one() |
720 | + except NotOneError: |
721 | # XXX kiko 2005-10-27: Untested |
722 | raise MultiplePackageReleaseError("Found more than one " |
723 | "entry for %s (%s) for %s in %s" % |
LGTM