dag_me 0.1.0 → 0.3.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba16630068a6ea6417e5aee617ba2d9621e7e711dfc7d4459721b48f399d0da7
4
- data.tar.gz: 04ffb8914db06228c7b7851155f8b6b779f6dc86756e7b2f2dacf0d6be3541f4
3
+ metadata.gz: f9afbe234f1c36638c4a965eae2c76bfd93ffbc259b6f57fcf9c7ff57688176d
4
+ data.tar.gz: e50691a084e46d40e800f51635d7e1ab07e98fb865324a784a6ad88d51ccb93d
5
5
  SHA512:
6
- metadata.gz: 9ddb66c78132f8766f56aa7d786a158968c09d83549121336597d755d2115bd6c85ef5158867926c9ab786379f6da2d331f3edb1cea2b1603c8b1bdef5e9fc21
7
- data.tar.gz: d16ee5de2122fe5a72ec4ffef79270c32f5d20a529781e1cbb0d15cfa27f9948f0c5c1f9b106066d28bf660b4edb56207eb3c016c8a3a21ed1becea67d4f9e4e
6
+ metadata.gz: b6a321babe86ba1529efb4e30bfa742f2149e40d42cf9971c78b15810783eb425f7ba9d45b31d4def9a4a39e4f024f566f12d49dd52a8f30c2a3968c96134e16
7
+ data.tar.gz: 0b43392262483e5691e449c6eba57b7517e4649db776a5b6e29b50a1ca9868be83849f8297e69e47c564dd4988af127feead3c70978ffe7c3c92e3ea8949e25d
data/README.md CHANGED
@@ -136,6 +136,20 @@ end
136
136
  `:recursive_cte` skips the closure table entirely - good for small graphs, high mutation
137
137
  rates, and as the truth oracle. Cycle rejection stays in-database either way.
138
138
 
139
+ ## Schema-qualified tables
140
+
141
+ Node tables living in a named PostgreSQL schema work out of the box:
142
+
143
+ ```ruby
144
+ class Station < ApplicationRecord
145
+ self.table_name = 'orbital.stations'
146
+ dag_me
147
+ end
148
+ ```
149
+
150
+ Generated tables (`orbital.station_dag_edges`, `orbital.station_dag_paths`) and
151
+ functions land in the node table's schema; trigger names stay plain identifiers.
152
+
139
153
  ## Multi-tenancy
140
154
 
141
155
  ```ruby
@@ -4,7 +4,7 @@ module DagMe
4
4
  class Configuration
5
5
  MAINTAIN_MODES = %i[postgresql_closure recursive_cte].freeze
6
6
 
7
- attr_reader :model, :name, :maintain, :prefix, :edge_table, :paths_table, :scope_columns
7
+ attr_reader :model, :name, :maintain, :prefix, :schema, :edge_table, :paths_table, :scope_columns
8
8
 
9
9
  def initialize(model:, name: nil, maintain: :postgresql_closure, scope: nil, edge_table: nil, paths_table: nil)
10
10
  unless MAINTAIN_MODES.include?(maintain)
@@ -15,9 +15,13 @@ module DagMe
15
15
  @name = name&.to_sym
16
16
  @maintain = maintain
17
17
  @scope_columns = Array(scope).map(&:to_sym).freeze
18
- @prefix = [model.table_name.singularize, @name, 'dag'].compact.join('_')
19
- @edge_table = edge_table || "#{prefix}_edges"
20
- @paths_table = paths_table || "#{prefix}_paths"
18
+ # Schema-qualified node tables ("ai.skills") keep the prefix a plain
19
+ # identifier - trigger and temp-table names cannot carry a schema - and
20
+ # place generated tables and functions in the node table's schema.
21
+ @schema, base_table = split_schema(model.table_name)
22
+ @prefix = [base_table.singularize, @name, 'dag'].compact.join('_')
23
+ @edge_table = edge_table || qualify("#{prefix}_edges")
24
+ @paths_table = paths_table || qualify("#{prefix}_paths")
21
25
  # Ivar peek keeps class load DB-free; composite keys must be declared
22
26
  # before the macro. Array === because AR seeds a BasicObject sentinel.
23
27
  @composite_pk = model.instance_variable_defined?(:@primary_key) &&
@@ -28,6 +32,22 @@ module DagMe
28
32
  maintain == :postgresql_closure
29
33
  end
30
34
 
35
+ # "skill_dag_edges" -> "ai.skill_dag_edges" when the node table lives in a
36
+ # named schema; identity otherwise.
37
+ def qualify(identifier)
38
+ schema ? "#{schema}.#{identifier}" : identifier
39
+ end
40
+
41
+ # Schema-qualified reference for a generated function ("ai.skill_dag_lock").
42
+ def function_ref(suffix)
43
+ qualify("#{prefix}_#{suffix}")
44
+ end
45
+
46
+ # Trigger names are plain identifiers; placement comes from ON <table>.
47
+ def trigger_name(suffix)
48
+ "#{prefix}_#{suffix}"
49
+ end
50
+
31
51
  # The unnamed dag from a bare `dag_me` call. Named dags come from
32
52
  # `dag_me :power` and get name-prefixed tables, constants, and
33
53
  # associations so several graphs can coexist on one model.
@@ -117,6 +137,12 @@ module DagMe
117
137
 
118
138
  private
119
139
 
140
+ def split_schema(table_name)
141
+ return table_name.split('.', 2) if table_name.include?('.')
142
+
143
+ [nil, table_name]
144
+ end
145
+
120
146
  def role_columns(role)
121
147
  return ["#{role}_id"].freeze unless composite_pk?
122
148
 
data/lib/dag_me/ddl.rb CHANGED
@@ -77,22 +77,21 @@ module DagMe
77
77
  end
78
78
 
79
79
  def uninstall_sql
80
- p = config.prefix
81
80
  [
82
- "DROP TRIGGER IF EXISTS #{p}_node_insert ON #{config.node_table};",
83
- "DROP TRIGGER IF EXISTS #{p}_node_update ON #{config.node_table};",
84
- "DROP TRIGGER IF EXISTS #{p}_node_delete ON #{config.node_table};",
81
+ "DROP TRIGGER IF EXISTS #{config.trigger_name('node_insert')} ON #{config.node_table};",
82
+ "DROP TRIGGER IF EXISTS #{config.trigger_name('node_update')} ON #{config.node_table};",
83
+ "DROP TRIGGER IF EXISTS #{config.trigger_name('node_delete')} ON #{config.node_table};",
85
84
  "DROP TABLE IF EXISTS #{config.paths_table};",
86
85
  "DROP TABLE IF EXISTS #{config.edge_table};",
87
- "DROP FUNCTION IF EXISTS #{p}_lock(text);",
88
- "DROP FUNCTION IF EXISTS #{p}_edge_insert_check();",
89
- "DROP FUNCTION IF EXISTS #{p}_edge_insert_apply();",
90
- "DROP FUNCTION IF EXISTS #{p}_edge_delete_apply();",
91
- "DROP FUNCTION IF EXISTS #{p}_node_insert();",
92
- "DROP FUNCTION IF EXISTS #{p}_node_update();",
93
- "DROP FUNCTION IF EXISTS #{p}_node_delete();",
94
- "DROP FUNCTION IF EXISTS #{p}_rebuild_paths();",
95
- "DROP FUNCTION IF EXISTS #{p}_validate_paths();"
86
+ "DROP FUNCTION IF EXISTS #{config.function_ref('lock')}(text);",
87
+ "DROP FUNCTION IF EXISTS #{config.function_ref('edge_insert_check')}();",
88
+ "DROP FUNCTION IF EXISTS #{config.function_ref('edge_insert_apply')}();",
89
+ "DROP FUNCTION IF EXISTS #{config.function_ref('edge_delete_apply')}();",
90
+ "DROP FUNCTION IF EXISTS #{config.function_ref('node_insert')}();",
91
+ "DROP FUNCTION IF EXISTS #{config.function_ref('node_update')}();",
92
+ "DROP FUNCTION IF EXISTS #{config.function_ref('node_delete')}();",
93
+ "DROP FUNCTION IF EXISTS #{config.function_ref('rebuild_paths')}();",
94
+ "DROP FUNCTION IF EXISTS #{config.function_ref('validate_paths')}();"
96
95
  ]
97
96
  end
98
97
 
@@ -176,10 +175,20 @@ module DagMe
176
175
  config.scope_columns.map { |c| "#{left}.#{c} IS DISTINCT FROM #{right}.#{c}" }.join(' OR ')
177
176
  end
178
177
 
178
+ # Honors PostgreSQLAdapter.create_unlogged_tables (Rails test-env speed
179
+ # setting): a logged table cannot FK-reference an unlogged node table.
180
+ def create_table_clause
181
+ if ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables
182
+ 'CREATE UNLOGGED TABLE'
183
+ else
184
+ 'CREATE TABLE'
185
+ end
186
+ end
187
+
179
188
  def edges_table_sql
180
189
  defs = (col_defs(parent_cols) + col_defs(child_cols)).map { |d| " #{d}," }.join("\n")
181
190
  <<~SQL
182
- CREATE TABLE #{config.edge_table} (
191
+ #{create_table_clause} #{config.edge_table} (
183
192
  id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
184
193
  #{defs}
185
194
  created_at timestamptz NOT NULL DEFAULT now()#{scope_column_defs},
@@ -195,7 +204,7 @@ module DagMe
195
204
  def paths_table_sql
196
205
  defs = (col_defs(anc_cols) + col_defs(desc_cols)).map { |d| " #{d}," }.join("\n")
197
206
  <<~SQL
198
- CREATE TABLE #{config.paths_table} (
207
+ #{create_table_clause} #{config.paths_table} (
199
208
  #{defs}
200
209
  min_depth integer NOT NULL,
201
210
  path_count numeric NOT NULL#{scope_column_defs},
@@ -211,7 +220,7 @@ module DagMe
211
220
  # snapshot after the lock wait, so anything above READ COMMITTED is refused.
212
221
  def lock_function_sql
213
222
  <<~SQL
214
- CREATE OR REPLACE FUNCTION #{config.prefix}_lock(scope_key text) RETURNS void
223
+ CREATE OR REPLACE FUNCTION #{config.function_ref('lock')}(scope_key text) RETURNS void
215
224
  LANGUAGE plpgsql AS $$
216
225
  BEGIN
217
226
  IF current_setting('transaction_isolation') NOT IN ('read committed', 'read uncommitted') THEN
@@ -229,8 +238,7 @@ module DagMe
229
238
  # reject cross-scope edges, stamp NEW, and lock the scope. Unscoped graphs
230
239
  # just lock the single '' key.
231
240
  def edge_check_preamble
232
- p = config.prefix
233
- return " PERFORM #{p}_lock('');" unless scoped?
241
+ return " PERFORM #{config.function_ref('lock')}('');" unless scoped?
234
242
 
235
243
  stamps = config.scope_columns.map { |c| " NEW.#{c} := parent_row.#{c};" }.join("\n")
236
244
  # FOR SHARE pins both node rows so a concurrent scope UPDATE cannot race
@@ -243,7 +251,7 @@ module DagMe
243
251
  USING ERRCODE = '#{SQLSTATE_CROSS_SCOPE}';
244
252
  END IF;
245
253
  #{stamps}
246
- PERFORM #{p}_lock(#{scope_key_expr('parent_row')});
254
+ PERFORM #{config.function_ref('lock')}(#{scope_key_expr('parent_row')});
247
255
  SQL
248
256
  end
249
257
 
@@ -279,7 +287,7 @@ module DagMe
279
287
 
280
288
  def closure_edge_insert_check_sql
281
289
  <<~SQL
282
- CREATE OR REPLACE FUNCTION #{config.prefix}_edge_insert_check() RETURNS trigger
290
+ CREATE OR REPLACE FUNCTION #{config.function_ref('edge_insert_check')}() RETURNS trigger
283
291
  LANGUAGE plpgsql AS $$
284
292
  #{edge_check_declarations}
285
293
  BEGIN
@@ -300,7 +308,7 @@ module DagMe
300
308
  def edge_insert_apply_sql
301
309
  paths = config.paths_table
302
310
  <<~SQL
303
- CREATE OR REPLACE FUNCTION #{config.prefix}_edge_insert_apply() RETURNS trigger
311
+ CREATE OR REPLACE FUNCTION #{config.function_ref('edge_insert_apply')}() RETURNS trigger
304
312
  LANGUAGE plpgsql AS $$
305
313
  BEGIN
306
314
  -- ancestors-incl-self of parent x descendants-incl-self of child
@@ -328,10 +336,10 @@ module DagMe
328
336
  y_cols = pk_cols.map { |c| "y_#{c}" }
329
337
  rect_defs = (col_defs(x_cols) + col_defs(y_cols)).map { |d| " #{d}," }.join("\n")
330
338
  <<~SQL
331
- CREATE OR REPLACE FUNCTION #{p}_edge_delete_apply() RETURNS trigger
339
+ CREATE OR REPLACE FUNCTION #{config.function_ref('edge_delete_apply')}() RETURNS trigger
332
340
  LANGUAGE plpgsql AS $$
333
341
  BEGIN
334
- PERFORM #{p}_lock(#{scope_key_expr('OLD')});
342
+ PERFORM #{config.function_ref('lock')}(#{scope_key_expr('OLD')});
335
343
 
336
344
  -- The affected rectangle: every pair (x, y) with x reaching OLD's parent
337
345
  -- and OLD's child reaching y lost `removed` paths through this edge.
@@ -401,7 +409,7 @@ module DagMe
401
409
 
402
410
  def node_insert_function_sql
403
411
  <<~SQL
404
- CREATE OR REPLACE FUNCTION #{config.prefix}_node_insert() RETURNS trigger
412
+ CREATE OR REPLACE FUNCTION #{config.function_ref('node_insert')}() RETURNS trigger
405
413
  LANGUAGE plpgsql AS $$
406
414
  BEGIN
407
415
  INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
@@ -414,10 +422,10 @@ module DagMe
414
422
 
415
423
  def node_delete_function_sql
416
424
  <<~SQL
417
- CREATE OR REPLACE FUNCTION #{config.prefix}_node_delete() RETURNS trigger
425
+ CREATE OR REPLACE FUNCTION #{config.function_ref('node_delete')}() RETURNS trigger
418
426
  LANGUAGE plpgsql AS $$
419
427
  BEGIN
420
- PERFORM #{config.prefix}_lock(#{scope_key_expr('OLD')});
428
+ PERFORM #{config.function_ref('lock')}(#{scope_key_expr('OLD')});
421
429
  -- Remove edges through their triggers so the closure shrinks
422
430
  -- incrementally instead of relying on FK-cascade ordering.
423
431
  DELETE FROM #{config.edge_table}
@@ -441,7 +449,7 @@ module DagMe
441
449
  '-- edges only: nothing materialized to restamp'
442
450
  end
443
451
  <<~SQL
444
- CREATE OR REPLACE FUNCTION #{config.prefix}_node_update() RETURNS trigger
452
+ CREATE OR REPLACE FUNCTION #{config.function_ref('node_update')}() RETURNS trigger
445
453
  LANGUAGE plpgsql AS $$
446
454
  BEGIN
447
455
  IF #{scope_distinct_expr('NEW', 'OLD')} THEN
@@ -461,22 +469,23 @@ module DagMe
461
469
  end
462
470
 
463
471
  def closure_trigger_sql
464
- p = config.prefix
465
472
  triggers = [
466
- trigger_sql("#{p}_edge_insert_check", 'BEFORE INSERT', config.edge_table),
467
- trigger_sql("#{p}_edge_insert_apply", 'AFTER INSERT', config.edge_table),
468
- trigger_sql("#{p}_edge_delete_apply", 'AFTER DELETE', config.edge_table),
469
- trigger_sql("#{p}_node_insert", 'AFTER INSERT', config.node_table),
470
- trigger_sql("#{p}_node_delete", 'BEFORE DELETE', config.node_table)
473
+ trigger_sql('edge_insert_check', 'BEFORE INSERT', config.edge_table),
474
+ trigger_sql('edge_insert_apply', 'AFTER INSERT', config.edge_table),
475
+ trigger_sql('edge_delete_apply', 'AFTER DELETE', config.edge_table),
476
+ trigger_sql('node_insert', 'AFTER INSERT', config.node_table),
477
+ trigger_sql('node_delete', 'BEFORE DELETE', config.node_table)
471
478
  ]
472
- triggers << trigger_sql("#{p}_node_update", 'BEFORE UPDATE', config.node_table) if scoped?
479
+ triggers << trigger_sql('node_update', 'BEFORE UPDATE', config.node_table) if scoped?
473
480
  triggers
474
481
  end
475
482
 
476
- def trigger_sql(name, timing, table)
483
+ # Trigger names are plain identifiers; the executed function carries the
484
+ # schema qualification.
485
+ def trigger_sql(suffix, timing, table)
477
486
  <<~SQL
478
- CREATE TRIGGER #{name} #{timing} ON #{table}
479
- FOR EACH ROW EXECUTE FUNCTION #{name}();
487
+ CREATE TRIGGER #{config.trigger_name(suffix)} #{timing} ON #{table}
488
+ FOR EACH ROW EXECUTE FUNCTION #{config.function_ref(suffix)}();
480
489
  SQL
481
490
  end
482
491
 
@@ -504,7 +513,7 @@ module DagMe
504
513
  scope_join = scoped? ? "JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 'walk')}" : ''
505
514
  scope_group = config.scope_columns.map { |c| ", n.#{c}" }.join
506
515
  <<~SQL
507
- CREATE OR REPLACE FUNCTION #{config.prefix}_rebuild_paths() RETURNS void
516
+ CREATE OR REPLACE FUNCTION #{config.function_ref('rebuild_paths')}() RETURNS void
508
517
  LANGUAGE plpgsql AS $$
509
518
  BEGIN
510
519
  LOCK TABLE #{config.node_table}, #{config.edge_table} IN SHARE ROW EXCLUSIVE MODE;
@@ -531,7 +540,7 @@ module DagMe
531
540
  .map { |d| " #{d}," }.join("\n")
532
541
  coalesced = (anc_cols + desc_cols).map { |c| "COALESCE(t.#{c}, s.#{c})" }.join(",\n ")
533
542
  <<~SQL
534
- CREATE OR REPLACE FUNCTION #{config.prefix}_validate_paths()
543
+ CREATE OR REPLACE FUNCTION #{config.function_ref('validate_paths')}()
535
544
  RETURNS TABLE(
536
545
  #{returns}
537
546
  stored_min_depth integer,
@@ -571,7 +580,7 @@ module DagMe
571
580
 
572
581
  def cte_edge_insert_check_sql
573
582
  <<~SQL
574
- CREATE OR REPLACE FUNCTION #{config.prefix}_edge_insert_check() RETURNS trigger
583
+ CREATE OR REPLACE FUNCTION #{config.function_ref('edge_insert_check')}() RETURNS trigger
575
584
  LANGUAGE plpgsql AS $$
576
585
  #{edge_check_declarations}
577
586
  BEGIN
@@ -593,9 +602,8 @@ module DagMe
593
602
  end
594
603
 
595
604
  def cte_trigger_sql
596
- p = config.prefix
597
- triggers = [trigger_sql("#{p}_edge_insert_check", 'BEFORE INSERT', config.edge_table)]
598
- triggers << trigger_sql("#{p}_node_update", 'BEFORE UPDATE', config.node_table) if scoped?
605
+ triggers = [trigger_sql('edge_insert_check', 'BEFORE INSERT', config.edge_table)]
606
+ triggers << trigger_sql('node_update', 'BEFORE UPDATE', config.node_table) if scoped?
599
607
  triggers
600
608
  end
601
609
  end
data/lib/dag_me/graph.rb CHANGED
@@ -51,7 +51,7 @@ module DagMe
51
51
  return self unless config.closure?
52
52
 
53
53
  model.connection_pool.with_connection do |conn|
54
- conn.execute("SELECT #{conn.quote_table_name("#{config.prefix}_rebuild_paths")}();")
54
+ conn.execute("SELECT #{conn.quote_table_name(config.function_ref('rebuild_paths'))}();")
55
55
  end
56
56
  self
57
57
  end
@@ -62,7 +62,7 @@ module DagMe
62
62
  return [] unless config.closure?
63
63
 
64
64
  model.connection_pool.with_connection do |conn|
65
- conn.select_all("SELECT * FROM #{conn.quote_table_name("#{config.prefix}_validate_paths")}();").to_a
65
+ conn.select_all("SELECT * FROM #{conn.quote_table_name(config.function_ref('validate_paths'))}();").to_a
66
66
  end
67
67
  end
68
68
 
@@ -76,7 +76,7 @@ module DagMe
76
76
  JOIN pg_class c ON c.oid = t.tgrelid
77
77
  JOIN pg_namespace n ON n.oid = c.relnamespace
78
78
  WHERE NOT t.tgisinternal
79
- AND n.nspname = ANY (current_schemas(false))
79
+ AND #{namespace_filter(conn, config)}
80
80
  AND t.tgname LIKE #{conn.quote("#{config.prefix}\\_%")}
81
81
  SQL
82
82
  (expected - installed).map { |name| bad("trigger #{name} missing") }
@@ -96,13 +96,21 @@ module DagMe
96
96
  installed = conn.select_values(<<~SQL)
97
97
  SELECT p.proname FROM pg_proc p
98
98
  JOIN pg_namespace n ON n.oid = p.pronamespace
99
- WHERE n.nspname = ANY (current_schemas(false))
99
+ WHERE #{namespace_filter(conn, config)}
100
100
  AND p.proname LIKE #{conn.quote("#{config.prefix}\\_%")}
101
101
  SQL
102
102
  (expected - installed).map { |name| bad("function #{name} missing") }
103
103
  .presence || [ok("functions (#{expected.length})")]
104
104
  end
105
105
 
106
+ # Objects for schema-qualified node tables live in that schema regardless
107
+ # of search_path; unqualified ones resolve through it.
108
+ def namespace_filter(conn, config)
109
+ return "n.nspname = #{conn.quote(config.schema)}" if config.schema
110
+
111
+ 'n.nspname = ANY (current_schemas(false))'
112
+ end
113
+
106
114
  def closure_check(model, config)
107
115
  return ok('closure: not materialized (recursive_cte)') unless config.closure?
108
116
  return nil unless model.connection.table_exists?(config.paths_table)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DagMe
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dag_me
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih