Module: Sixty::Shape

Defined in:
lib/sixty/shape.rb

Overview

Argument shape: what a query is, with none of what it is about.

SECURITY BOUNDARY, and the document-database counterpart to Sixty::Sql.

For SQL, identity is the statement with its literals stripped — a string with things taken out of it. That approach has no analogue here. A Mongo filter is a tree in which the values sit beside the keys, arbitrarily deep, so there is no text to lex and nothing to remove: the values are structurally interleaved with the only part we are allowed to keep.

So this walk never removes anything. It emits keys, and a value has no path to the output at all — not a redaction that could miss a case, but a construction in which the unsafe half is unreachable. If you are tempted to add "just the value, when it is a small integer, for readability": don't. That is the change that turns a structural guarantee back into a filter with exceptions.

The other job is bounding cardinality, exactly as SQL normalization does. Identity that moved with the data would make every query a new operation, and an operation with no history has nothing to drift against.

Constant Summary collapse

MAX_DEPTH =

Bounds on the walk, so a pathological argument cannot produce an unbounded identity. Both sit far above anything a real query reaches, and the depth cap is also what makes a cyclic structure terminate.

6
MAX_KEYS =
24
MAX_STAGES =

How many stages of an ordered structure — an aggregation pipeline — count.

12

Class Method Summary collapse

Class Method Details

.shape_of(value, depth = 0) ⇒ Object

The shape of one argument tree: filter{createdAt{$gte},status}.

Keys are sorted, because two call sites writing the same query with the keys in a different order are the same query, and insertion order would make them two operations that never accumulate a shared history.

Only a Hash or an Array is structure. Everything else is a value, however many attributes it happens to carry — and that is not a nicety: a BSON::ObjectId, a Time, a BSON::Regexp or any application object has internals that are not the query's structure, and walking them would put meaningless keys into the identity of every query that filters by _id, which is most of them.



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/sixty/shape.rb', line 48

def shape_of(value, depth = 0)
  return '' if depth > MAX_DEPTH

  case value
  when Array
    # An array contributes the shape of its first element, never its length.
    # `{_id: {'$in' => [...]}}` with three ids and with three thousand is one
    # operation — the same bargain SQL normalization strikes when it folds
    # `in (?, ?, ?)` down to `in (?)`.
    value.empty? ? '' : shape_of(value.first, depth + 1)
  when Hash
    keys = value.keys.sort_by(&:to_s).first(MAX_KEYS)
    return '' if keys.empty?

    keys.map do |key|
      inner = shape_of(value[key], depth + 1)
      inner.empty? ? key.to_s : "#{key}{#{inner}}"
    end.join(',')
  else
    ''
  end
end

.shape_of_sequence(values) ⇒ Object

The shape of an ordered structure, where every element matters.

An aggregation pipeline is a list whose entries are deliberately different from one another — [{$match}, {$group}, {$sort}] — so it is structure, not data, and taking only the first element the way shape_of does would collapse every pipeline in the application to the shape of whatever it happened to start with. $match → $group and $match → $lookup → $unwind would be one operation, which is the difference between a working query and the N+1 that replaced it.



80
81
82
83
84
85
86
87
88
89
# File 'lib/sixty/shape.rb', line 80

def shape_of_sequence(values)
  return '' unless values.is_a?(Array) && !values.empty?

  stages = values.first(MAX_STAGES).map do |stage|
    inner = shape_of(stage, 1)
    inner.empty? ? '[]' : "[#{inner}]"
  end
  stages << '[…]' if values.length > MAX_STAGES
  stages.join
end