Rails jsonb - 防止在保存到Postgresql数据库时重排JSON键

17

我有一个名为amount_splits的列,需要按照我指定的键顺序将JSON保存到其中。

我该如何防止Rails / Postgres jsonb在保存到数据库时(创建或更新)自动对我的JSON键进行排序?

看起来它似乎试图按字母顺序排序,但做得很差。

以下是我正在保存的内容:

{
    "str_fee": 3.17,       # key 1
    "eva_fee": 14.37,      # key 2
    "fran_royalty": 14.37, # key 3
    "fran_amount": 67.09   # key 4
}

这是它实际保存的方式:

{
    "eva_fee": 14.37,     # key 2
    "str_fee": 3.17,      # key 1
    "fran_amount": 67.09, # key 4
    "fran_royalty": 14.37 # key 3
}

目的:

在回答“当JSON在接收端消耗时排序并不重要”的问题之前,请先停下来思考一下……并请继续阅读

我需要以我需要的方式对键进行排序,因为消耗此JSON的客户端接口向需要按照文档中所述的顺序排列键的开发人员显示JSON。而需要按照该顺序排列的原因是为了首先显示计算发生的过程:

正确的顺序告诉开发人员:

首先应用str_fee,然后是eva_fee,然后是fran_royalty... 使fran_amount成为最终金额。

但基于jsonb的排序方式,它会错误地告诉我们的开发人员:

首先应用eva_fee,然后是str_fee,然后是fran_amount... 使fran_royalty成为最终金额。


2
非常老的问题,你有任何解决方案吗? - Kamal Panhwar
1
是的,但答案仅为评论(来自于@mu is too short),因此无法“接受”。我不想接受自己的答案,而其他答案(尽管有喜欢和好信息)并不能像@mu is too short那样解决我的问题。以下是他的答案链接。基本上,您应该在jsonb列中使用数组([{str_fee: 6}, {eva_fee: 11}, ...])来保存顺序。 - skplunkerin
我更新了我的回答,包括mu的回答,并注明它是来自mu而不是我。我希望我能将他的评论转换为一个答案。 - skplunkerin
1
谢谢,我也遇到了困境。我已经创建了数十亿条数据,但现在无法在PostgreSQL中对其进行排序,因为我不清楚自定义字段的情况,人们可以随意添加任意数量的字段。我将通过添加动态日期/数字来解决这个问题,并在稍后使用Rails进行排序。 - Kamal Panhwar
@skplunkerin 顺便提一下,如果你自己找到了答案,并且没有其他人回答了你的问题,完全可以回答并接受自己的答案:) - nzifnab
4个回答

14
实际上,它们并非按字母顺序排序,而是先按键长度排序,然后再按字母顺序排序,这就解释了你得到的顺序。 jsonb 类型被创建为更好的 json 类型版本,用于编写和访问数据,可能是为了索引和搜索目的才更改了键的顺序。如果您希望键的顺序不发生变化,则可以使用 json 类型,在将数据存储在数据库中时不更改键的顺序。
希望能对您有所帮助。

这很有趣,我需要测试一下,谢谢。你有任何记录这些细节的参考资料或来源吗? - skplunkerin
2
相比之下,jsonb不保留空格,不保留对象键的顺序,也不保留重复的对象键。 - David Hempy

10

Postgres文档建议使用json类型来保留对象键的顺序:

一般情况下,大多数应用程序应该优先将JSON数据存储为jsonb,除非存在非常专门的需求,例如关于对象键顺序的遗留假设。


3

[更新于2021/02/12]请查看来自@mu is too short下方评论中我的“被接受”的答案(我不想接受自己的答案,因为这是一种Rails hack)。

基本上要保存jsonb列中的顺序,需要使用数组(即[{str_fee: 6}, {eva_fee: 11}, ...])。


[旧的hacky答案]

我找不到任何关于如何修改jsonb保存/更新行为的内容,但您可以控制从Rails Model返回as_json的方式。

因此,不要直接调用self.amount_splits列来返回JSON(它会以错误的键顺序返回)... 手动分解每个键

注意:这只适用于您提前知道键名的情况...如果键名是在您知道它们之前动态创建的,则需要尝试其他方法...很可能将JSON保存为字符串而不是哈希表。

class Transaction < ApplicationRecord
  store_accessor :amount_splits, :str_fee, :eva_fee, :fran_royalty, :fran_amount

  [...]

  def as_json(options={})
    # simple JSON response:
    json = {
      [...]
      "amount_splits"   => {
        "str_fee"       => self.str_fee,
        "eva_fee"       => self.eva_fee,
        "fran_royalty"  => self.fran_royalty,
        "fran_amount"   => self.fran_amount
      },
      [...]
    }
    return json
  end

  [...]

