spark-sql-migrations 0.0.4__tar.gz → 0.0.6__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 (23) hide show
  1. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/PKG-INFO +56 -14
  2. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/setup.py +62 -17
  3. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations/migration_templates/default_migration_template.sql +3 -0
  4. spark_sql_migrations-0.0.6/spark_sql_migrations/migrations_initial/first_01_intial_create_cat.sql +9 -0
  5. spark_sql_migrations-0.0.4/spark_sql_migrations/migrations_initial/first_03_intial_create_state_table_all.sql → spark_sql_migrations-0.0.6/spark_sql_migrations/migrations_initial/first_03_intial_create_state_table.sql +1 -2
  6. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations/spark_sql/spark_sql.py +112 -84
  7. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations.egg-info/PKG-INFO +56 -14
  8. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations.egg-info/SOURCES.txt +3 -3
  9. spark_sql_migrations-0.0.4/spark_sql_migrations/migrations_initial/first_01_intial_create_cat_dbr_only.sql +0 -4
  10. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/LICENSE +0 -0
  11. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/MANIFEST.in +0 -0
  12. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/backend_shim.py +0 -0
  13. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/setup.cfg +0 -0
  14. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations/__init__.py +0 -0
  15. /spark_sql_migrations-0.0.4/spark_sql_migrations/migrations_initial/first_02_intial_create_schema_all.sql → /spark_sql_migrations-0.0.6/spark_sql_migrations/migrations_initial/first_02_intial_create_schema.sql +0 -0
  16. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations/py.typed +0 -0
  17. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations/spark_sql/__init__.py +0 -0
  18. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations/spark_utils.py +0 -0
  19. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations.egg-info/dependency_links.txt +0 -0
  20. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations.egg-info/entry_points.txt +0 -0
  21. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations.egg-info/namespace_packages.txt +0 -0
  22. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations.egg-info/requires.txt +0 -0
  23. {spark_sql_migrations-0.0.4 → spark_sql_migrations-0.0.6}/spark_sql_migrations.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spark_sql_migrations
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: Chained, idempotent SQL migrations for Spark and Databricks.
5
5
  Home-page: https://github.com/mcjug2015/spark_sql_migrations
6
6
  Author: Victor Semenov
@@ -79,28 +79,32 @@ Migrations come from two different owners, and they behave differently:
79
79
  | | ships in the wheel | owned by you |
80
80
  |---|---|---|
81
81
  | **what** | catalog, schema, `_spark_migrations_version` table | your tables and columns |
82
- | **where** | `spark_sql_migrations/migrations_initial/` | `all_spark_migrations/`, `dbr_only_migrations/` |
83
- | **selected by** | filename suffix — `_all.sql` everywhere, `_dbr_only.sql` on Databricks | the `prev_revision_id` chain |
82
+ | **where** | `spark_sql_migrations/migrations_initial/` | the directory you pass as `--migrations-dir` |
83
+ | **ordered by** | filename | the `prev_revision_id` chain |
84
84
  | **when applied** | until the version table exists | once, then recorded |
85
85
 
86
86
  The bootstrap chain is applied on every run until `_spark_migrations_version` exists, and
87
87
  skipped from then on. A run that fails partway through bootstrapping replays it, so it must
88
- still be idempotent. Your own chains are applied once each and their head revision is
88
+ still be idempotent. Your own migrations are applied once each and the head revision is
89
89
  written to the version table.
90
90
 
91
- ## Laying out your chains
91
+ Both kinds are rendered the same way, so both can gate a statement on `is_dbr` — the
92
+ bootstrap chain uses it for `CREATE CATALOG`, which only Unity Catalog has.
92
93
 
93
- Point spark_sql_migrations at a directory holding one or both chains:
94
+ ## Laying out your chain
95
+
96
+ `--migrations-dir` is the directory holding the chain — the `.sql` files sit in it directly:
94
97
 
95
98
  ```
96
99
  your_project/
97
- └── migrations/ <- this is --migrations-dir
98
- ├── all_spark_migrations/ <- runs everywhere
99
- └── dbr_only_migrations/ <- runs only when is_dbr()
100
+ └── migrations/ <- this is --migrations-dir
101
+ ├── 260831_01_create_test_table_e5ce0039b32b.sql
102
+ └── 260831_02_create_metrics_table_07990e2a101e.sql
100
103
  ```
101
104
 
102
- Choose deliberately. SQL that only Databricks understands must not live in
103
- `all_spark_migrations/`, or your local runs will break.
105
+ One chain covers both engines: a Databricks-only *statement* gates on `is_dbr` inside a
106
+ migration that otherwise runs everywhere — see below. SQL that only Databricks understands
107
+ must never sit ungated there, or your local runs will break.
104
108
 
105
109
  ## Running migrations
106
110
 
@@ -124,7 +128,7 @@ from spark_sql_migrations.spark_sql.spark_sql import main
124
128
 
125
129
 
126
130
  def get_migrations_dir():
127
- """the parent of this project's own migration chains."""
131
+ """the directory holding this project's own migration chain."""
128
132
  return os.path.dirname(__file__)
129
133
 
130
134
 
@@ -134,7 +138,7 @@ main(cat="spark_catalog", schema="default", migrations_dir=get_migrations_dir())
134
138
  `main` builds its own session via `get_spark()` and, being library code, leaves logging to
135
139
  you. To supply your own — a session already
136
140
  configured by your job, say — call `run_migrations(spark, cat, schema, output_folder,
137
- migrations_root)` directly.
141
+ migrations_dir)` directly.
138
142
 
139
143
  Every rendered statement is written to a timestamped folder under `migrations_out/`
140
144
  before it is executed, so you can always read the exact SQL a run applied.
@@ -144,7 +148,7 @@ before it is executed, so you can always read the exact SQL a run applied.
144
148
  Generate one rather than hand-rolling the header:
145
149
 
146
150
  ```bash
147
- spark-sql-migrations create_new_migration --message "add batch id to metrics" --output-path path/to/migrations/all_spark_migrations
151
+ spark-sql-migrations create_new_migration --message "add batch id to metrics" --output-path path/to/migrations
148
152
  ```
149
153
 
150
154
  That writes `<yymmdd>_<slug>_<revision_id>.sql` from the template shipped in the package
@@ -183,6 +187,44 @@ begin
183
187
  end;
184
188
  ```
185
189
 
190
+ ### Databricks-only statements
191
+
192
+ `is_dbr` is in the render context, so a migration in the shared chain can carry SQL only
193
+ Databricks understands:
194
+
195
+ ```sql
196
+ -- revision_id:e8614b76da6d;
197
+ -- prev_revision_id:57037b6c19b4;
198
+ {% if is_dbr %}
199
+ begin
200
+ grant READ FILES on external location `some-bucket` to `some_group`;
201
+ end;
202
+ {% endif %}
203
+ ```
204
+
205
+ `create_new_migration` writes that conditional for you with `--add-is-dbr`, below the
206
+ headers so they stay readable:
207
+
208
+ ```bash
209
+ spark-sql-migrations create_new_migration --message "read files on the cms zip external location" --output-path path/to/migrations --add-is-dbr
210
+ ```
211
+
212
+ This has to happen at render time, not in SQL. OSS Spark rejects `GRANT` and the rest of
213
+ its unsupported-native-command list while *parsing*, so a `declare exit handler` in the same
214
+ script never gets the chance to run — and the whole script fails to compile, not just that
215
+ statement. Jinja removes the text before `spark.sql()` ever sees it.
216
+
217
+ A migration left holding nothing but comments is recorded in the version table and not
218
+ executed, so the chain advances in step on both engines. That recording is forward-only: a
219
+ catalog migrated locally past a gated revision will not pick that statement up if it is
220
+ later migrated from Databricks. For disposable test schemas that is exactly what you want;
221
+ for a long-lived catalog, don't migrate it from both.
222
+
223
+ `_spark_migrations_version` holds one row and one column, `version_num`. There is no
224
+ in-place upgrade from the older two-chain shape, which carried a `migration_type` column and
225
+ a row per chain: a catalog bootstrapped by an earlier release has to be rebuilt from
226
+ scratch, which also clears the revision history that layout recorded.
227
+
186
228
  ## Local vs Databricks
187
229
 
188
230
  `get_spark()` returns a `DatabricksSession` when `is_dbr()` is true and a Delta-configured
@@ -80,28 +80,32 @@ Migrations come from two different owners, and they behave differently:
80
80
  | | ships in the wheel | owned by you |
81
81
  |---|---|---|
82
82
  | **what** | catalog, schema, `_spark_migrations_version` table | your tables and columns |
83
- | **where** | `spark_sql_migrations/migrations_initial/` | `all_spark_migrations/`, `dbr_only_migrations/` |
84
- | **selected by** | filename suffix — `_all.sql` everywhere, `_dbr_only.sql` on Databricks | the `prev_revision_id` chain |
83
+ | **where** | `spark_sql_migrations/migrations_initial/` | the directory you pass as `--migrations-dir` |
84
+ | **ordered by** | filename | the `prev_revision_id` chain |
85
85
  | **when applied** | until the version table exists | once, then recorded |
86
86
 
87
87
  The bootstrap chain is applied on every run until `_spark_migrations_version` exists, and
88
88
  skipped from then on. A run that fails partway through bootstrapping replays it, so it must
89
- still be idempotent. Your own chains are applied once each and their head revision is
89
+ still be idempotent. Your own migrations are applied once each and the head revision is
90
90
  written to the version table.
91
91
 
92
- ## Laying out your chains
92
+ Both kinds are rendered the same way, so both can gate a statement on `is_dbr` — the
93
+ bootstrap chain uses it for `CREATE CATALOG`, which only Unity Catalog has.
93
94
 
94
- Point spark_sql_migrations at a directory holding one or both chains:
95
+ ## Laying out your chain
96
+
97
+ `--migrations-dir` is the directory holding the chain — the `.sql` files sit in it directly:
95
98
 
96
99
  ```
97
100
  your_project/
98
- └── migrations/ <- this is --migrations-dir
99
- ├── all_spark_migrations/ <- runs everywhere
100
- └── dbr_only_migrations/ <- runs only when is_dbr()
101
+ └── migrations/ <- this is --migrations-dir
102
+ ├── 260831_01_create_test_table_e5ce0039b32b.sql
103
+ └── 260831_02_create_metrics_table_07990e2a101e.sql
101
104
  ```
102
105
 
103
- Choose deliberately. SQL that only Databricks understands must not live in
104
- `all_spark_migrations/`, or your local runs will break.
106
+ One chain covers both engines: a Databricks-only *statement* gates on `is_dbr` inside a
107
+ migration that otherwise runs everywhere — see below. SQL that only Databricks understands
108
+ must never sit ungated there, or your local runs will break.
105
109
 
106
110
  ## Running migrations
107
111
 
@@ -128,7 +132,7 @@ from spark_sql_migrations.spark_sql.spark_sql import main
128
132
 
129
133
 
130
134
  def get_migrations_dir():
131
- \"\"\"the parent of this project's own migration chains.\"\"\"
135
+ \"\"\"the directory holding this project's own migration chain.\"\"\"
132
136
  return os.path.dirname(__file__)
133
137
 
134
138
 
@@ -138,7 +142,7 @@ main(cat="spark_catalog", schema="default", migrations_dir=get_migrations_dir())
138
142
  `main` builds its own session via `get_spark()` and, being library code, leaves logging to
139
143
  you. To supply your own — a session already
140
144
  configured by your job, say — call `run_migrations(spark, cat, schema, output_folder,
141
- migrations_root)` directly.
145
+ migrations_dir)` directly.
142
146
 
143
147
  Every rendered statement is written to a timestamped folder under `migrations_out/`
144
148
  before it is executed, so you can always read the exact SQL a run applied.
@@ -150,7 +154,7 @@ Generate one rather than hand-rolling the header:
150
154
  ```bash
151
155
  spark-sql-migrations create_new_migration \
152
156
  --message "add batch id to metrics" \
153
- --output-path path/to/migrations/all_spark_migrations
157
+ --output-path path/to/migrations
154
158
  ```
155
159
 
156
160
  That writes `<yymmdd>_<slug>_<revision_id>.sql` from the template shipped in the package
@@ -189,6 +193,47 @@ begin
189
193
  end;
190
194
  ```
191
195
 
196
+ ### Databricks-only statements
197
+
198
+ `is_dbr` is in the render context, so a migration in the shared chain can carry SQL only
199
+ Databricks understands:
200
+
201
+ ```sql
202
+ -- revision_id:e8614b76da6d;
203
+ -- prev_revision_id:57037b6c19b4;
204
+ {% if is_dbr %}
205
+ begin
206
+ grant READ FILES on external location `some-bucket` to `some_group`;
207
+ end;
208
+ {% endif %}
209
+ ```
210
+
211
+ `create_new_migration` writes that conditional for you with `--add-is-dbr`, below the
212
+ headers so they stay readable:
213
+
214
+ ```bash
215
+ spark-sql-migrations create_new_migration \
216
+ --message "read files on the cms zip external location" \
217
+ --output-path path/to/migrations \
218
+ --add-is-dbr
219
+ ```
220
+
221
+ This has to happen at render time, not in SQL. OSS Spark rejects `GRANT` and the rest of
222
+ its unsupported-native-command list while *parsing*, so a `declare exit handler` in the same
223
+ script never gets the chance to run — and the whole script fails to compile, not just that
224
+ statement. Jinja removes the text before `spark.sql()` ever sees it.
225
+
226
+ A migration left holding nothing but comments is recorded in the version table and not
227
+ executed, so the chain advances in step on both engines. That recording is forward-only: a
228
+ catalog migrated locally past a gated revision will not pick that statement up if it is
229
+ later migrated from Databricks. For disposable test schemas that is exactly what you want;
230
+ for a long-lived catalog, don't migrate it from both.
231
+
232
+ `_spark_migrations_version` holds one row and one column, `version_num`. There is no
233
+ in-place upgrade from the older two-chain shape, which carried a `migration_type` column and
234
+ a row per chain: a catalog bootstrapped by an earlier release has to be rebuilt from
235
+ scratch, which also clears the revision history that layout recorded.
236
+
192
237
  ## Local vs Databricks
193
238
 
194
239
  `get_spark()` returns a `DatabricksSession` when `is_dbr()` is true and a Delta-configured
@@ -238,9 +283,9 @@ Apache License 2.0 — see [LICENSE](LICENSE).
238
283
  'package_data': {
239
284
  'spark_sql_migrations': (
240
285
  'migration_templates/default_migration_template.sql',
241
- 'migrations_initial/first_01_intial_create_cat_dbr_only.sql',
242
- 'migrations_initial/first_02_intial_create_schema_all.sql',
243
- 'migrations_initial/first_03_intial_create_state_table_all.sql',
286
+ 'migrations_initial/first_01_intial_create_cat.sql',
287
+ 'migrations_initial/first_02_intial_create_schema.sql',
288
+ 'migrations_initial/first_03_intial_create_state_table.sql',
244
289
  'py.typed',
245
290
  ),
246
291
  },
@@ -254,5 +299,5 @@ Apache License 2.0 — see [LICENSE](LICENSE).
254
299
  },
255
300
  'python_requires': '>=3.10',
256
301
  'url': 'https://github.com/mcjug2015/spark_sql_migrations',
257
- 'version': '0.0.4',
302
+ 'version': '0.0.6',
258
303
  })
@@ -5,6 +5,9 @@ begin
5
5
  -- would like to have applied to databricks db or local spark(for testing)
6
6
  -- crutch scaffolding will use jinja to fill in the cat and schema that are sent to it
7
7
  -- this is also taken care of in the gh build.
8
+ -- is_dbr is filled in too: statements only databricks understands go in a jinja
9
+ -- conditional on it, outside the begin/end, rather than in a second chain. pass
10
+ -- --add-is-dbr to create_new_migration to have that conditional written for you.
8
11
 
9
12
  -- e.g:
10
13
  -- create schema if not exists {{cat}}.{{schema}};
@@ -0,0 +1,9 @@
1
+ -- revision_id:;
2
+ -- a catalog is a unity catalog object: local spark has spark_catalog and no way
3
+ -- to make another, so this one is gated to databricks and renders to nothing
4
+ -- else, leaving the bootstrap chain the same three files everywhere.
5
+ {% if is_dbr %}
6
+ begin
7
+ create catalog if not exists {{cat}};
8
+ end;
9
+ {% endif %}
@@ -1,7 +1,6 @@
1
1
  -- revision_id:;
2
2
  begin
3
3
  create table if not exists {{cat}}.{{schema}}._spark_migrations_version (
4
- migration_type string not null,
5
4
  version_num string not null
6
5
  );
7
- end;
6
+ end;
@@ -3,15 +3,21 @@
3
3
  Two kinds of migration exist, and they are owned by different parties:
4
4
 
5
5
  * the *initial* chain and the new-migration template ship inside this package.
6
- They bootstrap the catalog, the schema and the version table, are selected by
7
- filename suffix (``_all.sql`` / ``_dbr_only.sql``) rather than by revision,
8
- and are applied on every run until the version table exists -- so a run that
9
- failed partway through replays them, and they must be idempotent.
10
- * the ``all_spark_migrations/`` and ``dbr_only_migrations/`` chains belong to
11
- the consuming project. The caller passes their parent directory as
12
- ``migrations_root``; each chain is walked from its root via
13
- ``prev_revision_id`` and only the tail not yet recorded in the version table
14
- is applied.
6
+ They bootstrap the catalog, the schema and the version table, carry no
7
+ revision of their own, and are applied in filename order on every run until
8
+ the version table exists -- so a run that failed partway through replays
9
+ them, and they must be idempotent.
10
+ * the client chain belongs to the consuming project. The caller passes the
11
+ directory holding it as ``migrations_dir``; the chain is walked from its root
12
+ via ``prev_revision_id`` and only the tail not yet recorded in the version
13
+ table is applied.
14
+
15
+ Which engine a migration targets is decided inside the migration, not by where it
16
+ sits: every one of them is rendered through jinja before it is executed, with
17
+ ``is_dbr`` in the render context, so a chain that runs everywhere can still gate
18
+ the statements one engine would reject. A migration left holding no statements is
19
+ recorded and not executed, which is what lets a project keep one chain and no
20
+ second head.
15
21
  """
16
22
 
17
23
  import argparse
@@ -23,24 +29,23 @@ import uuid
23
29
  from dataclasses import dataclass
24
30
 
25
31
  from jinja2 import Environment, FileSystemLoader, PackageLoader, select_autoescape
26
- from pyspark.sql.functions import col
27
32
 
28
33
  from spark_sql_migrations.spark_utils import get_spark, is_dbr
29
34
 
30
35
  logger = logging.getLogger(__name__)
31
36
 
32
37
 
33
- ALL_SPARK = "all_spark"
34
- DBR_ONLY = "dbr_only"
35
-
36
38
  VERSION_TABLE = "_spark_migrations_version"
37
39
 
38
40
  PACKAGE_NAME = "spark_sql_migrations"
39
41
  INITIAL_MIGRATIONS_PACKAGE_PATH = "migrations_initial"
42
+ # where a run's rendered client migrations land, under its output folder
43
+ CLIENT_OUTPUT_DIRNAME = "migrations"
40
44
  TEMPLATES_PACKAGE_PATH = "migration_templates"
41
45
  DEFAULT_TEMPLATE_NAME = "default_migration_template.sql"
42
46
 
43
47
  _HEADER_RE = re.compile(r"^--\s*(revision_id|prev_revision_id)\s*:\s*(\w*)\s*;$")
48
+ _COMMENT_OR_BLANK_RE = re.compile(r"^\s*(--.*)?$")
44
49
 
45
50
 
46
51
  @dataclass(frozen=True)
@@ -50,11 +55,6 @@ class Migration:
50
55
  template_name: str
51
56
 
52
57
 
53
- def get_migrations_dir(migrations_root, migration_type):
54
- """the client-owned directory holding one chain."""
55
- return os.path.join(migrations_root, f"{migration_type}_migrations")
56
-
57
-
58
58
  def get_default_template_path():
59
59
  """the template this package ships; callers may pass their own instead."""
60
60
  return os.path.join(os.path.dirname(__file__), "..", TEMPLATES_PACKAGE_PATH, DEFAULT_TEMPLATE_NAME)
@@ -150,39 +150,56 @@ def get_migrations_list(migrations_dir):
150
150
  return get_ordered_migration_objs(migrations)
151
151
 
152
152
 
153
- def get_unapplied_migrations_list(spark, migrations_dir, migration_type, cat: str, schema: str):
154
- """the tail of one chain after the revision VERSION_TABLE records for it.
155
-
156
- the whole chain when the table is missing or holds no row for this type.
157
- """
158
- ordered = get_migrations_list(migrations_dir)
159
-
153
+ def get_table_version(spark, cat: str, schema: str):
154
+ """the revision VERSION_TABLE records, or None if it holds no row yet."""
160
155
  version_table = f"{cat}.{schema}.{VERSION_TABLE}"
161
156
  if not spark.catalog.tableExists(version_table):
162
- return ordered
157
+ return None
158
+ return spark.read.table(version_table).select("version_num").head()
163
159
 
164
- current = (
165
- spark.read.table(version_table).where(col("migration_type") == migration_type).select("version_num").head()
166
- )
160
+
161
+ def get_unapplied_migrations_list(spark, migrations_dir, cat: str, schema: str):
162
+ ordered = get_migrations_list(migrations_dir)
163
+ current = get_table_version(spark, cat, schema)
167
164
  if not current:
168
165
  return ordered
169
166
 
170
167
  current_revision_id = current["version_num"]
171
168
 
169
+ if not ordered:
170
+ logger.warning(
171
+ f"{VERSION_TABLE} is at {current_revision_id}, but {migrations_dir} holds no migrations; "
172
+ f"treating the chain as retired;"
173
+ )
174
+ return ordered
175
+
172
176
  for index, migration in enumerate(ordered):
173
177
  if migration.revision_id == current_revision_id:
174
178
  return ordered[index + 1 :]
175
179
 
176
- raise ValueError(
177
- f"{VERSION_TABLE} has {migration_type} at {current_revision_id}, "
178
- f"which matches no migration in {migrations_dir};"
179
- )
180
+ raise ValueError(f"{VERSION_TABLE} is at {current_revision_id}, which matches no migration in {migrations_dir};")
180
181
 
181
182
 
182
- def create_new_migration(message: str, template_path: str, output_path: str, truncate_slug_length: int = 40):
183
- """
184
- TODO XXX start looking up prev revision id from db or files and including it
183
+ def gate_on_is_dbr(migration_content):
184
+ """wrap a migration's body in the jinja conditional, below its headers.
185
+
186
+ the revision headers stay outside it: they are read off the file as comments,
187
+ and the first line that is neither comment nor blank ends that read -- so an
188
+ opening tag above them would hide them. a body-less template gets an empty
189
+ gate at the end, which renders to nothing either way.
185
190
  """
191
+ lines = migration_content.splitlines()
192
+ body_start = len(lines)
193
+ for index, line in enumerate(lines):
194
+ if line.strip() and not line.startswith("--"):
195
+ body_start = index
196
+ break
197
+ return "\n".join(lines[:body_start] + ["{% if is_dbr %}"] + lines[body_start:] + ["{% endif %}"]) + "\n"
198
+
199
+
200
+ def create_new_migration(
201
+ message: str, template_path: str, output_path: str, truncate_slug_length: int = 40, add_is_dbr: bool = False
202
+ ):
186
203
  rev_id = uuid.uuid4().hex[-12:]
187
204
 
188
205
  date_prefix = datetime.datetime.today().strftime("%y%m%d")
@@ -195,15 +212,24 @@ def create_new_migration(message: str, template_path: str, output_path: str, tru
195
212
 
196
213
  migration_path = os.path.join(output_path, migration_filename)
197
214
 
215
+ try:
216
+ ordered_migrations = get_migrations_list(output_path)
217
+ last_migration_rev_id = ordered_migrations[-1].revision_id if len(ordered_migrations) > 0 else ""
218
+ except ValueError:
219
+ last_migration_rev_id = ""
220
+
198
221
  default_migration_content = open(template_path).read()
199
222
  default_migration_content = default_migration_content.replace("{{revision_id}}", rev_id)
223
+ default_migration_content = default_migration_content.replace("{{prev_revision_id}}", last_migration_rev_id)
224
+ if add_is_dbr:
225
+ default_migration_content = gate_on_is_dbr(default_migration_content)
200
226
 
201
227
  with open(migration_path, "w") as migration_file:
202
228
  migration_file.write(default_migration_content)
203
229
 
204
230
 
205
231
  def apply_template(output_dir, template, cat: str, schema: str):
206
- result_sql = template.render(cat=cat, schema=schema)
232
+ result_sql = template.render(cat=cat, schema=schema, is_dbr=is_dbr())
207
233
  with open(os.path.join(output_dir, template.name.replace(".sql", "_primed.sql")), "w") as file_handle:
208
234
  file_handle.write(result_sql)
209
235
  return result_sql
@@ -220,83 +246,76 @@ def get_output_folder(output_parent_path):
220
246
  return os.path.join(output_parent_path, folder_name)
221
247
 
222
248
 
223
- def use_migration_file(fname):
224
- if fname.endswith("all.sql"):
225
- return True
226
- elif is_dbr() and fname.endswith("dbr_only.sql"):
227
- return True
228
- return False
249
+ def has_no_statements(rendered_sql):
250
+ for line in rendered_sql.splitlines():
251
+ if not _COMMENT_OR_BLANK_RE.match(line):
252
+ return False
253
+ return True
254
+
255
+
256
+ def render_and_apply(spark, output_folder, template, cat: str, schema: str):
257
+ """render one migration and run it, unless this engine was gated out of it."""
258
+ result_sql = apply_template(output_folder, template, cat=cat, schema=schema)
259
+ if has_no_statements(result_sql):
260
+ logger.info(f"{template.name} rendered no statements; nothing to apply here;")
261
+ return
262
+ spark.sql(result_sql)
229
263
 
230
264
 
231
265
  def migrate_initial(spark, output_folder, cat: str, schema: str):
232
- """apply the package's own bootstrap chain; selected by suffix, not revision."""
266
+ """apply the package's own bootstrap chain, in filename order rather than by revision."""
233
267
  env = Environment(
234
268
  loader=PackageLoader(package_name=PACKAGE_NAME, package_path=INITIAL_MIGRATIONS_PACKAGE_PATH),
235
269
  autoescape=select_autoescape(),
236
270
  )
237
- all_templates = env.list_templates(filter_func=use_migration_file)
271
+ all_templates = env.list_templates(extensions=["sql"])
238
272
 
239
273
  logger.info(f"found {len(all_templates)} migrations; first five are {all_templates[:5]};")
240
274
  for template_name in all_templates:
241
- result_sql = apply_template(output_folder, env.get_template(template_name), cat=cat, schema=schema)
242
- spark.sql(result_sql)
243
- logger.info(f"invoked spark on {len(all_templates)} migrations;")
244
-
275
+ render_and_apply(spark, output_folder, env.get_template(template_name), cat=cat, schema=schema)
276
+ logger.info(f"rendered {len(all_templates)} initial migrations;")
245
277
 
246
- def record_revision(spark, cat: str, schema: str, migration_type: str, revision_id: str):
247
- """point VERSION_TABLE's row for one migration type at revision_id.
248
278
 
249
- delete + insert rather than merge: there is one row per type, and a replay
250
- after a crash between the two statements is harmless because migrations are
251
- idempotent. the values are inlined rather than passed as `args` because
252
- delta's DELETE does not bind named parameters.
253
- """
279
+ def record_revision(spark, cat: str, schema: str, revision_id: str):
254
280
  version_table = f"{cat}.{schema}.{VERSION_TABLE}"
255
- type_literal = migration_type.replace("'", "''")
256
281
  revision_literal = revision_id.replace("'", "''")
257
- spark.sql(f"delete from {version_table} where migration_type = '{type_literal}'")
258
- spark.sql(
259
- f"insert into {version_table} (migration_type, version_num) " f"values ('{type_literal}', '{revision_literal}')"
260
- )
282
+ spark.sql(f"delete from {version_table}")
283
+ spark.sql(f"insert into {version_table} (version_num) values ('{revision_literal}')")
261
284
 
262
285
 
263
- def migrate_w_rev(spark, output_folder, migrations_root, cat: str, schema: str, migration_type: str):
286
+ def migrate_w_rev(spark, output_folder, migrations_dir, cat: str, schema: str):
264
287
  """
265
- apply the migrations of one client chain that VERSION_TABLE hasn't recorded yet.
266
288
  TODO XXX permit applying up to a revision below the head revision
267
289
  TODO XXX add backwards migrations, permit migrating backwards
268
290
  """
269
- migrations_dir = get_migrations_dir(migrations_root, migration_type)
270
- unapplied = get_unapplied_migrations_list(spark, migrations_dir, migration_type, cat=cat, schema=schema)
291
+ if not os.path.isdir(migrations_dir):
292
+ logger.info(f"no chain at {migrations_dir}; skipping it;")
293
+ return
294
+ unapplied = get_unapplied_migrations_list(spark, migrations_dir, cat=cat, schema=schema)
271
295
 
272
296
  logger.info(
273
- f"{migration_type} has {len(unapplied)} unapplied migrations; "
297
+ f"{len(unapplied)} unapplied migrations; "
274
298
  f"first five are {[migration.template_name for migration in unapplied[:5]]};"
275
299
  )
276
300
 
277
301
  env = Environment(loader=FileSystemLoader(migrations_dir), autoescape=select_autoescape())
278
302
  for migration in unapplied:
279
- result_sql = apply_template(output_folder, env.get_template(migration.template_name), cat=cat, schema=schema)
280
- spark.sql(result_sql)
281
- record_revision(spark, cat, schema, migration_type, migration.revision_id)
282
- logger.info(f"applied {migration.template_name}; {migration_type} is now at {migration.revision_id};")
303
+ render_and_apply(spark, output_folder, env.get_template(migration.template_name), cat=cat, schema=schema)
304
+ record_revision(spark, cat, schema, migration.revision_id)
305
+ logger.info(f"{migration.template_name} done; now at {migration.revision_id};")
283
306
 
284
307
 
285
- def run_migrations(spark, cat, schema, output_folder, migrations_root):
308
+ def run_migrations(spark, cat, schema, output_folder, migrations_dir):
286
309
  initial_output_folder = os.path.join(output_folder, INITIAL_MIGRATIONS_PACKAGE_PATH)
287
- dbr_ouput_folder = os.path.join(output_folder, f"{DBR_ONLY}_migrations")
288
- all_spark_ouput_folder = os.path.join(output_folder, f"{ALL_SPARK}_migrations")
310
+ client_output_folder = os.path.join(output_folder, CLIENT_OUTPUT_DIRNAME)
289
311
  os.makedirs(initial_output_folder, exist_ok=True)
290
- os.makedirs(dbr_ouput_folder, exist_ok=True)
291
- os.makedirs(all_spark_ouput_folder, exist_ok=True)
312
+ os.makedirs(client_output_folder, exist_ok=True)
292
313
  version_table = f"{cat}.{schema}.{VERSION_TABLE}"
293
314
  if spark.catalog.tableExists(version_table):
294
315
  logger.info(f"{version_table} already exists; skipping the initial migrations;")
295
316
  else:
296
317
  migrate_initial(spark, initial_output_folder, cat, schema)
297
- if is_dbr():
298
- migrate_w_rev(spark, dbr_ouput_folder, migrations_root, cat, schema, DBR_ONLY)
299
- migrate_w_rev(spark, all_spark_ouput_folder, migrations_root, cat, schema, ALL_SPARK)
318
+ migrate_w_rev(spark, client_output_folder, migrations_dir, cat, schema)
300
319
 
301
320
 
302
321
  def main(cat, schema, migrations_dir, output_parent_path=None):
@@ -309,8 +328,8 @@ def _cli_main(cat, schema, migrations_dir): # pragma: no cover
309
328
  main(cat, schema, migrations_dir)
310
329
 
311
330
 
312
- def _cli_create_new_migration(message, output_path, template_path): # pragma: no cover
313
- create_new_migration(message, template_path or get_default_template_path(), output_path)
331
+ def _cli_create_new_migration(message, output_path, template_path, add_is_dbr): # pragma: no cover
332
+ create_new_migration(message, template_path or get_default_template_path(), output_path, add_is_dbr=add_is_dbr)
314
333
 
315
334
 
316
335
  def build_parser(): # pragma: no cover
@@ -320,15 +339,24 @@ def build_parser(): # pragma: no cover
320
339
  p_run = subparsers.add_parser("run", help="apply all pending migrations")
321
340
  p_run.add_argument("--cat", default="spark_catalog")
322
341
  p_run.add_argument("--schema", default="default")
323
- p_run.add_argument(
324
- "--migrations-dir", required=True, help="parent of all_spark_migrations/ and dbr_only_migrations/"
325
- )
342
+ p_run.add_argument("--migrations-dir", required=True, help="the directory holding the migration chain")
326
343
  p_run.set_defaults(func=_cli_main)
327
344
 
328
345
  p_create = subparsers.add_parser("create_new_migration", help="create a new migration from the template")
329
346
  p_create.add_argument("--message", required=True)
330
- p_create.add_argument("--output-path", required=True, help="the chain directory to write the new migration into")
347
+ p_create.add_argument(
348
+ "--output-path",
349
+ required=True,
350
+ help="the chain directory to write the new migration into. if sql files are in there, the last sql's rev_id"
351
+ "will be set as the new migrations prev_rev_id.",
352
+ )
331
353
  p_create.add_argument("--template-path", default=None, help="defaults to the template shipped in this package")
354
+ # the %% are argparse's: it interpolates help strings
355
+ p_create.add_argument(
356
+ "--add-is-dbr",
357
+ action="store_true",
358
+ help="wrap the new migration's body in {%% if is_dbr %%}, for statements only databricks understands",
359
+ )
332
360
  p_create.set_defaults(func=_cli_create_new_migration)
333
361
 
334
362
  return parser
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spark_sql_migrations
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: Chained, idempotent SQL migrations for Spark and Databricks.
5
5
  Home-page: https://github.com/mcjug2015/spark_sql_migrations
6
6
  Author: Victor Semenov
@@ -79,28 +79,32 @@ Migrations come from two different owners, and they behave differently:
79
79
  | | ships in the wheel | owned by you |
80
80
  |---|---|---|
81
81
  | **what** | catalog, schema, `_spark_migrations_version` table | your tables and columns |
82
- | **where** | `spark_sql_migrations/migrations_initial/` | `all_spark_migrations/`, `dbr_only_migrations/` |
83
- | **selected by** | filename suffix — `_all.sql` everywhere, `_dbr_only.sql` on Databricks | the `prev_revision_id` chain |
82
+ | **where** | `spark_sql_migrations/migrations_initial/` | the directory you pass as `--migrations-dir` |
83
+ | **ordered by** | filename | the `prev_revision_id` chain |
84
84
  | **when applied** | until the version table exists | once, then recorded |
85
85
 
86
86
  The bootstrap chain is applied on every run until `_spark_migrations_version` exists, and
87
87
  skipped from then on. A run that fails partway through bootstrapping replays it, so it must
88
- still be idempotent. Your own chains are applied once each and their head revision is
88
+ still be idempotent. Your own migrations are applied once each and the head revision is
89
89
  written to the version table.
90
90
 
91
- ## Laying out your chains
91
+ Both kinds are rendered the same way, so both can gate a statement on `is_dbr` — the
92
+ bootstrap chain uses it for `CREATE CATALOG`, which only Unity Catalog has.
92
93
 
93
- Point spark_sql_migrations at a directory holding one or both chains:
94
+ ## Laying out your chain
95
+
96
+ `--migrations-dir` is the directory holding the chain — the `.sql` files sit in it directly:
94
97
 
95
98
  ```
96
99
  your_project/
97
- └── migrations/ <- this is --migrations-dir
98
- ├── all_spark_migrations/ <- runs everywhere
99
- └── dbr_only_migrations/ <- runs only when is_dbr()
100
+ └── migrations/ <- this is --migrations-dir
101
+ ├── 260831_01_create_test_table_e5ce0039b32b.sql
102
+ └── 260831_02_create_metrics_table_07990e2a101e.sql
100
103
  ```
101
104
 
102
- Choose deliberately. SQL that only Databricks understands must not live in
103
- `all_spark_migrations/`, or your local runs will break.
105
+ One chain covers both engines: a Databricks-only *statement* gates on `is_dbr` inside a
106
+ migration that otherwise runs everywhere — see below. SQL that only Databricks understands
107
+ must never sit ungated there, or your local runs will break.
104
108
 
105
109
  ## Running migrations
106
110
 
@@ -124,7 +128,7 @@ from spark_sql_migrations.spark_sql.spark_sql import main
124
128
 
125
129
 
126
130
  def get_migrations_dir():
127
- """the parent of this project's own migration chains."""
131
+ """the directory holding this project's own migration chain."""
128
132
  return os.path.dirname(__file__)
129
133
 
130
134
 
@@ -134,7 +138,7 @@ main(cat="spark_catalog", schema="default", migrations_dir=get_migrations_dir())
134
138
  `main` builds its own session via `get_spark()` and, being library code, leaves logging to
135
139
  you. To supply your own — a session already
136
140
  configured by your job, say — call `run_migrations(spark, cat, schema, output_folder,
137
- migrations_root)` directly.
141
+ migrations_dir)` directly.
138
142
 
139
143
  Every rendered statement is written to a timestamped folder under `migrations_out/`
140
144
  before it is executed, so you can always read the exact SQL a run applied.
@@ -144,7 +148,7 @@ before it is executed, so you can always read the exact SQL a run applied.
144
148
  Generate one rather than hand-rolling the header:
145
149
 
146
150
  ```bash
