Module: SuperAuth::Nestable::ClassMethods

Defined in:
lib/super_auth/nestable.rb

Instance Method Summary collapse

Instance Method Details

#ancestor_pairs(of: nil) ⇒ Object

Every node paired with itself and each of its ancestors, as (descendant_id, ancestor_id). The path strategies join these integer pairs on equality; matching ids inside the comma-separated path strings with LIKE forced a nested loop no planner could index, and compile time grew roughly cubically with the graph.

Both pair CTEs recurse with UNION rather than UNION ALL. The pair relation is finite (at most n² rows), so UNION stops as soon as a step produces nothing new, which on a parent_id cycle is the first time round; UNION ALL re-derives the same pairs forever and compile! never returns. On a valid tree no step repeats a pair, so the output is the same.

of: (a dataset or an array of ids) restricts the anchor to those nodes, so only their ancestor chains are walked: one row per level.



103
104
105
106
107
108
109
110
111
# File 'lib/super_auth/nestable.rb', line 103

def ancestor_pairs(of: nil)
  table = pluralize
  name = :"#{singularize}_ancestor_pairs"
  anchor = db[table].select(Sequel[:id].as(:descendant_id), Sequel[:id].as(:ancestor_id))
  anchor = anchor.where(id: of) unless of.nil?
  step = db[name].join(table, id: :ancestor_id).exclude(Sequel[table][:parent_id] => nil).
    select(Sequel[name][:descendant_id], Sequel[table][:parent_id])
  db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id], union_all: false)
end

#assert_acyclic!Object

Refuses a table with a parent_id cycle in it, naming the nodes no root reaches. compile! calls this for groups, roles and resources before touching the compiled table, because a cycle does not fail a compile: the walks terminate, and every node in the cycle is an ancestor of every other, so a grant on any of them silently reaches all of their subtrees. validate refuses the shape at the model; this catches it after a write that went around the model.

Raises:



169
170
171
172
173
174
175
# File 'lib/super_auth/nestable.rb', line 169

def assert_acyclic!
  unreachable = dataset.exclude(id: rooted).select_order_map(:id)
  return if unreachable.empty?

  raise SuperAuth::Error, "#{pluralize} has a parent_id cycle: node(s) #{unreachable.join(', ')} " \
    "cannot be reached from any root. Point one of them at a root, or at no parent, and recompile."
end

#base_name_path(base = self) ⇒ Object



274
275
276
# File 'lib/super_auth/nestable.rb', line 274

def base_name_path(base = self)
  "#{singularize(base)}_name_path".to_sym
end

#base_path(base = self) ⇒ Object



270
271
272
# File 'lib/super_auth/nestable.rb', line 270

def base_path(base = self)
  "#{singularize(base)}_path".to_sym
end

#cte(id = nil, direction = :desc) ⇒ Object



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/super_auth/nestable.rb', line 177

def cte(id = nil, direction = :desc)
  model = self
  cte_name = model.cte_name
  base_ds = model.select_all(pluralize)

  case direction
  when :asc
    base_ds = base_ds.where(id: id)

    recursive_ds = model
      .join(cte_name, parent_id: :id)
      .select_all(pluralize)
    base_ds, recursive_ds = with_ascending_paths(base_ds, recursive_ds, cte_name)
  when :desc
    if id
      base_ds = base_ds.where(id: id)
    else
      base_ds = base_ds.where(parent_id: id)
    end

    recursive_ds = model
      .join(cte_name, id: :parent_id)
      .select_all(pluralize(model))

    base_ds, recursive_ds = with_descending_paths(base_ds, recursive_ds, cte_name)
  end

  model.from(cte_name)
    .with_recursive(cte_name, base_ds, recursive_ds)
end

#cte_name(base = self) ⇒ Object



266
267
268
# File 'lib/super_auth/nestable.rb', line 266

def cte_name(base = self)
  "super_auth_#{pluralize(base)}_cte".to_sym
end

#demodularize(base = self) ⇒ Object

See: ActiveSupport::Inflector.demodulize



250
251
252
253
254
255
256
# File 'lib/super_auth/nestable.rb', line 250

def demodularize(base = self)
  if i = base.name.rindex("::")
    base.name[(i + 2), base.name.length]
  else
    base.name
  end
end

#descend_from(parent) ⇒ Object

Whether descendant_pairs continues below a node: a Sequel condition on the parent row, addressed through the alias parent, or nil to descend from every node. Groups and roles descend from everything. SuperAuth::Resource stops at a type-level node, so a grant on one yields its own row and nothing beneath it on every path that reads the walk.



144
145
146
# File 'lib/super_auth/nestable.rb', line 144

def descend_from(parent)
  nil
end

#descendant_pairs(of: nil) ⇒ Object

Every node paired with itself and each of its descendants, as (ancestor_id, descendant_id). Granting a role grants its whole subtree.

