dag_me 0.1.0 → 0.2.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: 700c8b25bcaadebf2b1d273e1d640585b0941a1b755e4e68b8b4cde46b4dcfc7
4
+ data.tar.gz: 17f8e047810f1fdcefedbf0717d34fab12482bdbc356a1391a999b31bf5988b9
5
5
  SHA512:
6
- metadata.gz: 9ddb66c78132f8766f56aa7d786a158968c09d83549121336597d755d2115bd6c85ef5158867926c9ab786379f6da2d331f3edb1cea2b1603c8b1bdef5e9fc21
7
- data.tar.gz: d16ee5de2122fe5a72ec4ffef79270c32f5d20a529781e1cbb0d15cfa27f9948f0c5c1f9b106066d28bf660b4edb56207eb3c016c8a3a21ed1becea67d4f9e4e
6
+ metadata.gz: 40c39bced539dbdab504c0f271d86dfb40efecdc28dde66f63077effd077fee53b303eb0e0e765ec32db000e9bfdaf59a415cd9db60f50ae98edf68545c50266
7
+ data.tar.gz: 047dd04b53fb61c367fca26038652e1bc4ca42984849565aec2ec45cf4917f94a8917cf829eec3bc36f4459c1941cd80f1d4209196afb793269343b962f8f6de
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
 
@@ -211,7 +210,7 @@ module DagMe
211
210
  # snapshot after the lock wait, so anything above READ COMMITTED is refused.
212
211
  def lock_function_sql
213
212
  <<~SQL
214
- CREATE OR REPLACE FUNCTION #{config.prefix}_lock(scope_key text) RETURNS void
213
+ CREATE OR REPLACE FUNCTION #{config.function_ref('lock')}(scope_key text) RETURNS void
215
214
  LANGUAGE plpgsql AS $$
216
215
  BEGIN
217
216
  IF current_setting('transaction_isolation') NOT IN ('read committed', 'read uncommitted') THEN
@@ -229,8 +228,7 @@ module DagMe
229
228
  # reject cross-scope edges, stamp NEW, and lock the scope. Unscoped graphs
230
229
  # just lock the single '' key.
231
230
  def edge_check_preamble
232
- p = config.prefix
233
- return " PERFORM #{p}_lock('');" unless scoped?
231
+ return " PERFORM #{config.function_ref('lock')}('');" unless scoped?
234
232
 
235
233
  stamps = config.scope_columns.map { |c| " NEW.#{c} := parent_row.#{c};" }.join("\n")
236
234
  # FOR SHARE pins both node rows so a concurrent scope UPDATE cannot race
@@ -243,7 +241,7 @@ module DagMe
243
241
  USING ERRCODE = '#{SQLSTATE_CROSS_SCOPE}';
244
242
  END IF;
245
243
  #{stamps}
246
- PERFORM #{p}_lock(#{scope_key_expr('parent_row')});
244
+ PERFORM #{config.function_ref('lock')}(#{scope_key_expr('parent_row')});
247
245
  SQL
248
246
  end
249
247
 
@@ -279,7 +277,7 @@ module DagMe
279
277
 
280
278
  def closure_edge_insert_check_sql
281
279
  <<~SQL
282
- CREATE OR REPLACE FUNCTION #{config.prefix}_edge_insert_check() RETURNS trigger
280
+ CREATE OR REPLACE FUNCTION #{config.function_ref('edge_insert_check')}() RETURNS trigger
283
281
  LANGUAGE plpgsql AS $$
284
282
  #{edge_check_declarations}
285
283
  BEGIN
@@ -300,7 +298,7 @@ module DagMe
300
298
  def edge_insert_apply_sql
301
299
  paths = config.paths_table
302
300
  <<~SQL
303
- CREATE OR REPLACE FUNCTION #{config.prefix}_edge_insert_apply() RETURNS trigger
301
+ CREATE OR REPLACE FUNCTION #{config.function_ref('edge_insert_apply')}() RETURNS trigger
304
302
  LANGUAGE plpgsql AS $$
305
303
  BEGIN
306
304
  -- ancestors-incl-self of parent x descendants-incl-self of child
@@ -328,10 +326,10 @@ module DagMe
328
326
  y_cols = pk_cols.map { |c| "y_#{c}" }
329
327
  rect_defs = (col_defs(x_cols) + col_defs(y_cols)).map { |d| " #{d}," }.join("\n")
330
328
  <<~SQL
331
- CREATE OR REPLACE FUNCTION #{p}_edge_delete_apply() RETURNS trigger
329
+ CREATE OR REPLACE FUNCTION #{config.function_ref('edge_delete_apply')}() RETURNS trigger
332
330
  LANGUAGE plpgsql AS $$
333
331
  BEGIN
334
- PERFORM #{p}_lock(#{scope_key_expr('OLD')});
332
+ PERFORM #{config.function_ref('lock')}(#{scope_key_expr('OLD')});
335
333
 
336
334
  -- The affected rectangle: every pair (x, y) with x reaching OLD's parent
337
335
  -- and OLD's child reaching y lost `removed` paths through this edge.
@@ -401,7 +399,7 @@ module DagMe
401
399
 
402
400
  def node_insert_function_sql
403
401
  <<~SQL
404
- CREATE OR REPLACE FUNCTION #{config.prefix}_node_insert() RETURNS trigger
402
+ CREATE OR REPLACE FUNCTION #{config.function_ref('node_insert')}() RETURNS trigger
405
403
  LANGUAGE plpgsql AS $$
406
404
  BEGIN
407
405
  INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
@@ -414,10 +412,10 @@ module DagMe
414
412
 
415
413
  def node_delete_function_sql
416
414
  <<~SQL
417
- CREATE OR REPLACE FUNCTION #{config.prefix}_node_delete() RETURNS trigger
415
+ CREATE OR REPLACE FUNCTION #{config.function_ref('node_delete')}() RETURNS trigger
418
416
  LANGUAGE plpgsql AS $$
419
417
  BEGIN
420
- PERFORM #{config.prefix}_lock(#{scope_key_expr('OLD')});
418
+ PERFORM #{config.function_ref('lock')}(#{scope_key_expr('OLD')});
421
419
  -- Remove edges through their triggers so the closure shrinks
422
420
  -- incrementally instead of relying on FK-cascade ordering.
423
421
  DELETE FROM #{config.edge_table}
@@ -441,7 +439,7 @@ module DagMe
441
439
  '-- edges only: nothing materialized to restamp'
442
440
  end
443
441
  <<~SQL
444
- CREATE OR REPLACE FUNCTION #{config.prefix}_node_update() RETURNS trigger
442
+ CREATE OR REPLACE FUNCTION #{config.function_ref('node_update')}() RETURNS trigger
445
443
  LANGUAGE plpgsql AS $$
446
444
  BEGIN
447
445
  IF #{scope_distinct_expr('NEW', 'OLD')} THEN
@@ -461,22 +459,23 @@ module DagMe
461
459
  end
462
460
 
463
461
  def closure_trigger_sql
464
- p = config.prefix
465
462
  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)
463
+ trigger_sql('edge_insert_check', 'BEFORE INSERT', config.edge_table),
464
+ trigger_sql('edge_insert_apply', 'AFTER INSERT', config.edge_table),
465
+ trigger_sql('edge_delete_apply', 'AFTER DELETE', config.edge_table),
466
+ trigger_sql('node_insert', 'AFTER INSERT', config.node_table),
467
+ trigger_sql('node_delete', 'BEFORE DELETE', config.node_table)
471
468
  ]
472
- triggers << trigger_sql("#{p}_node_update", 'BEFORE UPDATE', config.node_table) if scoped?
469
+ triggers << trigger_sql('node_update', 'BEFORE UPDATE', config.node_table) if scoped?
473
470
  triggers
474
471
  end
475
472
 
476
- def trigger_sql(name, timing, table)
473
+ # Trigger names are plain identifiers; the executed function carries the
474
+ # schema qualification.
475
+ def trigger_sql(suffix, timing, table)
477
476
  <<~SQL
478
- CREATE TRIGGER #{name} #{timing} ON #{table}
479
- FOR EACH ROW EXECUTE FUNCTION #{name}();
477
+ CREATE TRIGGER #{config.trigger_name(suffix)} #{timing} ON #{table}
478
+ FOR EACH ROW EXECUTE FUNCTION #{config.function_ref(suffix)}();
480
479
  SQL
481
480
  end
482
481
 
@@ -504,7 +503,7 @@ module DagMe
504
503
  scope_join = scoped? ? "JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 'walk')}" : ''
505
504
  scope_group = config.scope_columns.map { |c| ", n.#{c}" }.join
506
505
  <<~SQL
507
- CREATE OR REPLACE FUNCTION #{config.prefix}_rebuild_paths() RETURNS void
506
+ CREATE OR REPLACE FUNCTION #{config.function_ref('rebuild_paths')}() RETURNS void
508
507
  LANGUAGE plpgsql AS $$
509
508
  BEGIN
510
509
  LOCK TABLE #{config.node_table}, #{config.edge_table} IN SHARE ROW EXCLUSIVE MODE;
@@ -531,7 +530,7 @@ module DagMe
531
530
  .map { |d| " #{d}," }.join("\n")
532
531
  coalesced = (anc_cols + desc_cols).map { |c| "COALESCE(t.#{c}, s.#{c})" }.join(",\n ")
533
532
  <<~SQL
534
- CREATE OR REPLACE FUNCTION #{config.prefix}_validate_paths()
533
+ CREATE OR REPLACE FUNCTION #{config.function_ref('validate_paths')}()
535
534
  RETURNS TABLE(
536
535
  #{returns}
537
536
  stored_min_depth integer,
@@ -571,7 +570,7 @@ module DagMe
571
570
 
572
571
  def cte_edge_insert_check_sql
573
572
  <<~SQL
574
- CREATE OR REPLACE FUNCTION #{config.prefix}_edge_insert_check() RETURNS trigger
573
+ CREATE OR REPLACE FUNCTION #{config.function_ref('edge_insert_check')}() RETURNS trigger
575
574
  LANGUAGE plpgsql AS $$
576
575
  #{edge_check_declarations}
577
576
  BEGIN
@@ -593,9 +592,8 @@ module DagMe
593
592
  end
594
593
 
595
594
  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?
595
+ triggers = [trigger_sql('edge_insert_check', 'BEFORE INSERT', config.edge_table)]
596
+ triggers << trigger_sql('node_update', 'BEFORE UPDATE', config.node_table) if scoped?
599
597
  triggers
600
598
  end
601
599
  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.2.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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih