Skip to content

Commit a08bb1e

Browse files
authored
Merge pull request #117 from DanCardin/dc/fix-pg11
fix: Compatibility with postgres 11, and allow specifying zero objects.
2 parents b7c0d8c + d721e59 commit a08bb1e

File tree

12 files changed

+107
-65
lines changed

12 files changed

+107
-65
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## 0.15
44

5+
### 0.15.12
6+
- fix: Enable registering zero objects (enable without declaring anything).
7+
- fix: Add explicit relkind casts to ensure compatibility with postgres 11.
8+
- fix: Improve quoting of role names, particularly in postgres.
9+
510
### 0.15.11
611
- fix: Exclude extension-created objects from comparisons.
712
- fix: Quote audit column names.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sqlalchemy-declarative-extensions"
3-
version = "0.15.11"
3+
version = "0.15.12"
44
authors = [
55
{name = "Dan Cardin", email = "ddcardin@gmail.com"},
66
]

src/sqlalchemy_declarative_extensions/api.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -224,41 +224,41 @@ def register_create_events(
224224
from sqlalchemy_declarative_extensions.trigger.ddl import trigger_ddl
225225
from sqlalchemy_declarative_extensions.view.ddl import view_ddl
226226

227-
concrete_schemas = metadata.info.get("schemas")
228-
concrete_roles = metadata.info.get("roles")
229-
concrete_grants = metadata.info.get("grants")
230-
concrete_views = Views.extract(metadata)
231-
concrete_procedures = metadata.info.get("procedures")
232-
concrete_functions = metadata.info.get("functions")
233-
concrete_triggers = metadata.info.get("triggers")
234-
concrete_databases = metadata.info.get("databases")
235-
concrete_rows = metadata.info.get("rows")
236-
237-
if concrete_databases and databases:
227+
concrete_schemas = metadata.info.get("schemas") or Schemas()
228+
concrete_roles = metadata.info.get("roles") or Roles()
229+
concrete_grants = metadata.info.get("grants") or Grants()
230+
concrete_views = Views.extract(metadata) or Views()
231+
concrete_procedures = metadata.info.get("procedures") or Procedures()
232+
concrete_functions = metadata.info.get("functions") or Functions()
233+
concrete_triggers = metadata.info.get("triggers") or Triggers()
234+
concrete_databases = metadata.info.get("databases") or Databases()
235+
concrete_rows = metadata.info.get("rows") or Rows()
236+
237+
if databases:
238238
database_filter = databases if isinstance(databases, list) else None
239239
event.listen(
240240
metadata,
241241
"before_create",
242242
database_ddl(concrete_databases, database_filter),
243243
)
244244

245-
if concrete_schemas and schemas:
245+
if schemas:
246246
schema_filter = schemas if isinstance(schemas, list) else None
247247
event.listen(
248248
metadata,
249249
"before_create",
250250
schema_ddl(concrete_schemas, schema_filter),
251251
)
252252

253-
if concrete_roles and roles:
253+
if roles:
254254
role_filter = roles if isinstance(roles, list) else None
255255
event.listen(
256256
metadata,
257257
"before_create",
258258
role_ddl(concrete_roles, role_filter),
259259
)
260260

261-
if concrete_grants and grants:
261+
if grants:
262262
event.listen(
263263
metadata,
264264
"before_create",
@@ -267,39 +267,39 @@ def register_create_events(
267267
# There should(?) be no need to handle dropping for grants,
268268
# they will be handled directly by table handling.
269269

270-
if concrete_views and views:
270+
if views:
271271
view_filter = views if isinstance(views, list) else None
272272
event.listen(
273273
metadata,
274274
"after_create",
275275
view_ddl(concrete_views, view_filter),
276276
)
277277

278-
if concrete_procedures and procedures:
278+
if procedures:
279279
procedure_filter = procedures if isinstance(procedures, list) else None
280280
event.listen(
281281
metadata,
282282
"after_create",
283283
procedure_ddl(concrete_procedures, procedure_filter),
284284
)
285285

286-
if concrete_functions and functions:
286+
if functions:
287287
function_filter = functions if isinstance(functions, list) else None
288288
event.listen(
289289
metadata,
290290
"after_create",
291291
function_ddl(concrete_functions, function_filter),
292292
)
293293

294-
if concrete_triggers and triggers:
294+
if triggers:
295295
trigger_filter = triggers if isinstance(triggers, list) else None
296296
event.listen(
297297
metadata,
298298
"after_create",
299299
trigger_ddl(concrete_triggers, trigger_filter),
300300
)
301301

302-
if concrete_rows and rows:
302+
if rows:
303303
row_filter = rows if isinstance(rows, list) else None
304304
event.listen(
305305
metadata,

src/sqlalchemy_declarative_extensions/dialects/postgresql/role.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,29 +91,28 @@ def __repr__(self):
9191
return f'{cls_name}("{self.name}", {options})'
9292

9393
def to_sql_create(self, raw: bool = True) -> list[str]:
94-
segments = ["CREATE ROLE", self.name]
94+
segments = ["CREATE ROLE", generic.quote_name(self.name)]
9595

9696
options = postgres_render_role_options(self, raw=raw)
9797
if options:
9898
segments.append("WITH")
9999
segments.extend(options)
100100

101101
if self.in_roles is not None:
102-
in_roles = ", ".join(generic.role_names(self.in_roles))
103-
segment = f"IN ROLE {in_roles}"
102+
segment = f"IN ROLE {generic.quote_names(self.in_roles)}"
104103
segments.append(segment)
105104

106105
command = " ".join(segments) + ";"
107106
return [command]
108107

109108
def to_sql_update(self, to_role: Role, raw: bool = True) -> list[str]:
110-
role_name = to_role.name
109+
role_name = generic.quote_name(to_role.name)
111110
diff = RoleDiff.diff(self, to_role)
112111

113112
result = []
114113

115114
if self.use_role:
116-
result.append(f"SET ROLE {self.use_role};")
115+
result.append(f"""SET ROLE "{generic.quote_name(self.use_role)}";""")
117116

118117
diff_options = postgres_render_role_options(diff, raw=raw)
119118
if diff_options:
@@ -122,10 +121,12 @@ def to_sql_update(self, to_role: Role, raw: bool = True) -> list[str]:
122121
result.append(alter_role)
123122

124123
for add_name in diff.add_roles:
125-
result.append(f"GRANT {add_name} TO {role_name};")
124+
result.append(f"""GRANT {generic.quote_name(add_name)} TO {role_name};""")
126125

127126
for remove_name in diff.remove_roles:
128-
result.append(f"REVOKE {remove_name} FROM {role_name};")
127+
result.append(
128+
f"""REVOKE {generic.quote_name(remove_name)} FROM {role_name};"""
129+
)
129130

130131
if self.use_role:
131132
result.append("RESET ROLE")

src/sqlalchemy_declarative_extensions/dialects/postgresql/schema.py

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from typing import Collection
2+
13
from sqlalchemy import (
4+
BindParameter,
25
String,
36
and_,
47
bindparam,
@@ -17,6 +20,11 @@
1720
char = CHAR(1)
1821

1922

23+
def char_literals(*literals: str) -> Collection[BindParameter]:
24+
"""Create a collection of CHAR literals."""
25+
return [literal(lit, char) for lit in literals]
26+
27+
2028
tables = table(
2129
"tables",
2230
column("table_schema"),
@@ -209,6 +217,8 @@ def _not_from_extension(obj_id_col, class_name):
209217
)
210218
)
211219

220+
object_acl_relkinds = char_literals("r", "S", "f", "n", "T", "v")
221+
namespace_acl_relkind = literal("n", char)
212222
object_acl_query = union(
213223
select(
214224
pg_namespace.c.nspname.label("schema"),
@@ -222,24 +232,13 @@ def _not_from_extension(obj_id_col, class_name):
222232
pg_roles, pg_class.c.relowner == pg_roles.c.oid
223233
)
224234
)
225-
.where(
226-
pg_class.c.relkind.cast(char).in_(
227-
[
228-
literal("r", char),
229-
literal("S", char),
230-
literal("f", char),
231-
literal("n", char),
232-
literal("T", char),
233-
literal("v", char),
234-
]
235-
)
236-
)
235+
.where(pg_class.c.relkind.cast(char).in_(object_acl_relkinds))
237236
.where(_table_not_pg)
238237
.where(_schema_not_pg()),
239238
select(
240239
literal(None).label("schema"),
241240
pg_namespace.c.nspname.label("name"),
242-
literal("n").cast(char).label("relkind"),
241+
namespace_acl_relkind.label("relkind"),
243242
pg_roles.c.rolname.label("owner"),
244243
pg_namespace.c.nspacl.cast(ARRAY(String)),
245244
)
@@ -257,34 +256,25 @@ def _not_from_extension(obj_id_col, class_name):
257256
.select_from(
258257
pg_class.join(pg_namespace, pg_class.c.relnamespace == pg_namespace.c.oid)
259258
)
260-
.where(
261-
pg_class.c.relkind.cast(char).in_(
262-
[
263-
literal("r", char),
264-
literal("S", char),
265-
literal("f", char),
266-
literal("n", char),
267-
literal("T", char),
268-
literal("v", char),
269-
]
270-
)
271-
)
259+
.where(pg_class.c.relkind.cast(char).in_(object_acl_relkinds))
272260
.where(_table_not_pg)
273261
.where(_schema_not_pg())
274262
.where(_schema_not_from_extension())
275263
)
276264

265+
view_relkinds = char_literals("v", "m")
266+
materialized_relkind = literal("m", char)
277267
views_query = (
278268
select(
279269
pg_namespace.c.nspname.label("schema"),
280270
pg_class.c.relname.label("name"),
281271
func.pg_get_viewdef(pg_class.c.oid).label("definition"),
282-
(pg_class.c.relkind == literal("m")).label("materialized"),
272+
(pg_class.c.relkind.cast(char) == materialized_relkind).label("materialized"),
283273
)
284274
.select_from(
285275
pg_class.join(pg_namespace, pg_class.c.relnamespace == pg_namespace.c.oid)
286276
)
287-
.where(pg_class.c.relkind.in_(["v", "m"]))
277+
.where(pg_class.c.relkind.cast(char).in_(view_relkinds))
288278
.where(_schema_not_pg(pg_namespace.c.nspname))
289279
.where(_schema_not_from_extension())
290280
.where(_not_from_extension(pg_class.c.oid, "pg_class"))

src/sqlalchemy_declarative_extensions/role/generic.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
from dataclasses import dataclass, replace
5+
from typing import Sequence
56

67
from sqlalchemy_declarative_extensions.context import context
78

@@ -78,10 +79,9 @@ def normalize(self):
7879
)
7980

8081
def to_sql_create(self, raw: bool = True) -> list[str]:
81-
statement = f'CREATE ROLE "{self.name}"'
82+
statement = f"CREATE ROLE {quote_name(self.name)}"
8283
if self.in_roles is not None:
83-
in_roles = ", ".join(role_names(self.in_roles))
84-
statement += f"IN ROLE {in_roles}"
84+
statement += f"IN ROLE {quote_names(self.in_roles)}"
8585
return [statement + ";"]
8686

8787
def to_sql_update(self, to_role, raw: bool = True) -> list[str]:
@@ -90,7 +90,7 @@ def to_sql_update(self, to_role, raw: bool = True) -> list[str]:
9090
)
9191

9292
def to_sql_drop(self, raw: bool = True) -> list[str]:
93-
return [f'DROP ROLE "{self.name}";']
93+
return [f"DROP ROLE {quote_name(self.name)};"]
9494

9595
def to_sql_use(self, undo: bool) -> list[str]:
9696
raise NotImplementedError()
@@ -132,3 +132,11 @@ def role_name(role: Role | str) -> str:
132132

133133
def role_names(roles: list[Role | str]) -> list[str]:
134134
return [role_name(r) for r in roles]
135+
136+
137+
def quote_name(role: Role | str) -> str:
138+
return f'"{role_name(role)}"'
139+
140+
141+
def quote_names(roles: Sequence[Role | str]) -> str:
142+
return ", ".join(quote_name(role) for role in roles)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import pytest
2+
from pytest_mock_resources import PostgresConfig, create_postgres_fixture
3+
from sqlalchemy import MetaData, text
4+
5+
from sqlalchemy_declarative_extensions import register_sqlalchemy_events
6+
from sqlalchemy_declarative_extensions.api import declare_database
7+
from sqlalchemy_declarative_extensions.role.base import Roles
8+
9+
metadata = MetaData()
10+
declare_database(metadata, roles=Roles(ignore_unspecified=True))
11+
12+
13+
register_sqlalchemy_events(
14+
metadata,
15+
functions=True,
16+
triggers=True,
17+
views=True,
18+
schemas=True,
19+
roles=True,
20+
grants=True,
21+
procedures=True,
22+
)
23+
24+
25+
@pytest.fixture
26+
def pmr_postgres_config():
27+
return PostgresConfig(port=None, image="postgres:11")
28+
29+
30+
pg = create_postgres_fixture(engine_kwargs={"echo": True}, session=True)
31+
32+
33+
def test_create(pg):
34+
pg.execute(text("CREATE VIEW foo AS SELECT 1"))
35+
pg.commit()
36+
37+
metadata.create_all(bind=pg.connection())

tests/grant/test_only_defined_roles.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
class Base(_Base): # type: ignore
1919
__abstract__ = True
2020

21+
roles = ["user"]
2122
grants = Grants(only_defined_roles=False, ignore_self_grants=False).are(
2223
DefaultGrant.on_tables_in_schema("public").grant(
2324
"select",

tests/role/test_remove_valid_until.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def test_valid_until_date_being_removed(pg):
2222
result = compare_roles(conn, roles)
2323
assert len(result) == 1
2424
sql = result[0].to_sql()
25-
assert sql == ["ALTER ROLE forever_valid WITH VALID UNTIL 'infinity';"]
25+
assert sql == ["""ALTER ROLE "forever_valid" WITH VALID UNTIL 'infinity';"""]
2626

2727

2828
def test_valid_until_infinity_ignored(pg):

tests/role/test_update_parent_role.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ def test_createall_role(pg):
4141

4242
child_no_parent_diff = result[0].to_sql()
4343
assert child_no_parent_diff == [
44-
"REVOKE parent1 FROM child_no_parent;",
45-
"REVOKE parent2 FROM child_no_parent;",
44+
"""REVOKE "parent1" FROM "child_no_parent";""",
45+
"""REVOKE "parent2" FROM "child_no_parent";""",
4646
]
4747

4848
child_with_parent_diff = result[1].to_sql()
4949
assert child_with_parent_diff == [
50-
"GRANT parent1 TO child_with_parent;",
51-
"GRANT parent2 TO child_with_parent;",
50+
"""GRANT "parent1" TO "child_with_parent";""",
51+
"""GRANT "parent2" TO "child_with_parent";""",
5252
]

0 commit comments

Comments
 (0)