Skip to content

Commit 8701dfc

Browse files
committed
Add an option to dynamically get the view sql
1 parent 642971e commit 8701dfc

File tree

5 files changed

+75
-15
lines changed

5 files changed

+75
-15
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,30 @@ class PreferredCustomer(pg.View):
261261
managed = False
262262
```
263263

264+
### Dynamic View SQL
265+
266+
If you need a dynamic view SQL (for example if it needs a value from settings in it), you can override the `run_sql`
267+
classmethod on the view to return the SQL. The method should return a namedtuple `ViewSQL`, which contains the query
268+
and potentially the params to `cursor.execute` call. Params should be either None or a list of parameters for the query.
269+
270+
```python
271+
from django.conf import settings
272+
from django_pgviews import view as pg
273+
274+
275+
class PreferredCustomer(pg.View):
276+
@classmethod
277+
def get_sql(cls):
278+
return pg.ViewSQL(
279+
"""SELECT * FROM myapp_customer WHERE is_preferred = TRUE and created_at >= %s;""",
280+
[settings.MIN_PREFERRED_CUSTOMER_CREATED_AT]
281+
)
282+
283+
class Meta:
284+
db_table = 'preferredcustomer'
285+
managed = False
286+
```
287+
264288
### Sync Listeners
265289

266290
django-pgviews 0.5.0 adds the ability to listen to when a `post_sync` event has

django_pgviews/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def run_backlog(self, models, force, update):
5151
status = create_view(
5252
connection,
5353
view_cls._meta.db_table,
54-
view_cls.sql,
54+
view_cls.get_sql(),
5555
update=update,
5656
force=force,
5757
materialized=isinstance(view_cls(), MaterializedView),

django_pgviews/view.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
FIELD_SPEC_REGEX = r"^([A-Za-z_][A-Za-z0-9_]*)\." r"([A-Za-z_][A-Za-z0-9_]*)\." r"(\*|(?:[A-Za-z_][A-Za-z0-9_]*))$"
1919
FIELD_SPEC_RE = re.compile(FIELD_SPEC_REGEX)
2020

21+
ViewSQL = collections.namedtuple("ViewSQL", "query,params")
22+
2123
log = logging.getLogger("django_pgviews.view")
2224

2325

@@ -62,7 +64,7 @@ def realize_deferred_projections(sender, *args, **kwargs):
6264

6365

6466
@transaction.atomic()
65-
def create_view(connection, view_name, view_query, update=True, force=False, materialized=False, index=None):
67+
def create_view(connection, view_name, view_query: ViewSQL, update=True, force=False, materialized=False, index=None):
6668
"""
6769
Create a named view on a connection.
6870
@@ -98,27 +100,32 @@ def create_view(connection, view_name, view_query, update=True, force=False, mat
98100
cursor.execute("CREATE TEMPORARY VIEW check_conflict AS SELECT * FROM {0};".format(view_name))
99101
try:
100102
with transaction.atomic():
101-
cursor.execute("CREATE OR REPLACE TEMPORARY VIEW check_conflict AS {0};".format(view_query))
103+
cursor.execute(
104+
"CREATE OR REPLACE TEMPORARY VIEW check_conflict AS {0};".format(view_query.query),
105+
view_query.params,
106+
)
102107
except psycopg2.ProgrammingError:
103108
force_required = True
104109
finally:
105110
cursor.execute("DROP VIEW IF EXISTS check_conflict;")
106111

107112
if materialized:
108113
cursor.execute("DROP MATERIALIZED VIEW IF EXISTS {0} CASCADE;".format(view_name))
109-
cursor.execute("CREATE MATERIALIZED VIEW {0} AS {1};".format(view_name, view_query))
114+
cursor.execute(
115+
"CREATE MATERIALIZED VIEW {0} AS {1};".format(view_name, view_query.query), view_query.params
116+
)
110117
if index is not None:
111118
index_sub_name = "_".join([s.strip() for s in index.split(",")])
112119
cursor.execute(
113120
"CREATE UNIQUE INDEX {0}_{1}_index ON {0} ({2})".format(view_name, index_sub_name, index)
114121
)
115122
ret = view_exists and "UPDATED" or "CREATED"
116123
elif not force_required:
117-
cursor.execute("CREATE OR REPLACE VIEW {0} AS {1};".format(view_name, view_query))
124+
cursor.execute("CREATE OR REPLACE VIEW {0} AS {1};".format(view_name, view_query.query), view_query.params)
118125
ret = view_exists and "UPDATED" or "CREATED"
119126
elif force:
120127
cursor.execute("DROP VIEW IF EXISTS {0} CASCADE;".format(view_name))
121-
cursor.execute("CREATE VIEW {0} AS {1};".format(view_name, view_query))
128+
cursor.execute("CREATE VIEW {0} AS {1};".format(view_name, view_query.query), view_query.params)
122129
ret = "FORCED"
123130
else:
124131
ret = "FORCE_REQUIRED"
@@ -197,10 +204,15 @@ class BaseManagerMeta:
197204

198205

199206
class View(models.Model, metaclass=ViewMeta):
200-
"""Helper for exposing Postgres views as Django models.
207+
""" Helper for exposing Postgres views as Django models.
201208
"""
202209

203210
_deferred = False
211+
sql = None
212+
213+
@classmethod
214+
def get_sql(cls):
215+
return ViewSQL(cls.sql, None)
204216

205217
class Meta:
206218
abstract = True

tests/test_project/test_project/viewtest/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from datetime import timedelta
2+
13
from django.db import models
4+
from django.utils import timezone
25

36
from django_pgviews import view
47

@@ -15,6 +18,17 @@ class Superusers(view.View):
1518
sql = """SELECT * FROM auth_user WHERE is_superuser = TRUE;"""
1619

1720

21+
class LatestSuperusers(view.View): # concept doesn't make much sense, but it will get the job done
22+
projection = ["auth.User.*"]
23+
24+
@classmethod
25+
def get_sql(cls):
26+
return view.ViewSQL(
27+
"""SELECT * FROM auth_user WHERE is_superuser = TRUE and date_joined >= %s;""",
28+
[timezone.now() - timedelta(days=5)],
29+
)
30+
31+
1832
class SimpleUser(view.View):
1933
projection = ["auth.User.username", "auth.User.password"]
2034
# The row_number() window function is needed so that Django sees some kind

tests/test_project/test_project/viewtest/tests.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
"""Test Django PGViews.
22
"""
33
from contextlib import closing
4+
from datetime import timedelta
45

56
from django.contrib import auth
7+
from django.contrib.auth.models import User
68
from django.core.management import call_command
79
from django.db import connection
810
from django.db.models import signals
911
from django.dispatch import receiver
1012
from django.test import TestCase
13+
from django.utils import timezone
14+
1115
from django_pgviews.signals import view_synced, all_views_synced
1216

1317
from . import models
18+
from .models import LatestSuperusers
1419

1520

1621
@receiver(signals.post_migrate)
@@ -31,7 +36,7 @@ def test_views_have_been_created(self):
3136
cur.execute("""SELECT COUNT(*) FROM pg_views WHERE viewname LIKE 'viewtest_%';""")
3237

3338
(count,) = cur.fetchone()
34-
self.assertEqual(count, 4)
39+
self.assertEqual(count, 5)
3540

3641
cur.execute("""SELECT COUNT(*) FROM pg_matviews WHERE matviewname LIKE 'viewtest_%';""")
3742

@@ -140,10 +145,18 @@ def on_all_views_synced(sender, **kwargs):
140145
call_command("sync_pgviews", update=False)
141146

142147
# All views went through syncing
143-
self.assertEqual(len(synced_views), 8)
148+
self.assertEqual(len(synced_views), 9)
144149
self.assertEqual(all_views_were_synced[0], True)
145150
self.assertFalse(expected)
146151

152+
def test_get_sql(self):
153+
User.objects.create(username="old", is_superuser=True, date_joined=timezone.now() - timedelta(days=10))
154+
User.objects.create(username="new", is_superuser=True, date_joined=timezone.now() - timedelta(days=1))
155+
156+
call_command("sync_pgviews", update=False)
157+
158+
self.assertEqual(LatestSuperusers.objects.count(), 1)
159+
147160

148161
class DependantViewTestCase(TestCase):
149162
def test_sync_depending_views(self):
@@ -171,7 +184,7 @@ def test_sync_depending_views(self):
171184
cur.execute("""SELECT COUNT(*) FROM pg_views WHERE viewname LIKE 'viewtest_%';""")
172185

173186
(count,) = cur.fetchone()
174-
self.assertEqual(count, 4)
187+
self.assertEqual(count, 5)
175188

176189
with self.assertRaises(Exception):
177190
cur.execute("""SELECT name from viewtest_relatedview;""")
@@ -203,13 +216,10 @@ def test_sync_depending_materialized_views(self):
203216
call_command("sync_pgviews", "--force")
204217

205218
with closing(connection.cursor()) as cur:
206-
cur.execute(
207-
"""SELECT COUNT(*) FROM pg_views
208-
WHERE viewname LIKE 'viewtest_%';"""
209-
)
219+
cur.execute("""SELECT COUNT(*) FROM pg_views WHERE viewname LIKE 'viewtest_%';""")
210220

211221
(count,) = cur.fetchone()
212-
self.assertEqual(count, 4)
222+
self.assertEqual(count, 5)
213223

214224
with self.assertRaises(Exception):
215225
cur.execute("""SELECT name from viewtest_dependantmaterializedview;""")

0 commit comments

Comments
 (0)