147
- spark-sql-migrations create_new_migration --message "add batch id to metrics" --output-path path/to/migrations/all_spark_migrations
151
+ spark-sql-migrations create_new_migration --message "add batch id to metrics" --output-path path/to/migrations
148
152
  ```
149
153
 
150
154
  That writes `<yymmdd>_<slug>_<revision_id>.sql` from the template shipped in the package
@@ -183,6 +187,44 @@ begin
183
187
  end;
184
188
  ```
185
189
 
190
+ ### Databricks-only statements
191
+
192
+ `is_dbr` is in the render context, so a migration in the shared chain can carry SQL only
193
+ Databricks understands:
194
+
195
+ ```sql
196
+ -- revision_id:e8614b76da6d;
197
+ -- prev_revision_id:57037b6c19b4;
198
+ {% if is_dbr %}
199
+ begin
200
+ grant READ FILES on external location `some-bucket` to `some_group`;
201
+ end;
202
+ {% endif %}
203
+ ```
204
+
205
+ `create_new_migration` writes that conditional for you with `--add-is-dbr`, below the
206
+ headers so they stay readable:
207
+
208
+ ```bash
209
+ spark-sql-migrations create_new_migration --message "read files on the cms zip external location" --output-path path/to/migrations --add-is-dbr
210
+ ```
211
+
212
+ This has to happen at render time, not in SQL. OSS Spark rejects `GRANT` and the rest of
213
+ its unsupported-native-command list while *parsing*, so a `declare exit handler` in the same
214
+ script never gets the chance to run — and the whole script fails to compile, not just that
215
+ statement. Jinja removes the text before `spark.sql()` ever sees it.
216
+
217
+ A migration left holding nothing but comments is recorded in the version table and not
218
+ executed, so the chain advances in step on both engines. That recording is forward-only: a
219
+ catalog migrated locally past a gated revision will not pick that statement up if it is
220
+ later migrated from Databricks. For disposable test schemas that is exactly what you want;
221
+ for a long-lived catalog, don't migrate it from both.
222
+
223
+ `_spark_migrations_version` holds one row and one column, `version_num`. There is no
224
+ in-place upgrade from the older two-chain shape, which carried a `migration_type` column and
225
+ a row per chain: a catalog bootstrapped by an earlier release has to be rebuilt from
226
+ scratch, which also clears the revision history that layout recorded.
227
+
186
228
  ## Local vs Databricks
