Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose discrete range types as their canonicalized value, omitting inclusive #2272

Open
3 of 6 tasks
the-sun-will-rise-tomorrow opened this issue Dec 6, 2024 · 4 comments
Assignees

Comments

@the-sun-will-rise-tomorrow
Copy link
Contributor

Feature description

Currently, discrete range types such as daterange are represented as:

type DateRange {
  start: DateRangeBound
  end: DateRangeBound
}

type DateRangeBound {
  value: Date!
  inclusive: Bool!
}

For discrete range types, we can convert them to their canonical representation, so that the start of the range is always inclusive and the end always exclusive. Then, we can simplify the representation to just

type DateRange {
  start: Date # always inclusive
  end: Date   # always exclusive
}

Motivating example

We have a lot of date ranges in our schema, and would prefer to avoid pushing the requirement to deal with the inclusive flag to clients.

Breaking changes

Yes, this would be a breaking change and should probably be optional (maybe using a smart tag).

Supporting development

I [tick all that apply]:

  • am interested in building this feature myself
  • am interested in collaborating on building this feature
  • am willing to help testing this feature before it's released
  • am willing to write a test-driven test suite for this feature (before it exists)
  • am a Graphile sponsor ❤️
  • have an active support or consultancy contract with Graphile
@github-project-automation github-project-automation bot moved this to 🌳 Triage in V5.0.0 Dec 6, 2024
@benjie
Copy link
Member

benjie commented Dec 6, 2024

Neat!

Here's an example of a plugin that adds a codec, and registers the GraphQL types for it:

https://github.com/graphile/crystal/blob/main/graphile-build/graphile-build-pg/src/plugins/PgLtreePlugin.ts

If you can use the existing codec, you may be able to get away with just this bit:

schema: {
hooks: {
init(_, build) {
const codec = build.input.pgRegistry.pgCodecs.ltree;
if (codec) {
const ltreeTypeName = build.inflection.builtin("LTree");
build.registerScalarType(
ltreeTypeName,
{ pgCodec: codec },
() => ({
description:
"Represents an `ltree` hierarchical label tree as outlined in https://www.postgresql.org/docs/current/ltree.html",
// TODO: specifiedByURL: https://postgraphile.org/scalars/ltree
}),
'Adding "LTree" scalar type from PgLtreePlugin.',
);
build.setGraphQLTypeForPgCodec(codec, "output", ltreeTypeName);
build.setGraphQLTypeForPgCodec(codec, "input", ltreeTypeName);
}
return _;
},
},

because as the PostgreSQL docs note:

The built-in range types int4range, int8range, and daterange all use a canonical form that includes the lower bound and excludes the upper bound; that is, [).

Here's where we register the range types:

if (!build.getTypeMetaByName(rangeTypeName)) {
build.registerObjectType(
rangeTypeName,
{
isPgRangeType: true,
pgCodec: codec,
},
() => ({
description: build.wrapDescription(
`A range of \`${underlyingOutputTypeName}\`.`,
"type",
),
fields: () => ({
start: {
description: build.wrapDescription(
"The starting bound of our range.",
"field",
),
type: build.getOutputTypeByName(rangeBoundTypeName!),
},
end: {
description: build.wrapDescription(
"The ending bound of our range.",
"field",
),
type: build.getOutputTypeByName(rangeBoundTypeName!),
},
}),
}),
"PgCodecsPlugin enum range output",
);
}
if (!build.getTypeMetaByName(rangeInputTypeName)) {
build.registerInputObjectType(
rangeInputTypeName,
{
isPgRangeInputType: true,
pgCodec: codec,
},
() => ({
description: build.wrapDescription(
`A range of \`${underlyingInputTypeName}\`.`,
"type",
),
fields: () => ({
start: {
description: build.wrapDescription(
"The starting bound of our range.",
"field",
),
type: build.getInputTypeByName(
rangeBoundInputTypeName,
),
},
end: {
description: build.wrapDescription(
"The ending bound of our range.",
"field",
),
type: build.getInputTypeByName(
rangeBoundInputTypeName,
),
},
}),
}),
"PgCodecsPlugin enum range input",
);
}
build.setGraphQLTypeForPgCodec(codec, "output", rangeTypeName);
build.setGraphQLTypeForPgCodec(
codec,
"input",
rangeInputTypeName,
);

As you can see, we check to see if the type already exists and only register it if it doesn't - so you can register the type ahead of time and it should Just Work ™️.

You should be able to write this as a plugin in your own codebase; and once you have it working we can look at merging it into core. Please let me know how you get on!

@the-sun-will-rise-tomorrow
Copy link
Contributor Author

the-sun-will-rise-tomorrow commented Jan 5, 2025

If you can use the existing codec, you may be able to get away with just this bit:

Sorry, I don't see how to make that work. We have to add somewhere some logic for encoding and decoding fields in the new simpler schema, right?

As far as I understand, this means either wrapping the existing codec in one which adjusts its inputs and outputs, or one which replaces it with a simpler variation.

I failed with the second approach because the existing codec implementation uses some internal functions.

After a few hours of trial and error, I have something that seems to work with the first approach, for date ranges only:

import { gatherConfig } from "graphile-build";

const discreteTypes = ["integer", "bigint", "date"];

export const MyRangePlugin: GraphileConfig.Plugin = {
  name: "MyRangePlugin",

  gather: gatherConfig({
    hooks: {
      async pgCodecs_findPgCodec(info, event) {
        const { pgCodec } = event;

        if (pgCodec && pgCodec.rangeOfCodec && discreteTypes.includes(pgCodec.rangeOfCodec.name)) {
          const oldCodec = pgCodec;
          const innerCodec = pgCodec.rangeOfCodec;
          const newCodec = {
            name: `simple${innerCodec.name}range`,
            sqlType: oldCodec.sqlType,
            fromPg(value: any) {
              const oldCodecOutput = oldCodec.fromPg(value);
              if ((oldCodecOutput.start !== null && oldCodecOutput.start.inclusive !== true) ||
                  (oldCodecOutput.end !== null && oldCodecOutput.end.inclusive !== false)) {
                throw new Error(`Range was not normalized: ${JSON.stringify(oldCodecOutput)}`);
              }
              return {
                start: oldCodecOutput.start?.value,
                end: oldCodecOutput.end?.value,
              };
            },
            toPg(value: any) {
              const oldCodecInput = {
                start: value.start === null ? null : { value: value.start, inclusive: true },
                end: value.end === null ? null : { value: value.end, inclusive: false },
              };
              return oldCodec.toPg(oldCodecInput);
            },
            castFromPg: oldCodec.castFromPg,
            rangeOfCodec: innerCodec,
            executor: null,
            attributes: undefined,
          };
          event.pgCodec = newCodec;
        }
      },
    },
  }),
  schema: {
    hooks: {
      init(_, build) {
        // TODO: how to find all our codecs?
        const pgCodec = build.input.pgRegistry.pgCodecs.simpledaterange;
        if (pgCodec) {
          const rangeTypeName = build.inflection.builtin("DateRange"); // TODO: How to get this programmatically?
          const rangeInputTypeName = build.inflection.inputType(rangeTypeName);
          build.registerObjectType(
            rangeTypeName,
            {
              isPgRangeType: true,
              pgCodec,
            },
            () => ({
              description: build.wrapDescription(
                `A range of dates.`,
                "type",
              ),
              fields: () => ({
                start: {
                  description: build.wrapDescription(
                    "The starting bound of our range.",
                    "field",
                  ),
                  type: build.getOutputTypeByName("Date"),
                },
                end: {
                  description: build.wrapDescription(
                    "The ending bound of our range.",
                    "field",
                  ),
                  type: build.getOutputTypeByName("Date"),
                },
              }),
            }),
            "MyRangePlugin range output",
          );
          build.registerInputObjectType(
            rangeInputTypeName,
            {
              isPgRangeInputType: true,
              pgCodec,
            },
            () => ({
              description: build.wrapDescription(
                `A range of dates.`,
                "type",
              ),
              fields: () => ({
                start: {
                  description: build.wrapDescription(
                    "The starting bound of our range.",
                    "field",
                  ),
                  type: build.getInputTypeByName("Date"),
                },
                end: {
                  description: build.wrapDescription(
                    "The ending bound of our range.",
                    "field",
                  ),
                  type: build.getInputTypeByName("Date"),
                },
              }),
            }),
            "MyRangePlugin range input",
          );
          build.setGraphQLTypeForPgCodec(pgCodec, "output", rangeTypeName);
          build.setGraphQLTypeForPgCodec(pgCodec, "input", rangeInputTypeName);
        }
        return _;
      },
    },
  },
};

I think you were implying that it should be possible to achieve this with a lot less code, what have I missed?

@benjie
Copy link
Member

benjie commented Jan 6, 2025

There's no need to replace the codec.

The built-in range types int4range, int8range, and daterange all use a canonical form that includes the lower bound and excludes the upper bound; that is, [).

^ The Postgres docs guarantee that these 3 range types are AUTOMATICALLY in canonical form.

So use the existing codec, and just change how it's expressed in GraphQL by writing plan resolvers on the fields. Very much like what you have but without the gather phase at all, and with plan() { ... } methods for each field.

@benjie
Copy link
Member

benjie commented Jan 6, 2025

(And inputPlan() on the input object type fields, IIRC.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: 🌳 Triage
Development

No branches or pull requests

2 participants