From 7532dc62a9a7dbd769ff01311d59b77b3e4d0483 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 18:45:46 +0200 Subject: [PATCH] feat(orm): introduce Prism and Orm --- lib/prism/orm/serialization.rb | 111 +++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 lib/prism/orm/serialization.rb diff --git a/lib/prism/orm/serialization.rb b/lib/prism/orm/serialization.rb new file mode 100644 index 0000000..a3e15d9 --- /dev/null +++ b/lib/prism/orm/serialization.rb @@ -0,0 +1,111 @@ +module Prism + module Orm + # Mixed into Relation/HabtmCollection so `json(Post.all)`/`render json: + # post.comments` works the same way as a single record: each element is + # serialized through Model#as_json (so hide_from_json still applies to + # every record in the collection, not just a lone one). + module CollectionSerialization + def as_json(options = {}) + to_a.map { |record| record.as_json(options) } + end + + def to_json(*args) + as_json.to_json(*args) + end + end + + # ActiveModel::Serialization-lite: turns a record into a plain Hash + # (#serializable_hash) or a JSON string (#as_json/#to_json), Rails-style + # only:/except:/methods:/include:. + # + # Before this existed, `render json: record`/`json(record)` fell through + # to the stdlib json library's default Object#to_json (String#to_json of + # #to_s, e.g. "#") and hand-built hashes in controllers had + # no protection against leaking a raw column - notably SecurePassword's + # digest column, since Model#to_h dumps every attribute unfiltered. + # `hide_from_json` closes that: a class can mark a column excluded by + # default, and an explicit `only:` naming it is the one way to opt back + # in (a deliberate ask overrides the safety net, it does not disable it). + module Serialization + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + # Same superclass-plus-own fallback as Callbacks#callbacks_for / + # Controller#before_actions - a plain `@ivar ||= []` per class would + # silently drop a base class's hidden columns in every subclass. + def json_hidden_attributes + inherited = superclass.respond_to?(:json_hidden_attributes) ? superclass.json_hidden_attributes : [] + inherited + own_json_hidden_attributes + end + + def hide_from_json(*names) + own_json_hidden_attributes.concat(names.map(&:to_s)) + end + + def own_json_hidden_attributes + @own_json_hidden_attributes ||= [] + end + end + + def serializable_hash(only: nil, except: nil, methods: nil, include: nil) + only_names = only && Array(only).map(&:to_s) + except_names = Array(except).map(&:to_s) + hidden = self.class.json_hidden_attributes + + hash = to_h.each_with_object({}) do |(key, value), acc| + allowed = only_names ? only_names.include?(key) : !hidden.include?(key) && !except_names.include?(key) + acc[key] = value if allowed + end + + Array(methods).each { |name| hash[name.to_s] = send(name) } + merge_included_associations(hash, include) if include + hash + end + + def as_json(options = {}) + jsonify(serializable_hash(**options)) + end + + def to_json(*args) + as_json.to_json(*args) + end + + private + + def jsonify(value) + case value + when Hash then value.transform_values { |nested| jsonify(nested) } + when Array then value.map { |nested| jsonify(nested) } + when Time, Date, DateTime then value.iso8601 + else value + end + end + + def merge_included_associations(hash, include) + normalize_include(include).each do |name, nested_options| + associated = send(name) + hash[name.to_s] = serialize_association(associated, nested_options || {}) + end + end + + def serialize_association(associated, nested_options) + if associated.is_a?(Model) + associated.serializable_hash(**nested_options) + elsif associated.respond_to?(:map) + associated.map { |record| record.serializable_hash(**nested_options) } + end + end + + def normalize_include(include) + case include + when Symbol, String then { include.to_sym => nil } + when Array then include.each_with_object({}) { |name, acc| acc[name.to_sym] = nil } + when Hash then include + else {} + end + end + end + end +end