pgdevkit 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/PKG-INFO +1 -1
  2. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/db/complex_types.py +22 -4
  3. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/testdb/schema.py +8 -3
  4. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pyproject.toml +1 -1
  5. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/db/test_complex_types.py +41 -0
  6. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/uv.lock +1 -1
  7. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/.github/workflows/python-publish.yml +0 -0
  8. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/.github/workflows/python-test.yml +0 -0
  9. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/.gitignore +0 -0
  10. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/.python-version +0 -0
  11. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/README.md +0 -0
  12. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/docs/database-layout.md +0 -0
  13. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/__init__.py +0 -0
  14. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/cli.py +0 -0
  15. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/connection.py +0 -0
  16. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/db/__init__.py +0 -0
  17. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/db/connection.py +0 -0
  18. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/db/crud.py +0 -0
  19. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/db/loader.py +0 -0
  20. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/db/model.py +0 -0
  21. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/diff.py +0 -0
  22. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/fetch_missing.py +0 -0
  23. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/introspect.py +0 -0
  24. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/lakebase.py +0 -0
  25. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/models.py +0 -0
  26. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/parser.py +0 -0
  27. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/testdb/__init__.py +0 -0
  28. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/testdb/api.py +0 -0
  29. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/testdb/config.py +0 -0
  30. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/testdb/constants.py +0 -0
  31. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/testdb/container.py +0 -0
  32. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/testdb/naming.py +0 -0
  33. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/pgdevkit/testdb/query.py +0 -0
  34. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/skills/pgdevkit/SKILL.md +0 -0
  35. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  36. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/skills/pgdevkit/references/temporal-tables.md +0 -0
  37. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/__init__.py +0 -0
  38. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/conftest.py +0 -0
  39. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/db/__init__.py +0 -0
  40. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/db/test_connection.py +0 -0
  41. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/db/test_crud.py +0 -0
  42. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/db/test_loader.py +0 -0
  43. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/fixtures/01_schema.sql +0 -0
  44. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/fixtures/02_types.sql +0 -0
  45. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/fixtures/03_tables.sql +0 -0
  46. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/fixtures/04_views.sql +0 -0
  47. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/fixtures/05_functions.sql +0 -0
  48. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/fixtures/06_indexes.sql +0 -0
  49. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/test_cli_compare.py +0 -0
  50. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/test_compare.py +0 -0
  51. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/test_connection.py +0 -0
  52. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/test_fetch_missing.py +0 -0
  53. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/test_fetch_missing_cli.py +0 -0
  54. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/test_lakebase.py +0 -0
  55. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/__init__.py +0 -0
  56. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/conftest.py +0 -0
  57. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  58. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  59. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  60. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  61. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  62. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  63. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  64. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  65. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  66. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  67. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  68. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  69. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/test_api.py +0 -0
  70. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/test_cli.py +0 -0
  71. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/test_config.py +0 -0
  72. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/test_constants.py +0 -0
  73. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/test_container.py +0 -0
  74. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/test_naming.py +0 -0
  75. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/test_query.py +0 -0
  76. {pgdevkit-0.2.2 → pgdevkit-0.2.4}/tests/testdb/test_schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgdevkit
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -9,7 +9,18 @@ from psycopg.types.composite import CompositeInfo, register_composite
9
9
  from psycopg.types.enum import EnumInfo, register_enum
10
10
  from psycopg.types.json import Jsonb
11
11
 
12
- ComplexTypeInfo = CompositeInfo | EnumInfo | type[Jsonb] | None
12
+
13
+ class JsonbArray:
14
+ """Sentinel for a `jsonb[]` column (a Postgres ARRAY of jsonb) — distinct
15
+ from `Jsonb` (a scalar jsonb column) so `recursive_convert` knows whether
16
+ an incoming Python list *is* the array (each element needs its own
17
+ `Jsonb(...)` wrapper) or is JSON array *content* for one scalar jsonb
18
+ value (the whole list gets wrapped once). Both cases arrive from
19
+ `information_schema.columns` looking identical (a plain Python list) —
20
+ only the column's own array-ness disambiguates them."""
21
+
22
+
23
+ ComplexTypeInfo = CompositeInfo | EnumInfo | type[Jsonb] | type[JsonbArray] | None
13
24
 
14
25
 
15
26
  class ComplexHelper:
@@ -67,7 +78,7 @@ class ComplexHelper:
67
78
  if res["data_type"].lower() == "jsonb":
68
79
  return Jsonb
69
80
  if res["data_type"].upper() == "ARRAY" and res["udt_name"] == "_jsonb":