of: (a dataset or an array of ids) restricts the anchor to those nodes, so only their subtrees are walked. Groups and roles are few and the whole table is cheap; resources are one row per protected record, and an unanchored CTE materialises every pair of the whole table once per strategy that joins it.

The recursive step joins the parent row only when the model puts a condition on it (descend_from); the pair CTE itself never carries more than the two ids.



125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/super_auth/nestable.rb', line 125

def descendant_pairs(of: nil)
  table = pluralize
  name = :"#{singularize}_descendant_pairs"
  parent = :"#{singularize}_parent"
  anchor = db[table].select(Sequel[:id].as(:ancestor_id), Sequel[:id].as(:descendant_id))
  anchor = anchor.where(id: of) unless of.nil?
  step = db[name].join(table, parent_id: :descendant_id).
    select(Sequel[name][:ancestor_id], Sequel[table][:id])
  if (condition = descend_from(parent))
    step = step.join(Sequel[table].as(parent), id: Sequel[name][:descendant_id]).where(condition)
  end
  db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id], union_all: false)
end

#path_cast_typeObject

Cast type for the anchor row of the path CTEs. MySQL types a recursive CTE's columns from the anchor SELECT alone, so a bare CAST(id AS CHAR) makes the path column varchar(11) and every deeper level overflows it ("Data too long for column"). :text is unbounded elsewhere.



80
81
82
83
84
85
86
87
# File 'lib/super_auth/nestable.rb', line 80

def path_cast_type
  case SuperAuth.db.database_type
  when :mysql, :mysql2
    "char(4000)"
  else
    :text
  end
end

#pluralize(base = self) ⇒ Object



258
259
260
# File 'lib/super_auth/nestable.rb', line 258

def pluralize(base = self)
  "super_auth_#{demodularize(base).downcase}s".to_sym
end

#rootedObject

Every node some root reaches, walking parent_id downward from the rows that have none. On a valid forest that is the whole table; what it misses is exactly the nodes on or under a parent_id cycle (and, where no foreign key stands, a node whose parent is missing). No path columns, unlike trees: this runs over the whole resources table before every compile, and needs only the ids.



154
155
156
157
158
159
160
# File 'lib/super_auth/nestable.rb', line 154

def rooted
  table = pluralize
  name = :"rooted_#{table}"
  anchor = db[table].where(parent_id: nil).select(:id)
  step = db[name].join(table, parent_id: :id).select(Sequel[table][:id])
  db.from(name).with_recursive(name, anchor, step, args: [:id], union_all: false)
end

#singularize(base = self) ⇒ Object



262
263
264
# File 'lib/super_auth/nestable.rb', line 262

def singularize(base = self)
  demodularize(base).downcase.to_sym
end

#string_cast_typeObject

Helper method to get the appropriate string cast type for the database



67
68
69
70
71
72
73
74
# File 'lib/super_auth/nestable.rb', line 67

def string_cast_type
  case SuperAuth.db.database_type
  when :mysql, :mysql2
    :char
  else
    :text
  end
end

#with_ascending_paths(base_ds, recursive_ds, cte_name) ⇒ Object



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/super_auth/nestable.rb', line 230

def with_ascending_paths(base_ds, recursive_ds, cte_name)
  [
    base_ds.select_append(Sequel[table_name][:id].cast(path_cast_type).as(base_path)).select_append(Sequel[table_name][:name].cast(path_cast_type).as(base_name_path)),
    recursive_ds.select_append(
      Sequel.function(:concat,
        Sequel[table_name][:id].cast(string_cast_type),
        Sequel.lit("','"),
        Sequel[cte_name][base_path].cast(string_cast_type),
      ).as(base_path)
    ).select_append(
       Sequel.function(:concat,
        Sequel[table_name][:name],
        Sequel.lit("','"),
        Sequel[cte_name][base_name_path],
      ).as(base_name_path)
    )
  ]
end

#with_descending_paths(base_ds, recursive_ds, cte_name) ⇒ Object



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/super_auth/nestable.rb', line 208

def with_descending_paths(base_ds, recursive_ds, cte_name)
  [
    base_ds.select_append(
      Sequel[table_name][:id].cast(path_cast_type).as(base_path)
    ).select_append(Sequel[table_name][:name].cast(path_cast_type).as(base_name_path)),

    recursive_ds.select_append(
      Sequel.function(:concat,
        Sequel[cte_name][base_path].cast(string_cast_type),
        Sequel.lit("','"),
        Sequel[pluralize][:id].cast(string_cast_type),
      ).as(base_path)
    ).select_append(
       Sequel.function(:concat,
        Sequel[cte_name][base_name_path],
        Sequel.lit("','"),
        Sequel[table_name][:name],
      ).as(base_name_path)
    )
  ]
end