end

注意: 我已经显著地缩写了我的自定义as_json方法,只留下它将返回的JSON的相关部分。


1
你并没有错,但也不完全正确。JSON对象是"一组无序的名称/值对",因此数据库没有顺序需要保留。是的,Ruby哈希是有序的,(现代)JavaScript对象也是有序的,但JSON既不是前者也不是后者。数据库可以自由地以方便它的任何顺序返回键,浏览器中的JSON.parse或移动应用程序中的JSON解析器也是如此。你正在使用错误的数据结构。 - mu is too short
如果一个对象不是正确的数据结构,那么什么才是呢? - skplunkerin
3
数组会更适合。在Ruby、JSON、JavaScript、Go、Python等语言中,数组的顺序天然有序。类似[{str_fee: 6}, {eva_fee: 11}, ...][{type: 'str_fee', amount: 6}, {type: 'eva_fee', amount: 11}, ...]这样的结构将能够在各种情况下使用,并且如果需要的话也更方便地操纵它们的顺序。 - mu is too short
@muistooshort,这很有道理...虽然有点凌乱和不规范,但是完全可以可靠地工作。如果我需要在查看数据库时(在Rails / rails console之外)正确排序键,或者如果我开始拥有动态键而事先不知道名称,我会采用这种方法。感谢您的想法! - skplunkerin

0
你可以使用PostgreSQL的json类型并保持顺序。如果要利用jsonb的许多性能优势,则会失去本地顺序保留。
以下是一种保留顺序的方法,通过在每个键中注入数字索引:
class OrderedHashSerializer < ActiveRecord::Coders::JSON
  class << self
    def dump(obj)
      ActiveSupport::JSON.encode(
        dump_transform(obj)
      )
    end

    def load(json)
      json = ActiveSupport::JSON.decode(json) if json.is_a?(String)

      load_transform(json)
    end

    private

    # to indicate identifiers order as the postgresql jsonb type does not preserve order:
    def dump_transform(obj)
      obj.transform_keys.with_index do |key, index|
        "#{index + 1}_#{key}"
      end
    end

    def load_transform(hash)
      hash
        &.sort { |item, next_item| item.first.to_i <=> next_item.first.to_i }
        &.map { |key, value| format_item(key, value) }
        &.to_h
    end

    def format_item(key, value)
      [
        key.gsub(/^\d+_/, '').to_sym,
        value.in?([nil, true]) ? value : value.try(:to_sym) || value
      ]
    end
  end
end

注意,这将破坏在 SQL 查询中使用嵌入式 JSON 数据的能力,因为所有键名都会被污染。但如果您需要保留顺序而不是需要 JSON 查询,这是一种解决方案。(尽管必须承认,在那种情况下,json 类型开始看起来非常好)

测试看起来像:

describe OrderedHashSerializer do
  describe '#load' do
    subject(:invoke) { described_class.load(data) }

    let(:data) do
      {
        '1_error' => 'checksum_failure',
        '2_parent' => nil,
        '22_last_item' => 'omega',
        '3_code' => 'service_server_failure',
        '4_demographics': { age: %w[29], 'flavor' => %w[cherry vanilla rhubarb] }
      }.to_json
    end

    it 'formats data properly when loading it from database' do
      is_expected.to eq(
        error: :checksum_failure,
        parent: nil,
        last_item: :omega,
        code: :service_server_failure,
        demographics: { 'age' => ["29"], 'flavor' => %w[cherry vanilla rhubarb] },
      )
    end

    it 'preserves intended key order' do
      expect(invoke.keys.last).to eq :last_item
    end
  end

  describe '#dump' do
    subject(:invoke) { described_class.dump(data) }

    let(:data) do
      {
        'error' => 'checksum_failure',
        'parent' => nil,
        'code' => 'service_server_failure',
        demographics: { age: %w[65], 'flavor' => %w[cherry vanilla rhubarb] },
        'last_item' => 'omega'
      }
    end

    it 'prefixes keys with the numbers, in order' do
      is_expected.to eq(
        {
          "1_error" => :checksum_failure,
          "2_parent" => nil,
          "3_code" => :service_server_failure,
          "4_demographics" => { age: %w[65], flavor: %w[cherry vanilla rhubarb] },
          "5_last_item" => :omega
        }.to_json
      )
    end
  end
end

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接