70
- return Jsonb
81
+ return JsonbArray
71
82
  if (
72
83
  not res["is_enum"]
73
84
  and not res["is_user_defined"]
@@ -181,9 +192,16 @@ class ComplexHelper:
181
192
  return None
182
193
  if self.system_complex_type_dict is None:
183
194
  await self.load_complex_type_dict()
195
+ if info == JsonbArray:
196
+ # The value here *is* the jsonb[] array -- each element is its
197
+ # own scalar jsonb value, unlike the plain-Jsonb case below.
198
+ assert isinstance(value, list), f"Expected a list for a jsonb[] column, got {type(value)}"
199
+ return [Jsonb(item) for item in value]
184
200
  if info == Jsonb:
185
- # A JSONB column's value is wrapped whole, even if it's a list —
186
- # only an array-of-composite/enum column recurses per element.
201
+ # A scalar JSONB column's value is wrapped whole, even if it's a
202
+ # list that list is JSON array *content* for the one jsonb
203
+ # value, not multiple array elements (see JsonbArray above for
204
+ # the jsonb[] column case).
187
205
  return Jsonb(value)
188
206
  if isinstance(value, list):
189
207
  return [await self.recursive_convert(item, info, con) for item in value]
@@ -94,7 +94,11 @@ def _get_sql_deps(sql: str) -> set[str]:
94
94
  continue
95
95
  for t in e.find_all(exp.Table):
96
96
  if t.args.get("this") is not None and t.args.get("db") is not None:
97
- deps.add(str(t))
97
+ # exp.table_name(), not str(t): str() includes " AS alias" for
98
+ # an aliased reference (e.g. "FROM editing.visit v"), which
99
+ # would never match the plain declared name any dependent
100
+ # file's own CREATE target is recorded under.
101
+ deps.add(exp.table_name(t))
98
102
  return deps
99
103
 
100
104
 
@@ -119,7 +123,7 @@ def _iter_sql_files(database_dir: Path):
119
123
  deps = _get_sql_deps(content)
120
124
  if file.parent.name in _SCHEMA_QUALIFIED_TYPES:
121
125
  schema = _strip_layer_prefix(file.parent.parent.name)
122
- full_name = f"{schema}.{file.stem}"
126
+ full_name = f"{schema}.{_strip_layer_prefix(file.stem)}"
123
127
  deps.discard(full_name) # the file's own CREATE target is not a real dependency
124
128
  all_declared.add(full_name)
125
129
  if not deps or all(d in delivered for d in deps):
@@ -219,7 +223,8 @@ async def apply_schema(
219
223
  json_file = file.with_suffix(".test_data.json")
220
224
  if json_file.exists():
221
225
  schema_name = _strip_layer_prefix(file.parent.parent.name)
222
- await _insert_test_data(json_file, f"{schema_name}.{file.stem}", force_reset, con, complex_helper)
226
+ table_stem = _strip_layer_prefix(file.stem)
227
+ await _insert_test_data(json_file, f"{schema_name}.{table_stem}", force_reset, con, complex_helper)
223
228
 
224
229
  failures: list[tuple[Path, str]] = []
225
230
  for file, sql in _iter_sql_files(database_dir):
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.2.2"
14
+ version = "0.2.4"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -80,6 +80,47 @@ async def test_select_list_includes_generated_columns(complex_types_test_db):
80
80
  assert '"total"' in rendered
81
81
 
82
82
 
83
+ @requires_podman
84
+ async def test_jsonb_array_column_wraps_each_element_not_the_whole_list(complex_types_test_db):
85
+ # Regression: a jsonb[] column (a Postgres ARRAY of jsonb) and a plain
86
+ # jsonb column both surface identically from information_schema (a bare
87
+ # Python list, if that's the value) -- previously both were detected as
88
+ # the same `Jsonb` sentinel, and recursive_convert wrapped the whole
89
+ # incoming list as one Jsonb(...) for either case. That's correct for a
90
+ # plain jsonb column storing a JSON array *value*, but wrong for a
91
+ # jsonb[] column, where the list *is* the array and each element needs
92
+ # its own Jsonb(...) wrapper -- psycopg would otherwise try to bind a
93
+ # single jsonb value to an array column and Postgres would raise
94
+ # DatatypeMismatch ("column ... is of type jsonb[] but expression is of
95
+ # type jsonb").
96
+ async with await psycopg.AsyncConnection.connect(_db_dsn(), autocommit=True) as con:
97
+ await con.execute("""
98
+ CREATE TABLE gadget (
99
+ id serial PRIMARY KEY,
100
+ tags jsonb[],
101
+ metadata jsonb
102
+ )
103
+ """)
104
+ helper = ComplexHelper(con)
105
+ types = await helper.load_all_complex_types(("public", "gadget"))
106
+
107
+ tags_value = [{"name": "a"}, {"name": "b"}]
108
+ converted_tags = await helper.recursive_convert(tags_value, types["tags"], con)
109
+
110
+ metadata_value = [1, 2, 3] # JSON array *content* for one scalar jsonb value
111
+ converted_metadata = await helper.recursive_convert(metadata_value, types["metadata"], con)
112
+
113
+ await con.execute(
114
+ "INSERT INTO gadget (id, tags, metadata) VALUES (1, %s, %s)",
115
+ (converted_tags, converted_metadata),
116
+ )
117
+ async with con.cursor() as cur:
118
+ await cur.execute("SELECT tags, metadata FROM gadget WHERE id = 1")
119
+ tags, metadata = await cur.fetchone()
120
+ assert tags == tags_value
121
+ assert metadata == metadata_value
122
+
123
+
83
124
  def test_complex_types_cache_is_per_instance_not_shared():
84
125
  # Regression: complex_types used to be a mutable class attribute, so a
85
126
  # CompositeInfo/EnumInfo (which carries OIDs from one specific
@@ -281,7 +281,7 @@ wheels = [
281
281
 
282
282
  [[package]]
283
283
  name = "pgdevkit"
284
- version = "0.2.2"
284
+ version = "0.2.4"
285
285
  source = { editable = "." }
286
286
  dependencies = [
287
287
  { name = "docker" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes