|
| 1 | +# Copyright 2024 - 2026 Block, Inc. |
| 2 | +# |
| 3 | +# Use of this source code is governed by an MIT-style |
| 4 | +# license that can be found in the LICENSE file or at |
| 5 | +# https://opensource.org/licenses/MIT. |
| 6 | +# |
| 7 | +# frozen_string_literal: true |
| 8 | + |
| 9 | +require "elastic_graph/constants" |
| 10 | +require "elastic_graph/json_ingestion/schema_definition/api_extension" |
| 11 | +require "elastic_graph/schema_definition/rake_tasks" |
| 12 | +require "fileutils" |
| 13 | +require "yaml" |
| 14 | + |
| 15 | +module ElasticGraph |
| 16 | + module JSONIngestion |
| 17 | + module SchemaDefinition |
| 18 | + RSpec.describe SchemaArtifactManagerExtension, :in_temp_dir, :rake_task do |
| 19 | + after do |
| 20 | + Thread.current[:eg_schema_load_count] = nil |
| 21 | + end |
| 22 | + |
| 23 | + it "dumps public JSON schemas and private versioned JSON schemas with ElasticGraph metadata" do |
| 24 | + write_schema(json_schema_version: 1) |
| 25 | + output = run_rake("schema_artifacts:dump") |
| 26 | + |
| 27 | + expect(output.lines).to include( |
| 28 | + a_string_including("Dumped", JSON_SCHEMAS_FILE), |
| 29 | + a_string_including("Dumped", versioned_json_schema_file(1)) |
| 30 | + ) |
| 31 | + |
| 32 | + public_id_schema = read_yaml_artifact(JSON_SCHEMAS_FILE).dig("$defs", "Widget", "properties", "id") |
| 33 | + versioned_id_schema = read_yaml_artifact(versioned_json_schema_file(1)).dig("$defs", "Widget", "properties", "id") |
| 34 | + |
| 35 | + expect(public_id_schema).to eq(json_schema_for_keyword_type("ID")) |
| 36 | + expect(versioned_id_schema).to eq(json_schema_for_keyword_type("ID", { |
| 37 | + "ElasticGraph" => { |
| 38 | + "type" => "ID!", |
| 39 | + "nameInIndex" => "id" |
| 40 | + } |
| 41 | + })) |
| 42 | + |
| 43 | + expect(run_rake("schema_artifacts:dump")).to include("is already up to date", JSON_SCHEMAS_FILE) |
| 44 | + end |
| 45 | + |
| 46 | + it "requires JSON schema version bumps unless enforcement is disabled" do |
| 47 | + write_schema(json_schema_version: 1) |
| 48 | + run_rake("schema_artifacts:dump") |
| 49 | + |
| 50 | + write_schema(json_schema_version: 2) |
| 51 | + expect { |
| 52 | + run_rake("schema_artifacts:dump") |
| 53 | + }.to change { read_artifact(JSON_SCHEMAS_FILE) } |
| 54 | + .from(a_string_including("\njson_schema_version: 1\n")) |
| 55 | + .to(a_string_including("\njson_schema_version: 2\n")) |
| 56 | + |
| 57 | + write_schema(json_schema_version: 2, extra_widget_body: "t.field 'color', 'String!'") |
| 58 | + expect { |
| 59 | + run_rake("schema_artifacts:dump") |
| 60 | + }.to abort_with a_string_including( |
| 61 | + "A change has been attempted to `json_schemas.yaml`", |
| 62 | + "`schema.json_schema_version 3`" |
| 63 | + ).and matching(/line \d+ at `(\S*\/?)schema\.rb`/) |
| 64 | + |
| 65 | + write_schema( |
| 66 | + json_schema_version: 2, |
| 67 | + extra_widget_body: "t.field 'color', 'String!'", |
| 68 | + enforce_json_schema_version: false |
| 69 | + ) |
| 70 | + |
| 71 | + expect(run_rake("schema_artifacts:dump")).to include( |
| 72 | + "WARNING: the `json_schemas.yaml` artifact is being updated without the `json_schema_version` being correspondingly incremented" |
| 73 | + ) |
| 74 | + end |
| 75 | + |
| 76 | + it "keeps field metadata up to date on every versioned JSON schema" do |
| 77 | + write_schema(json_schema_version: 1) |
| 78 | + run_rake("schema_artifacts:dump") |
| 79 | + |
| 80 | + write_schema(json_schema_version: 2, extra_widget_body: "t.field 'color', 'String!'") |
| 81 | + run_rake("schema_artifacts:dump") |
| 82 | + |
| 83 | + write_schema( |
| 84 | + json_schema_version: 2, |
| 85 | + name_field_suffix: ", name_in_index: 'name2'", |
| 86 | + extra_widget_body: "t.field 'color', 'String!'" |
| 87 | + ) |
| 88 | + run_rake("schema_artifacts:dump") |
| 89 | + |
| 90 | + loaded_v1 = read_yaml_artifact(versioned_json_schema_file(1)) |
| 91 | + loaded_v2 = read_yaml_artifact(versioned_json_schema_file(2)) |
| 92 | + |
| 93 | + expect(loaded_v1.dig("$defs", "Widget", "properties", "name")).to eq( |
| 94 | + json_schema_for_keyword_type("String", { |
| 95 | + "ElasticGraph" => { |
| 96 | + "type" => "String!", |
| 97 | + "nameInIndex" => "name2" |
| 98 | + } |
| 99 | + }) |
| 100 | + ) |
| 101 | + expect(loaded_v1.dig("$defs", "Widget", "properties", "color")).to eq(nil) |
| 102 | + |
| 103 | + expect(loaded_v2.dig("$defs", "Widget", "properties", "name")).to eq( |
| 104 | + json_schema_for_keyword_type("String", { |
| 105 | + "ElasticGraph" => { |
| 106 | + "type" => "String!", |
| 107 | + "nameInIndex" => "name2" |
| 108 | + } |
| 109 | + }) |
| 110 | + ) |
| 111 | + expect(loaded_v2.dig("$defs", "Widget", "properties", "color")).to eq( |
| 112 | + json_schema_for_keyword_type("String", { |
| 113 | + "ElasticGraph" => { |
| 114 | + "type" => "String!", |
| 115 | + "nameInIndex" => "color" |
| 116 | + } |
| 117 | + }) |
| 118 | + ) |
| 119 | + end |
| 120 | + |
| 121 | + it "gives clear errors for old schema versions with missing fields or types" do |
| 122 | + write_schema(json_schema_version: 8) |
| 123 | + run_rake("schema_artifacts:dump") |
| 124 | + write_schema(json_schema_version: 9, omit_widget_name_field: true) |
| 125 | + expect { run_rake("schema_artifacts:dump") }.to abort_with a_string_including( |
| 126 | + "The `Widget.name` field (which existed in JSON schema version 8) no longer exists", |
| 127 | + "at this old version", |
| 128 | + "delete its file from `json_schemas_by_version`" |
| 129 | + ) |
| 130 | + |
| 131 | + write_schema(json_schema_version: 9) |
| 132 | + run_rake("schema_artifacts:dump") |
| 133 | + write_schema(json_schema_version: 10, omit_widget_name_field: true) |
| 134 | + expect { run_rake("schema_artifacts:dump") }.to abort_with a_string_including( |
| 135 | + "The `Widget.name` field (which existed in JSON schema versions 8 and 9) no longer exists", |
| 136 | + "at these old versions", |
| 137 | + "delete their files from `json_schemas_by_version`" |
| 138 | + ) |
| 139 | + |
| 140 | + write_schema(json_schema_version: 10) |
| 141 | + run_rake("schema_artifacts:dump") |
| 142 | + write_schema(json_schema_version: 11, omit_widget_name_field: true) |
| 143 | + expect { run_rake("schema_artifacts:dump") }.to abort_with a_string_including( |
| 144 | + "The `Widget.name` field (which existed in JSON schema versions 8, 9, and 10) no longer exists" |
| 145 | + ) |
| 146 | + |
| 147 | + write_schema(json_schema_version: 11, omit_widget_name_field: true, extra_widget_body: "t.field('full_name', 'String') { |f| f.renamed_from 'name' }") |
| 148 | + run_rake("schema_artifacts:dump") |
| 149 | + |
| 150 | + delete_artifact(JSON_SCHEMAS_FILE) |
| 151 | + write_schema(json_schema_version: 11, omit_widget_name_field: true, extra_widget_body: "t.deleted_field 'name'") |
| 152 | + run_rake("schema_artifacts:dump") |
| 153 | + |
| 154 | + delete_artifacts |
| 155 | + write_schema(json_schema_version: 1) |
| 156 | + run_rake("schema_artifacts:dump") |
| 157 | + write_schema(json_schema_version: 2, widget_type_name: "Widget2") |
| 158 | + expect { run_rake("schema_artifacts:dump") }.to abort_with a_string_including( |
| 159 | + "The `Widget` type (which existed in JSON schema version 1) no longer exists", |
| 160 | + "If the `Widget` type has been renamed" |
| 161 | + ) |
| 162 | + end |
| 163 | + |
| 164 | + it "reports deprecated schema element warnings, conflicts, and missing necessary fields" do |
| 165 | + ::File.write("schema.rb", <<~EOS) |
| 166 | + ElasticGraph.define_schema do |schema| |
| 167 | + schema.json_schema_version 1 |
| 168 | + schema.deleted_type "SomeType" |
| 169 | +
|
| 170 | + schema.object_type "Widget" do |t| |
| 171 | + t.renamed_from "OldWidget" |
| 172 | + t.deleted_field "old_name" |
| 173 | + t.field "id", "ID!" |
| 174 | + t.field "name", "String" do |f| |
| 175 | + f.renamed_from "old_name" |
| 176 | + end |
| 177 | + t.index "widgets" |
| 178 | + end |
| 179 | + end |
| 180 | + EOS |
| 181 | + |
| 182 | + expect(run_rake("schema_artifacts:dump")).to include( |
| 183 | + "The schema definition has 4 unneeded reference(s)", |
| 184 | + "`schema.deleted_type \"SomeType\"`", |
| 185 | + "`type.renamed_from \"OldWidget\"`", |
| 186 | + "`type.deleted_field \"old_name\"`", |
| 187 | + "`field.renamed_from \"old_name\"`" |
| 188 | + ) |
| 189 | + |
| 190 | + delete_artifacts |
| 191 | + ::File.write("schema.rb", <<~EOS) |
| 192 | + ElasticGraph.define_schema do |schema| |
| 193 | + schema.json_schema_version 1 |
| 194 | + schema.deleted_type "Widget" |
| 195 | +
|
| 196 | + schema.object_type "Widget" do |t| |
| 197 | + t.field "id", "ID!" |
| 198 | + t.index "widgets" |
| 199 | +
|
| 200 | + t.field "token", "ID" do |f| |
| 201 | + f.renamed_from "id" |
| 202 | + end |
| 203 | + t.deleted_field "id" |
| 204 | + end |
| 205 | + end |
| 206 | + EOS |
| 207 | + |
| 208 | + expect { |
| 209 | + run_rake("schema_artifacts:dump") |
| 210 | + }.to abort_with a_string_including( |
| 211 | + "The schema definition of `Widget` has conflicts", |
| 212 | + "The schema definition of `Widget.id` has conflicts" |
| 213 | + ) |
| 214 | + |
| 215 | + delete_artifacts |
| 216 | + ::File.write("schema.rb", <<~EOS) |
| 217 | + ElasticGraph.define_schema do |schema| |
| 218 | + schema.json_schema_version 1 |
| 219 | +
|
| 220 | + schema.object_type "Embedded" do |t| |
| 221 | + t.field "workspace_id", "ID" |
| 222 | + t.field "created_at", "DateTime" |
| 223 | + end |
| 224 | +
|
| 225 | + schema.object_type "Widget" do |t| |
| 226 | + t.field "id", "ID" |
| 227 | + t.field "embedded", "Embedded" |
| 228 | + t.index "widgets" do |i| |
| 229 | + i.route_with "embedded.workspace_id" |
| 230 | + i.rollover :yearly, "embedded.created_at" |
| 231 | + end |
| 232 | + end |
| 233 | + end |
| 234 | + EOS |
| 235 | + |
| 236 | + run_rake("schema_artifacts:dump") |
| 237 | + |
| 238 | + ::File.write("schema.rb", <<~EOS) |
| 239 | + ElasticGraph.define_schema do |schema| |
| 240 | + schema.json_schema_version 2 |
| 241 | +
|
| 242 | + schema.object_type "Embedded" do |t| |
| 243 | + t.field "workspace_id2", "ID", name_in_index: "workspace_id" |
| 244 | + t.deleted_field "workspace_id" |
| 245 | +
|
| 246 | + t.field "created_at2", "DateTime", name_in_index: "created_at" |
| 247 | + t.deleted_field "created_at" |
| 248 | + end |
| 249 | +
|
| 250 | + schema.object_type "Widget" do |t| |
| 251 | + t.field "id", "ID" |
| 252 | + t.field "embedded", "Embedded" |
| 253 | + t.index "widgets" do |i| |
| 254 | + i.route_with "embedded.workspace_id2" |
| 255 | + i.rollover :yearly, "embedded.created_at2" |
| 256 | + end |
| 257 | + end |
| 258 | + end |
| 259 | + EOS |
| 260 | + |
| 261 | + expect { |
| 262 | + run_rake("schema_artifacts:dump") |
| 263 | + }.to abort_with a_string_including( |
| 264 | + "JSON schema version 1 has no field that maps to the routing field path of `Widget.embedded.workspace_id`", |
| 265 | + "JSON schema version 1 has no field that maps to the rollover field path of `Widget.embedded.created_at`" |
| 266 | + ) |
| 267 | + end |
| 268 | + |
| 269 | + def write_schema( |
| 270 | + json_schema_version:, |
| 271 | + enforce_json_schema_version: true, |
| 272 | + widget_type_name: "Widget", |
| 273 | + name_field_suffix: "", |
| 274 | + extra_widget_body: "", |
| 275 | + omit_widget_name_field: false |
| 276 | + ) |
| 277 | + ::File.write("schema.rb", <<~EOS) |
| 278 | + Thread.current[:eg_schema_load_count] = (Thread.current[:eg_schema_load_count] || 0) + 1 |
| 279 | + raise "Schema file was loaded more than once!" if Thread.current[:eg_schema_load_count] > 1 |
| 280 | +
|
| 281 | + ElasticGraph.define_schema do |schema| |
| 282 | + schema.json_schema_version #{json_schema_version} |
| 283 | + #{"schema.enforce_json_schema_version false" unless enforce_json_schema_version} |
| 284 | +
|
| 285 | + schema.object_type "#{widget_type_name}" do |t| |
| 286 | + t.field "id", "ID!" |
| 287 | + #{%(t.field "name", "String!"#{name_field_suffix}) unless omit_widget_name_field} |
| 288 | + #{extra_widget_body} |
| 289 | + t.index "widgets" |
| 290 | + end |
| 291 | + end |
| 292 | + EOS |
| 293 | + end |
| 294 | + |
| 295 | + def run_rake(*args) |
| 296 | + Thread.current[:eg_schema_load_count] = nil |
| 297 | + |
| 298 | + super(*args) do |output| |
| 299 | + ::ElasticGraph::SchemaDefinition::RakeTasks.new( |
| 300 | + schema_element_name_form: :snake_case, |
| 301 | + index_document_sizes: true, |
| 302 | + path_to_schema: "schema.rb", |
| 303 | + schema_artifacts_directory: "config/schema/artifacts", |
| 304 | + extension_modules: [APIExtension], |
| 305 | + output: output |
| 306 | + ) |
| 307 | + end |
| 308 | + end |
| 309 | + |
| 310 | + def read_artifact(*name_parts) |
| 311 | + path = ::File.join("config", "schema", "artifacts", *name_parts) |
| 312 | + ::File.exist?(path) && ::File.read(path) |
| 313 | + end |
| 314 | + |
| 315 | + def read_yaml_artifact(*name_parts) |
| 316 | + ::YAML.safe_load(read_artifact(*name_parts)) |
| 317 | + end |
| 318 | + |
| 319 | + def delete_artifact(*name_parts) |
| 320 | + ::File.delete(::File.join("config", "schema", "artifacts", *name_parts)) |
| 321 | + end |
| 322 | + |
| 323 | + def delete_artifacts |
| 324 | + ::FileUtils.rm_rf(::File.join("config", "schema", "artifacts")) |
| 325 | + end |
| 326 | + |
| 327 | + def versioned_json_schema_file(version) |
| 328 | + ::File.join(JSON_SCHEMAS_BY_VERSION_DIRECTORY, "v#{version}.yaml") |
| 329 | + end |
| 330 | + |
| 331 | + def json_schema_for_keyword_type(type, extras = {}) |
| 332 | + { |
| 333 | + "allOf" => [ |
| 334 | + {"$ref" => "#/$defs/#{type}"}, |
| 335 | + {"maxLength" => DEFAULT_MAX_KEYWORD_LENGTH} |
| 336 | + ] |
| 337 | + }.merge(extras) |
| 338 | + end |
| 339 | + end |
| 340 | + end |
| 341 | + end |
| 342 | +end |
0 commit comments