187
229
 
188
230
  `get_spark()` returns a `DatabricksSession` when `is_dbr()` is true and a Delta-configured
@@ -13,8 +13,8 @@ spark_sql_migrations.egg-info/namespace_packages.txt
13
13
  spark_sql_migrations.egg-info/requires.txt
14
14
  spark_sql_migrations.egg-info/top_level.txt
15
15
  spark_sql_migrations/migration_templates/default_migration_template.sql
16
- spark_sql_migrations/migrations_initial/first_01_intial_create_cat_dbr_only.sql
17
- spark_sql_migrations/migrations_initial/first_02_intial_create_schema_all.sql
18
- spark_sql_migrations/migrations_initial/first_03_intial_create_state_table_all.sql
16
+ spark_sql_migrations/migrations_initial/first_01_intial_create_cat.sql
17
+ spark_sql_migrations/migrations_initial/first_02_intial_create_schema.sql
18
+ spark_sql_migrations/migrations_initial/first_03_intial_create_state_table.sql
19
19
  spark_sql_migrations/spark_sql/__init__.py
20
20
  spark_sql_migrations/spark_sql/spark_sql.py
@@ -1,4 +0,0 @@
1
- -- revision_id:;
2
- begin
3
- create catalog if not exists {{cat}};
4
- end;