Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
CodecBzip2 = "523fee87-0ab8-5b00-afb7-3ecf72e48cfd"
CodecZlib = "944b1d66-785c-5afd-91f1-9de20f533193"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
JSONSchema = "7d188eb4-7ad8-530c-ae41-71a32a6d4692"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
MutableArithmetics = "d8a4904e-b15c-11e9-3269-09a3773c0cb0"
OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
Expand All @@ -25,3 +24,9 @@ JSONSchema = "0.3"
MutableArithmetics = "0.2"
OrderedCollections = "1"
julia = "1"

[extras]
JSONSchema = "7d188eb4-7ad8-530c-ae41-71a32a6d4692"

[targets]
test = ["JSONSchema"]
4 changes: 4 additions & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
[deps]
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
JSONSchema = "7d188eb4-7ad8-530c-ae41-71a32a6d4692"

[compat]
Documenter = "0.25"
JSON = "0.21"
JSONSchema = "0.3"
60 changes: 60 additions & 0 deletions docs/src/submodules/FileFormats/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,63 @@ A Mathematical Programming System (MPS) model

julia> read!(io, src_2)
```

## Validating MOF files

MathOptFormat files are governed by a schema. Use [JSONSchema.jl](https://github.com/fredo-dedup/JSONSchema.jl)
to check if a `.mof.json` file satisfies the schema.

First, construct the schema object as follows:
```jldoctest schema_mof
julia> import JSON, JSONSchema

julia> schema = JSONSchema.Schema(JSON.parsefile(MOI.FileFormats.MOF.SCHEMA_PATH))
A JSONSchema
```

Then, check if a model file is valid using `isvalid`:
```jldoctest schema_mof
julia> good_model = JSON.parse("""
{
"version": {
"major": 0,
"minor": 6
},
"variables": [{"name": "x"}],
"objective": {"sense": "feasibility"},
"constraints": []
}
""");

julia> isvalid(good_model, schema)
true
```

If we construct an invalid file, for example by mis-typing `name` as `NaMe`, the
validation fails:
```jldoctest schema_mof
julia> bad_model = JSON.parse("""
{
"version": {
"major": 0,
"minor": 6
},
"variables": [{"NaMe": "x"}],
"objective": {"sense": "feasibility"},
"constraints": []
}
""");

julia> isvalid(bad_model, schema)
false
```

Use `JSONSchema.validate` to obtain more insight into why the validation failed:
```jldoctest schema_mof
julia> JSONSchema.validate(bad_model, schema)
Validation failed:
path: [variables][1]
instance: Dict{String,Any}("NaMe"=>"x")
schema key: required
schema value: Any["name"]
```
58 changes: 5 additions & 53 deletions src/FileFormats/MOF/MOF.jl
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,12 @@ module MOF
import ..FileFormats
import OrderedCollections
import JSON
import JSONSchema
import MathOptInterface

const MOI = MathOptInterface

const SCHEMA_PATH = joinpath(@__DIR__, "mof.0.6.schema.json")
const VERSION = let data = JSON.parsefile(SCHEMA_PATH, use_mmap = false)
VersionNumber(
data["properties"]["version"]["properties"]["major"]["const"],
data["properties"]["version"]["properties"]["minor"]["const"],
)
end
const VERSION = v"0.6"

const OrderedObject = OrderedCollections.OrderedDict{String,Any}
const UnorderedObject = Dict{String,Any}
Expand Down Expand Up @@ -75,12 +70,11 @@ const Model = MOI.Utilities.UniversalFallback{InnerModel{Float64}}

struct Options
print_compact::Bool
validate::Bool
warn::Bool
end

function get_options(m::Model)
return get(m.model.ext, :MOF_OPTIONS, Options(false, false, false))
return get(m.model.ext, :MOF_OPTIONS, Options(false, false))
end

"""
Expand All @@ -93,19 +87,11 @@ Keyword arguments are:
- `print_compact::Bool=false`: print the JSON file in a compact format without
spaces or newlines.

- `validate::Bool=false`: validate each file prior to reading against the MOF
schema. Defaults to `false` because this can take a long time for large
models.

- `warn::Bool=false`: print a warning when variables or constraints are renamed
"""
function Model(;
print_compact::Bool = false,
validate::Bool = false,
warn::Bool = false,
)
function Model(; print_compact::Bool = false, warn::Bool = false)
model = MOI.Utilities.UniversalFallback(InnerModel{Float64}())
model.model.ext[:MOF_OPTIONS] = Options(print_compact, validate, warn)
model.model.ext[:MOF_OPTIONS] = Options(print_compact, warn)
return model
end

Expand All @@ -114,40 +100,6 @@ function Base.show(io::IO, ::Model)
return
end

"""
validate(filename::String)

Validate that the MOF file `filename` conforms to the MOF JSON schema. Returns
`nothing` if the file is valid, otherwise throws an error describing why the
file is not valid.
"""
function validate(filename::String)
FileFormats.compressed_open(
filename,
"r",
FileFormats.AutomaticCompression(),
) do io
return validate(io)
end
return
end

function validate(io::IO)
object = JSON.parse(io)
seekstart(io)
mof_schema =
JSONSchema.Schema(JSON.parsefile(SCHEMA_PATH, use_mmap = false))
ret = JSONSchema.validate(object, mof_schema)
if ret !== nothing
error(
"Unable to read file because it does not conform to the MOF " *
"schema: ",
ret,
)
end
return
end

include("nonlinear.jl")

include("read.jl")
Expand Down
4 changes: 0 additions & 4 deletions src/FileFormats/MOF/read.jl
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ function Base.read!(io::IO, model::Model)
if !MOI.is_empty(model)
error("Cannot read model from file as destination model is not empty.")
end
options = get_options(model)
if options.validate
validate(io)
end
object = JSON.parse(io; dicttype = UnorderedObject)
file_version = _parse_mof_version(object["version"]::UnorderedObject)
if file_version.major != VERSION.major || file_version.minor > VERSION.minor
Expand Down
47 changes: 31 additions & 16 deletions test/FileFormats/MOF/MOF.jl
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import JSON
import JSONSchema
import MathOptInterface
using Test

Expand All @@ -9,19 +11,40 @@ const TEST_MOF_FILE = "test.mof.json"

@test sprint(show, MOF.Model()) == "A MathOptFormat Model"

const SCHEMA =
JSONSchema.Schema(JSON.parsefile(MOI.FileFormats.MOF.SCHEMA_PATH))

function _validate(filename::String)
MOI.FileFormats.compressed_open(
filename,
"r",
MOI.FileFormats.AutomaticCompression(),
) do io
object = JSON.parse(io)
ret = JSONSchema.validate(object, SCHEMA)
if ret !== nothing
error(
"Unable to read file because it does not conform to the MOF " *
"schema: ",
ret,
)
end
end
end

include("nonlinear.jl")

struct UnsupportedSet <: MOI.AbstractSet end
struct UnsupportedFunction <: MOI.AbstractFunction end

function test_model_equality(model_string, variables, constraints; suffix = "")
model = MOF.Model(validate = true)
model = MOF.Model()
MOIU.loadfromstring!(model, model_string)
MOI.write_to_file(model, TEST_MOF_FILE * suffix)
model_2 = MOF.Model()
MOI.read_from_file(model_2, TEST_MOF_FILE * suffix)
MOIU.test_models_equal(model, model_2, variables, constraints)
return MOF.validate(TEST_MOF_FILE * suffix)
return _validate(TEST_MOF_FILE * suffix)
end

@testset "Error handling: read_from_file" begin
Expand Down Expand Up @@ -106,35 +129,27 @@ end
@test MOF.moi_to_object(c1, model, name_map)["name"] == "c_1"
@test MOF.moi_to_object(c2, model, name_map)["name"] == "c"
end
@testset "v0.4" begin
filename = joinpath(@__DIR__, "v0.4.mof.json")
model = MOF.Model(validate = true)
@test_throws ErrorException MOI.read_from_file(model, filename)
model = MOF.Model(validate = false)
MOI.read_from_file(model, filename)
@test MOI.get(model, MOI.NumberOfVariables()) == 2
end
end
@testset "round trips" begin
@testset "Empty model" begin
model = MOF.Model(validate = true)
model = MOF.Model()
MOI.write_to_file(model, TEST_MOF_FILE)
model_2 = MOF.Model(validate = true)
model_2 = MOF.Model()
MOI.read_from_file(model_2, TEST_MOF_FILE)
MOIU.test_models_equal(model, model_2, String[], String[])
end
@testset "FEASIBILITY_SENSE" begin
model = MOF.Model(validate = true)
model = MOF.Model()
x = MOI.add_variable(model)
MOI.set(model, MOI.VariableName(), x, "x")
MOI.set(model, MOI.ObjectiveSense(), MOI.FEASIBILITY_SENSE)
MOI.write_to_file(model, TEST_MOF_FILE)
model_2 = MOF.Model(validate = true)
model_2 = MOF.Model()
MOI.read_from_file(model_2, TEST_MOF_FILE)
MOIU.test_models_equal(model, model_2, ["x"], String[])
end
@testset "Empty function term" begin
model = MOF.Model(validate = true)
model = MOF.Model()
x = MOI.add_variable(model)
MOI.set(model, MOI.VariableName(), x, "x")
c = MOI.add_constraint(
Expand All @@ -144,7 +159,7 @@ end
)
MOI.set(model, MOI.ConstraintName(), c, "c")
MOI.write_to_file(model, TEST_MOF_FILE)
model_2 = MOF.Model(validate = true)
model_2 = MOF.Model()
MOI.read_from_file(model_2, TEST_MOF_FILE)
MOIU.test_models_equal(model, model_2, ["x"], ["c"])
end
Expand Down
4 changes: 2 additions & 2 deletions test/FileFormats/MOF/nonlinear.jl
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ end
read(joinpath(@__DIR__, "nlp.mof.json"), String),
'\r' => "",
)
MOF.validate(TEST_MOF_FILE)
return _validate(TEST_MOF_FILE)
end
@testset "Error handling" begin
node_list = MOF.Object[]
Expand Down Expand Up @@ -188,6 +188,6 @@ end
@test foo2.expr == :(2 * $x + sin($x)^2 - $y)
@test MOI.get(model, MOI.ConstraintSet(), con) ==
MOI.get(model2, MOI.ConstraintSet(), con2)
MOF.validate(TEST_MOF_FILE)
return _validate(TEST_MOF_FILE)
end
end